use std::ops::Range;
use std::sync::Arc;
use crate::server_client::RagClient;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::ask::{AskSource, Thread, Turn, TurnStatus, citations, save};
use crate::components::Component;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx, AskData, FileOp, InputEvent};
use crate::components::panel::panel_block;
use crate::components::single_line_input::{InputOutcome, SingleLineInput};
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;
const COMPOSER_HEIGHT: u16 = 3;
const PAGE_OVERLAP: u16 = 2;
type PendingTurn = (String, Vec<(String, String)>, u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreadFocus {
Composer,
Turns,
}
enum RowSlot {
Turn(u64),
Answer {
turn_id: u64,
range: Range<usize>,
col_map: Vec<usize>,
},
}
pub struct ThreadPanel {
thread: Thread,
composer: SingleLineInput,
client: Option<Arc<RagClient>>,
focus: ThreadFocus,
scroll: u16,
follow_selection: bool,
bottom_follow_pending: bool,
turns_height: u16,
citation_target: Option<usize>,
turns_rect: Rect,
composer_rect: Rect,
row_map: Vec<RowSlot>,
icons: Icons,
}
impl ThreadPanel {
pub fn new() -> Self {
Self {
thread: Thread::default(),
composer: SingleLineInput::new(),
client: None,
focus: ThreadFocus::Composer,
scroll: 0,
follow_selection: true,
bottom_follow_pending: false,
turns_height: 0,
citation_target: None,
turns_rect: Rect::default(),
composer_rect: Rect::default(),
row_map: Vec::new(),
icons: Icons::new(false),
}
}
pub fn set_icons(&mut self, icons: Icons) {
self.icons = icons;
}
pub fn set_client(&mut self, client: Option<Arc<RagClient>>) {
self.client = client;
}
pub fn has_client(&self) -> bool {
self.client.is_some()
}
pub fn focus_composer(&mut self) {
self.focus = ThreadFocus::Composer;
}
pub fn thread(&self) -> &Thread {
&self.thread
}
pub fn thread_mut(&mut self) -> &mut Thread {
&mut self.thread
}
pub fn take_citation_target(&mut self) -> Option<usize> {
self.citation_target.take()
}
pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
match event {
InputEvent::Key(key) => self.handle_key(key, tx),
InputEvent::Mouse(mouse) => self.handle_mouse(mouse, tx),
InputEvent::Paste(_) => EventState::NotConsumed,
}
}
pub fn handle_data(&mut self, data: AskData) {
if let AskData::AnswerReady { turn_id, result } = data {
let completed_is_selected = self.thread.selected().map(|t| t.id) == Some(turn_id);
match result {
Ok((answer, sources)) => {
if self.thread.complete(turn_id, answer, sources) && completed_is_selected {
self.follow_bottom();
}
}
Err(e) => {
if self.thread.fail(turn_id, e) && completed_is_selected {
self.follow_bottom();
}
}
}
}
}
fn follow_bottom(&mut self) {
self.follow_selection = true;
self.bottom_follow_pending = true;
}
fn content_scroll_by(&mut self, delta: i32) {
self.follow_selection = false;
self.bottom_follow_pending = false;
self.scroll = if delta < 0 {
self.scroll.saturating_sub((-delta) as u16)
} else {
self.scroll.saturating_add(delta as u16)
};
}
fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
match self.focus {
ThreadFocus::Composer => self.handle_composer_key(key, tx),
ThreadFocus::Turns => self.handle_turns_key(key, tx),
}
}
fn handle_composer_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
if key.code == KeyCode::Esc {
self.focus = ThreadFocus::Turns;
return EventState::Consumed;
}
match self.composer.handle_key(key) {
InputOutcome::Submit => {
self.submit(tx);
EventState::Consumed
}
InputOutcome::NotConsumed => EventState::NotConsumed,
_ => EventState::Consumed,
}
}
fn handle_turns_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
let page = self.turns_height.saturating_sub(PAGE_OVERLAP).max(1) as i32;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.thread.select_prev();
self.follow_selection = true;
EventState::Consumed
}
KeyCode::Down | KeyCode::Char('j') => {
self.thread.select_next();
self.follow_selection = true;
EventState::Consumed
}
KeyCode::PageUp => {
self.content_scroll_by(-page);
EventState::Consumed
}
KeyCode::PageDown => {
self.content_scroll_by(page);
EventState::Consumed
}
KeyCode::Home => {
self.content_scroll_by(-(u16::MAX as i32));
EventState::Consumed
}
KeyCode::End => {
self.content_scroll_by(u16::MAX as i32);
EventState::Consumed
}
KeyCode::Char('i') | KeyCode::Char('/') => {
self.focus = ThreadFocus::Composer;
EventState::Consumed
}
KeyCode::Char('y') => {
self.copy_selected(tx);
EventState::Consumed
}
KeyCode::Char('e') => {
self.save_selected(tx);
EventState::Consumed
}
KeyCode::Char('r') => {
self.regenerate_selected(tx);
EventState::Consumed
}
_ => EventState::NotConsumed,
}
}
fn handle_mouse(&mut self, mouse: &MouseEvent, _tx: &AppTx) -> EventState {
let pos = Position {
x: mouse.column,
y: mouse.row,
};
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if self.composer_rect.contains(pos) {
self.focus = ThreadFocus::Composer;
return EventState::Consumed;
}
if !self.turns_rect.contains(pos) {
return EventState::NotConsumed;
}
self.focus = ThreadFocus::Turns;
self.click_turns(mouse);
EventState::Consumed
}
MouseEventKind::ScrollUp if self.turns_rect.contains(pos) => {
self.content_scroll_by(-1);
EventState::Consumed
}
MouseEventKind::ScrollDown if self.turns_rect.contains(pos) => {
self.content_scroll_by(1);
EventState::Consumed
}
_ => EventState::NotConsumed,
}
}
fn click_turns(&mut self, mouse: &MouseEvent) {
let idx = (mouse.row - self.turns_rect.y) as usize;
let hit = self.row_map.get(idx).map(|slot| match slot {
RowSlot::Turn(id) => (*id, None),
RowSlot::Answer {
turn_id,
range,
col_map,
} => (*turn_id, Some((range.clone(), col_map.clone()))),
});
let Some((turn_id, answer_hit)) = hit else {
return;
};
self.select_turn(turn_id);
let Some((range, col_map)) = answer_hit else {
return;
};
let col = mouse.column.saturating_sub(self.turns_rect.x);
let Some(turn) = self.thread.selected() else {
return;
};
let Some(citation_idx) = citation_at_column(&turn.answer[range], &col_map, col) else {
return;
};
if turn.source_for_citation(citation_idx).is_some() {
self.citation_target = Some(citation_idx);
}
}
fn select_turn(&mut self, id: u64) {
if self.thread.selected().map(|t| t.id) == Some(id) {
return;
}
let Some(target_idx) = self.thread.turns().iter().position(|t| t.id == id) else {
return;
};
self.thread.select_index(target_idx);
}
fn begin_turn(&mut self) -> Option<PendingTurn> {
self.client.as_ref()?;
let question = self.composer.take_text();
let question = question.trim().to_string();
if question.is_empty() {
return None;
}
let history = self.thread.history();
let turn_id = self.thread.ask(question.clone());
self.follow_bottom();
Some((question, history, turn_id))
}
fn submit(&mut self, tx: &AppTx) {
let Some((question, history, turn_id)) = self.begin_turn() else {
return;
};
let Some(client) = self.client.clone() else {
return;
};
Self::spawn_ask(tx, &client, question, history, turn_id);
}
pub(crate) fn regenerate_selected(&mut self, tx: &AppTx) {
let Some(client) = self.client.clone() else {
return;
};
let Some(id) = self.thread.selected().map(|t| t.id) else {
return;
};
let Some(question) = self.thread.regenerate(id) else {
return;
};
let history = self.thread.history();
Self::spawn_ask(tx, &client, question, history, id);
}
fn spawn_ask(
tx: &AppTx,
client: &Arc<RagClient>,
question: String,
history: Vec<(String, String)>,
turn_id: u64,
) {
let (tx, client) = (tx.clone(), client.clone());
tokio::spawn(async move {
let result = client
.ask(&question, &history, None)
.await
.map(|a| {
let sources = a
.sources
.into_iter()
.enumerate()
.map(|(i, c)| AskSource::from_chunk(i, c))
.collect();
(a.answer, sources)
})
.map_err(|e| e.to_string());
let _ = tx.send(AppEvent::Ask(AskData::AnswerReady { turn_id, result }));
});
}
pub(crate) fn copy_selected(&self, tx: &AppTx) {
let Some(turn) = self.thread.selected() else {
return;
};
let text = citations::strip(&turn.answer);
crate::components::yank(text, "answer copied", tx);
}
pub(crate) fn save_selected(&self, tx: &AppTx) {
let Some(turn) = self.thread.selected() else {
return;
};
let path = save::suggested_path(&turn.question);
let content = save::note_content(turn);
tx.send(AppEvent::FileOp(FileOp::ShowCreateWithContent {
path,
content,
}))
.ok();
}
fn render_turns(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
self.turns_rect = rect;
let question = Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::BOLD);
let separator = Style::default()
.fg(theme.gray.to_ratatui())
.add_modifier(Modifier::DIM);
let dim = Style::default().fg(theme.gray.to_ratatui());
let err = Style::default().fg(theme.red.to_ratatui());
let md = crate::components::markdown_lines::MdStyles::from_theme(theme);
let prompt = self.icons.question_prompt;
let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
let mut turn_start_row: Vec<(u64, u16)> = Vec::new();
for (i, turn) in self.thread.turns().iter().enumerate() {
turn_start_row.push((turn.id, rows.len() as u16));
render_turn(
turn,
rect.width,
i == 0,
prompt,
question,
separator,
dim,
err,
&md,
&mut rows,
);
}
let total = rows.len() as u16;
let height = rect.height;
self.turns_height = height;
if let Some(sel) = self.thread.selected()
&& let Some(&(_, start)) = turn_start_row.iter().find(|(id, _)| *id == sel.id)
{
let end = turn_start_row
.iter()
.map(|(_, s)| *s)
.filter(|s| *s > start)
.min()
.unwrap_or(total)
.saturating_sub(1);
if self.bottom_follow_pending {
if height > 0 {
self.scroll = end.saturating_sub(height - 1);
}
} else if self.follow_selection {
if start < self.scroll {
self.scroll = start;
} else if height > 0 && start >= self.scroll + height {
self.scroll = start.saturating_sub(height - 1);
}
}
}
self.bottom_follow_pending = false;
self.scroll = self.scroll.min(total.saturating_sub(height));
let selected_id = self.thread.selected().map(|t| t.id);
self.row_map.clear();
let mut lines: Vec<Line<'static>> = Vec::new();
for (slot, line) in rows
.into_iter()
.skip(self.scroll as usize)
.take(height as usize)
{
let row_turn_id = match &slot {
RowSlot::Turn(id) => *id,
RowSlot::Answer { turn_id, .. } => *turn_id,
};
let line = if focused && Some(row_turn_id) == selected_id {
line.style(Style::default().bg(theme.selection_bg.to_ratatui()))
} else {
line
};
self.row_map.push(slot);
lines.push(line);
}
f.render_widget(Paragraph::new(lines), rect);
}
fn render_composer(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
self.composer_rect = rect;
let enabled = self.client.is_some();
let title = if enabled {
"Ask a question"
} else {
"server unavailable"
};
let block = panel_block(title, theme, focused);
let inner = block.inner(rect);
f.render_widget(block, rect);
let style = if enabled {
Style::default().fg(theme.fg.to_ratatui())
} else {
Style::default()
.fg(theme.gray.to_ratatui())
.add_modifier(Modifier::DIM)
};
self.composer.render(f, inner, style, 0, focused && enabled);
}
}
impl Default for ThreadPanel {
fn default() -> Self {
Self::new()
}
}
impl Component for ThreadPanel {
fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(COMPOSER_HEIGHT)])
.split(rect);
self.render_turns(
f,
chunks[0],
theme,
focused && self.focus == ThreadFocus::Turns,
);
self.render_composer(
f,
chunks[1],
theme,
focused && self.focus == ThreadFocus::Composer,
);
}
fn hint_shortcuts(&self) -> Vec<(String, String)> {
match self.focus {
ThreadFocus::Composer => vec![
("Enter".into(), "Ask".into()),
("Esc".into(), "Turns".into()),
],
ThreadFocus::Turns => vec![
("j/k".into(), "Select".into()),
("PgUp/PgDn".into(), "Scroll".into()),
("i//".into(), "Compose".into()),
("y".into(), "Copy".into()),
("e".into(), "Save as note".into()),
("r".into(), "Regenerate".into()),
],
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_turn(
turn: &Turn,
width: u16,
is_first: bool,
prompt: &str,
question_style: Style,
sep_style: Style,
dim: Style,
err: Style,
md: &crate::components::markdown_lines::MdStyles,
out: &mut Vec<(RowSlot, Line<'static>)>,
) {
if !is_first {
out.push((RowSlot::Turn(turn.id), separator_line(width, sep_style)));
}
let question = format!("{prompt} {}", turn.question);
for qline in wrap_text(&question, width) {
out.push((
RowSlot::Turn(turn.id),
Line::from(Span::styled(question[qline].to_string(), question_style)),
));
}
match &turn.status {
TurnStatus::Thinking | TurnStatus::Streaming => {
out.push((
RowSlot::Turn(turn.id),
Line::from(Span::styled("… thinking", dim)),
));
}
TurnStatus::Error(msg) => {
let text = format!("✗ {msg}");
for eline in wrap_text(&text, width) {
out.push((
RowSlot::Turn(turn.id),
Line::from(Span::styled(text[eline].to_string(), err)),
));
}
out.push((
RowSlot::Turn(turn.id),
Line::from(Span::styled(" [r] retry", dim)),
));
}
TurnStatus::Done => render_answer(turn, width, md, out),
}
out.push((RowSlot::Turn(turn.id), Line::default()));
}
fn separator_line(width: u16, style: Style) -> Line<'static> {
Line::from(Span::styled("─".repeat(width as usize), style))
}
fn render_answer(
turn: &Turn,
width: u16,
md: &crate::components::markdown_lines::MdStyles,
out: &mut Vec<(RowSlot, Line<'static>)>,
) {
use crate::components::markdown_lines;
let logicals: Vec<&str> = turn
.answer
.split_inclusive('\n')
.map(|l| l.strip_suffix('\n').unwrap_or(l))
.collect();
let kinds = markdown_lines::classify_block_kinds(&logicals);
let mut offset = 0usize;
for (logical, &kind) in turn.answer.split_inclusive('\n').zip(kinds.iter()) {
let stripped = logical.strip_suffix('\n').unwrap_or(logical);
let line_start = offset;
for rel in wrap_text(stripped, width) {
let abs = (line_start + rel.start)..(line_start + rel.end);
let (line, col_map) =
markdown_lines::style_slice_mapped(&turn.answer[abs.clone()], kind, md);
out.push((
RowSlot::Answer {
turn_id: turn.id,
range: abs,
col_map,
},
line,
));
}
offset += logical.len();
}
}
fn citation_at_column(slice: &str, map: &[usize], col: u16) -> Option<usize> {
let mut w: u16 = 0;
for &raw in map {
let ch = slice[raw..].chars().next()?;
let cw = (ch.width().unwrap_or(0) as u16).max(1);
if col < w + cw {
return citations::scan(slice)
.into_iter()
.find(|c| c.range.contains(&raw))
.map(|c| c.index);
}
w += cw;
}
None
}
fn wrap_text(text: &str, width: u16) -> Vec<Range<usize>> {
let width = width.max(1) as usize;
let mut lines = Vec::new();
let mut para_start = 0;
for (i, ch) in text.char_indices() {
if ch == '\n' {
wrap_paragraph(text, para_start..i, width, &mut lines);
para_start = i + 1;
}
}
wrap_paragraph(text, para_start..text.len(), width, &mut lines);
lines
}
fn wrap_paragraph(text: &str, para: Range<usize>, width: usize, out: &mut Vec<Range<usize>>) {
let words = word_ranges(text, para.clone());
let Some(first) = words.first() else {
out.push(para.start..para.start);
return;
};
let mut line_start = first.start;
let mut line_end = first.end;
let mut line_w = text[first.clone()].width();
for w in &words[1..] {
let word_w = text[w.clone()].width();
if line_w + 1 + word_w > width {
out.push(line_start..line_end);
line_start = w.start;
line_end = w.end;
line_w = word_w;
} else {
line_end = w.end;
line_w += 1 + word_w;
}
}
out.push(line_start..line_end);
}
fn word_ranges(text: &str, range: Range<usize>) -> Vec<Range<usize>> {
let bytes = text.as_bytes();
let mut words = Vec::new();
let mut i = range.start;
while i < range.end {
while i < range.end && bytes[i] == b' ' {
i += 1;
}
if i >= range.end {
break;
}
let start = i;
while i < range.end && bytes[i] != b' ' {
i += 1;
}
words.push(start..i);
}
words
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyModifiers;
fn test_client() -> Arc<RagClient> {
Arc::new(RagClient::new(
"http://localhost:0".to_string(),
None,
"vault".to_string(),
))
}
fn test_panel() -> ThreadPanel {
let mut p = ThreadPanel::new();
p.composer.set_value("q");
p
}
fn test_panel_online() -> ThreadPanel {
let mut p = ThreadPanel::new();
p.set_client(Some(test_client()));
p
}
fn p_handle_enter(p: &mut ThreadPanel) -> EventState {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
p.handle_input(&InputEvent::Key(key), &tx)
}
#[test]
fn new_thread_panel_starts_empty_without_client_and_composer_focus() {
let panel = ThreadPanel::new();
assert!(panel.thread().is_empty());
assert!(!panel.has_client());
assert_eq!(panel.focus, ThreadFocus::Composer);
}
#[test]
fn set_client_toggles_the_composer_enable_signal() {
let mut panel = ThreadPanel::new();
assert!(!panel.has_client());
panel.set_client(Some(test_client()));
assert!(panel.has_client());
panel.set_client(None);
assert!(!panel.has_client());
}
#[test]
fn thread_mut_allows_mutating_the_conversation() {
let mut panel = ThreadPanel::new();
panel.thread_mut().ask("q?".to_string());
assert_eq!(panel.thread().turns().len(), 1);
}
#[tokio::test]
async fn enter_submits_only_with_a_client() {
let mut p = test_panel(); let _ = p_handle_enter(&mut p);
assert!(p.thread().is_empty(), "no client → no turn");
p.set_client(Some(test_client()));
let _ = p_handle_enter(&mut p);
assert_eq!(p.thread().turns().len(), 1);
assert!(matches!(
p.thread().selected().unwrap().status,
TurnStatus::Thinking
));
}
#[test]
fn answer_ready_completes_matching_turn_only() {
let mut p = test_panel_online();
let id = p.thread_mut().ask("q".into());
p.handle_data(AskData::AnswerReady {
turn_id: 999,
result: Ok(("x".into(), vec![])),
});
assert!(matches!(
p.thread().selected().unwrap().status,
TurnStatus::Thinking
));
p.handle_data(AskData::AnswerReady {
turn_id: id,
result: Ok(("a".into(), vec![])),
});
assert!(matches!(
p.thread().selected().unwrap().status,
TurnStatus::Done
));
}
#[test]
fn begin_turn_is_none_without_a_client() {
let mut p = ThreadPanel::new(); p.composer.set_value("hello");
assert!(p.begin_turn().is_none());
assert!(
p.thread().is_empty(),
"no client → no orphaned Thinking turn"
);
}
#[test]
fn begin_turn_is_none_when_composer_empty() {
let mut p = ThreadPanel::new();
p.set_client(Some(test_client()));
p.composer.set_value(" ");
assert!(p.begin_turn().is_none());
assert!(p.thread().is_empty());
}
#[test]
fn begin_turn_pushes_a_thinking_turn_and_selects_it() {
let mut p = ThreadPanel::new();
p.set_client(Some(test_client()));
p.composer.set_value("hello");
let (question, history, turn_id) = p.begin_turn().expect("client + non-empty");
assert_eq!(question, "hello");
assert!(history.is_empty());
assert_eq!(p.thread().turns().len(), 1);
assert_eq!(p.thread().selected().unwrap().id, turn_id);
}
#[test]
fn esc_in_composer_moves_focus_to_turns() {
let mut p = ThreadPanel::new();
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let state = p.handle_input(&InputEvent::Key(key), &tx);
assert_eq!(state, EventState::Consumed);
assert_eq!(p.focus, ThreadFocus::Turns);
}
#[test]
fn jk_in_turns_moves_selection() {
let mut p = ThreadPanel::new();
let first = p.thread_mut().ask("a".into());
p.thread_mut().complete(first, "a!".into(), vec![]);
let second = p.thread_mut().ask("b".into());
p.thread_mut().complete(second, "b!".into(), vec![]);
p.focus = ThreadFocus::Turns;
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
p.handle_input(&InputEvent::Key(key), &tx);
assert_eq!(p.thread().selected().unwrap().id, first);
}
#[test]
fn regenerate_without_a_client_does_nothing() {
let mut p = ThreadPanel::new();
let id = p.thread_mut().ask("q".into());
p.thread_mut().complete(id, "a".into(), vec![]);
p.focus = ThreadFocus::Turns;
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
p.handle_input(&InputEvent::Key(key), &tx);
assert!(
matches!(p.thread().selected().unwrap().status, TurnStatus::Done),
"no client → the completed turn stays Done"
);
assert_eq!(p.thread().selected().unwrap().id, id);
}
#[test]
fn i_and_slash_move_focus_to_composer() {
for ch in ['i', '/'] {
let mut p = ThreadPanel::new();
p.focus = ThreadFocus::Turns;
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
p.handle_input(&InputEvent::Key(key), &tx);
assert_eq!(p.focus, ThreadFocus::Composer);
}
}
#[test]
fn wrap_text_breaks_on_spaces_within_width() {
let lines = wrap_text("one two three", 7);
let text = "one two three";
let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
assert_eq!(rendered, vec!["one two", "three"]);
}
#[test]
fn wrap_text_keeps_an_overlong_word_on_its_own_line() {
let lines = wrap_text("a superlongword b", 5);
let text = "a superlongword b";
let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
assert_eq!(rendered, vec!["a", "superlongword", "b"]);
}
#[test]
fn wrap_text_forces_a_break_on_newline() {
let lines = wrap_text("a\nb", 10);
let text = "a\nb";
let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
assert_eq!(rendered, vec!["a", "b"]);
}
fn identity_map(slice: &str) -> Vec<usize> {
slice.char_indices().map(|(i, _)| i).collect()
}
#[test]
fn citation_at_column_finds_the_marker_under_the_click() {
let text = "Fact [1] more";
let map = identity_map(text);
let idx = citation_at_column(text, &map, 5);
assert_eq!(idx, Some(1));
let idx = citation_at_column(text, &map, 0);
assert_eq!(idx, None);
}
#[test]
fn citation_hit_test_resolves_through_hidden_emphasis() {
use crate::components::markdown_lines::{self, LineKind, MdStyles};
let md = MdStyles::from_theme(&Theme::default());
let raw = "**bold** then [1] tail";
let (line, col_map) = markdown_lines::style_slice_mapped(raw, LineKind::Normal, &md);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "bold then [1] tail");
let col = text.find("[1]").unwrap() as u16 + 1; assert_eq!(citation_at_column(raw, &col_map, col), Some(1));
}
#[test]
fn click_turns_selects_turn_and_resolves_citation_target() {
let mut p = ThreadPanel::new();
let first = p.thread_mut().ask("a".into());
p.thread_mut().complete(
first,
"See [1] for it".into(),
vec![AskSource {
path: kimun_core::nfs::VaultPath::new("a.md"),
heading: "h".into(),
date: None,
score: 1.0,
text: String::new(),
ordinal: 1,
}],
);
let second = p.thread_mut().ask("b".into());
p.thread_mut().complete(second, "b!".into(), vec![]);
p.turns_rect = Rect::new(0, 0, 40, 20);
let answer_slice = "See [1] for it";
p.row_map = vec![
RowSlot::Turn(first),
RowSlot::Answer {
turn_id: first,
range: 0..answer_slice.len(),
col_map: identity_map(answer_slice),
},
RowSlot::Turn(first),
RowSlot::Turn(second),
];
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 4, row: 1,
modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
};
p.click_turns(&mouse);
assert_eq!(p.thread().selected().unwrap().id, first);
assert_eq!(p.take_citation_target(), Some(1));
}
#[test]
fn separators_part_turns_and_question_line_stands_out() {
use crate::components::markdown_lines::MdStyles;
let theme = Theme::default();
let md = MdStyles::from_theme(&theme);
let qstyle = Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::BOLD);
let sep = Style::default()
.fg(theme.gray.to_ratatui())
.add_modifier(Modifier::DIM);
let dim = Style::default();
let err = Style::default();
let mut thread = Thread::default();
let a = thread.ask("first".into());
thread.complete(a, "ans a".into(), vec![]);
let b = thread.ask("second".into());
thread.complete(b, "ans b".into(), vec![]);
let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
for (i, turn) in thread.turns().iter().enumerate() {
render_turn(turn, 40, i == 0, ">", qstyle, sep, dim, err, &md, &mut rows);
}
let is_sep = |l: &Line<'static>| l.spans.iter().any(|s| s.content.contains('─'));
assert_eq!(rows.iter().filter(|(_, l)| is_sep(l)).count(), 1);
assert!(!is_sep(&rows[0].1), "no rule before the first turn");
let sep_idx = rows.iter().position(|(_, l)| is_sep(l)).unwrap();
assert!(matches!(rows[sep_idx].0, RowSlot::Turn(id) if id == b));
assert_ne!(sep_idx, rows.len() - 1, "no rule after the last turn");
let (_, qline) = rows
.iter()
.find(|(_, l)| l.spans.iter().any(|s| s.content.contains("first")))
.unwrap();
assert!(
qline.spans[0].content.starts_with('>'),
"carries the prompt"
);
assert_eq!(qline.spans[0].style, qstyle, "accent + bold");
}
mod rendering {
use super::*;
use crate::settings::themes::Theme;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn draw(p: &mut ThreadPanel, theme: &Theme, width: u16, height: u16, focused: bool) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| {
let area = f.area();
p.render(f, area, theme, focused);
})
.unwrap();
}
#[test]
fn clicking_a_separator_row_selects_its_turn() {
let theme = Theme::default();
let mut p = ThreadPanel::new();
let a = p.thread_mut().ask("first".into());
p.thread_mut().complete(a, "aaa".into(), vec![]);
let b = p.thread_mut().ask("second".into());
p.thread_mut().complete(b, "bbb".into(), vec![]);
p.focus = ThreadFocus::Turns;
p.thread_mut().select_index(0);
draw(&mut p, &theme, 40, 12, true);
let sep_row = p
.row_map
.iter()
.position(|s| matches!(s, RowSlot::Turn(id) if *id == b))
.expect("turn b has rows on screen");
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: p.turns_rect.y + sep_row as u16,
modifiers: KeyModifiers::NONE,
};
p.handle_input(&InputEvent::Mouse(mouse), &tx);
assert_eq!(p.thread().selected().unwrap().id, b);
}
#[test]
fn render_does_not_panic_across_states_and_sizes() {
let theme = Theme::default();
let mut p = ThreadPanel::new();
p.set_client(Some(test_client())); draw(&mut p, &theme, 40, 10, true);
let id = p
.thread_mut()
.ask("A fairly long question that should wrap across more than one line".into());
draw(&mut p, &theme, 40, 10, true);
p.thread_mut().complete(
id,
"An answer citing [1] a source and [2] another, spanning multiple \
wrapped lines to exercise citation styling."
.into(),
vec![],
);
draw(&mut p, &theme, 40, 10, true); p.focus = ThreadFocus::Turns;
draw(&mut p, &theme, 40, 10, true);
let id2 = p.thread_mut().ask("another".into());
p.thread_mut().fail(id2, "boom".into());
draw(&mut p, &theme, 40, 10, true);
p.set_client(None);
draw(&mut p, &theme, 40, 10, false);
draw(&mut p, &theme, 3, 3, true); draw(&mut p, &theme, 0, 0, true); }
fn turns_key(p: &mut ThreadPanel, code: KeyCode) {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
p.handle_input(
&InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
&tx,
);
}
#[test]
fn markdown_answer_keeps_prose_citations_clickable() {
let theme = Theme::default();
let mut p = ThreadPanel::new();
let id = p.thread_mut().ask("q".into());
let answer = "# Title\nSee [1] here.\n```\nlet x = arr[9];\n```".to_string();
p.thread_mut().complete(
id,
answer.clone(),
vec![AskSource {
path: kimun_core::nfs::VaultPath::new("a.md"),
heading: "h".into(),
date: None,
score: 1.0,
text: String::new(),
ordinal: 1,
}],
);
p.focus = ThreadFocus::Turns;
draw(&mut p, &theme, 60, 12, true);
let hit = p.row_map.iter().find_map(|slot| match slot {
RowSlot::Answer { range, col_map, .. } if answer[range.clone()].contains("[1]") => {
let slice = &answer[range.clone()];
let col = slice.find("[1]").unwrap() as u16 + 1;
Some(citation_at_column(slice, col_map, col))
}
_ => None,
});
assert_eq!(
hit,
Some(Some(1)),
"the prose citation resolves through the rendered slice"
);
}
#[test]
fn completion_bottom_follows_to_show_the_answer_end() {
let theme = Theme::default();
let mut p = ThreadPanel::new();
p.set_client(Some(test_client()));
let id = p.thread_mut().ask("q".into());
p.focus = ThreadFocus::Turns;
let answer = (0..10)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
p.handle_data(AskData::AnswerReady {
turn_id: id,
result: Ok((answer, vec![])),
});
draw(&mut p, &theme, 60, 8, true);
assert_eq!(p.scroll, 7, "bottom-follow shows the answer's end");
}
#[test]
fn completion_of_an_unselected_turn_leaves_scroll_untouched() {
let mut p = ThreadPanel::new();
p.set_client(Some(test_client()));
let old = p.thread_mut().ask("old".into());
p.thread_mut().complete(old, "old answer".into(), vec![]);
let new = p.thread_mut().ask("new".into());
p.thread_mut().complete(new, "new answer".into(), vec![]);
p.thread_mut().select_last();
p.scroll = 4;
p.follow_selection = false;
p.bottom_follow_pending = false;
p.handle_data(AskData::AnswerReady {
turn_id: old,
result: Ok(("regenerated".into(), vec![])),
});
assert_eq!(p.scroll, 4, "unselected completion must not move scroll");
assert!(!p.follow_selection, "follow flags untouched");
assert!(!p.bottom_follow_pending, "bottom-follow not armed");
let newer = p.thread_mut().ask("newer".into());
p.handle_data(AskData::AnswerReady {
turn_id: newer,
result: Ok(("visible".into(), vec![])),
});
assert!(
p.bottom_follow_pending,
"selected completion follows bottom"
);
}
#[test]
fn selection_scrolls_into_view_and_content_scroll_clamps() {
let theme = Theme::default();
let mut p = ThreadPanel::new();
for i in 0..8 {
let id = p.thread_mut().ask(format!("q{i}"));
p.thread_mut().complete(id, format!("a{i}"), vec![]);
}
p.focus = ThreadFocus::Turns;
draw(&mut p, &theme, 60, 9, true);
for _ in 0..8 {
turns_key(&mut p, KeyCode::Char('k'));
}
draw(&mut p, &theme, 60, 9, true);
assert_eq!(
p.scroll, 0,
"selecting the first turn scrolled it into view"
);
turns_key(&mut p, KeyCode::End);
draw(&mut p, &theme, 60, 9, true);
assert_eq!(p.scroll, 25, "content scroll clamps to the last page");
}
}
}