Skip to main content

kimun_notes/components/
ask_thread.rs

1//! `ThreadPanel` — the editor area's Ask-workspace content (see CONTEXT.md:
2//! **Ask workspace**, **Thread**). Owns the conversation `Thread`,
3//! the docked question composer, and the live `RagClient` (when the Kimün
4//! server can answer questions).
5//!
6//! The panel is a permanent resident of `PanelSet`, like the note editor:
7//! its conversation survives the user switching the editor area to
8//! another view because the panel itself is never dropped or moved. Losing the
9//! client (server unreachable / no LLM) disables the composer without evicting
10//! the thread — the thread's answers are already local.
11//!
12//! Input runs through the inherent [`ThreadPanel::handle_input`], not the
13//! `Component` trait method: the panel derives everything it needs (enabled
14//! state, submission) from its own `client`, so the trait `render` (and its
15//! default no-op input) is all the generic `dyn Component` dispatch needs.
16
17use 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::panel::panel_block;
34use crate::components::single_line_input::{InputOutcome, SingleLineInput};
35use crate::settings::icons::Icons;
36use crate::settings::themes::Theme;
37
38/// Height (in rows) of the docked composer box, borders included.
39const COMPOSER_HEIGHT: u16 = 3;
40
41/// Rows a PageUp/PageDown leaves visible from the previous view (shared
42/// convention with `AttachmentView`).
43const PAGE_OVERLAP: u16 = 2;
44
45/// The synchronous half of a turn kickoff (see `ThreadPanel::begin_turn`):
46/// the question, the history to send with it, and the new turn's id.
47type PendingTurn = (String, Vec<(String, String)>, u64);
48
49/// Which part of the Ask workspace has keyboard focus within the editor
50/// area: the question composer, or the turn list above it.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ThreadFocus {
53    Composer,
54    Turns,
55}
56
57/// What a single rendered row of the turn list belongs to — the last
58/// `render_turns` call's row → data mapping, used for mouse hit-testing
59/// (`handle_mouse`). `row_map[i]` describes the row at `turns_rect.y + i`.
60enum RowSlot {
61    /// A turn's question/status line — clicking anywhere on it selects the
62    /// turn.
63    Turn(u64),
64    /// One word-wrapped line of a turn's answer body: the turn it belongs to,
65    /// the line's byte range into `turn.answer`, and the line's column map
66    /// (`rendered char index → byte offset within the sliced range`, from
67    /// `markdown_lines::style_slice_mapped`). Because emphasis sigils are hidden
68    /// in the rendered answer, a click's column no longer maps 1:1 to the source
69    /// bytes — the map resolves it back so `citation_at_column` still lands on
70    /// the right `[n]`.
71    Answer {
72        turn_id: u64,
73        range: Range<usize>,
74        col_map: Vec<usize>,
75    },
76}
77
78/// The Ask workspace's editor-area content: the conversation `Thread` plus
79/// the docked question composer. See the module doc for lifetime notes.
80pub struct ThreadPanel {
81    thread: Thread,
82    composer: SingleLineInput,
83    /// The live RAG client when the Kimün server can answer questions, else
84    /// `None`. Its presence is the single source of truth for whether the
85    /// composer is enabled: losing the client disables submission without
86    /// evicting the thread (the answers are already local — CONTEXT.md:
87    /// **Ask workspace**).
88    client: Option<Arc<RagClient>>,
89    focus: ThreadFocus,
90    /// Topmost visible row of the flattened turn-lines list. While
91    /// `follow_selection` is set the render keeps the selected turn in view;
92    /// content-scroll keys (`PageUp`/`PageDown`/`Home`/`End`) and the wheel take
93    /// it over.
94    scroll: u16,
95    /// True while the render owns `scroll` (keep the selected turn in view). A
96    /// content-scroll key or wheel tick clears it; a selection move (`j`/`k`)
97    /// re-arms it. Mirrors `PreviewPane`'s anchored/user-owned split.
98    follow_selection: bool,
99    /// One-shot: the next render scrolls so the selected turn's *end* is visible
100    /// (bottom-follow), set when a turn is added or its answer completes so new
101    /// content comes into view. Cleared by the render that honors it.
102    bottom_follow_pending: bool,
103    /// The turn list's viewport height from the last render — the page size for
104    /// `PageUp`/`PageDown`.
105    turns_height: u16,
106    /// Citation `[n]` ordinal a click asked the Sources drawer to focus (NOT a
107    /// vec position — the drawer resolves ordinal → row). Cleared on read via
108    /// `take_citation_target`.
109    citation_target: Option<usize>,
110    /// The turn list's rect from the last render — mouse hit-testing base.
111    turns_rect: Rect,
112    /// The composer's rect from the last render — mouse hit-testing base.
113    composer_rect: Rect,
114    /// Row → data mapping from the last render, scoped to `turns_rect`.
115    row_map: Vec<RowSlot>,
116    /// Glyph set (question-prompt chevron, …) resolved from `use_nerd_fonts`.
117    /// Defaults to the ASCII set; `set_icons` swaps in the configured one.
118    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    /// Swap in the configured glyph set (nerd-font vs ASCII) — the Ask panel is
141    /// resident, so `PanelSet` refreshes it here whenever icons are (re)built.
142    pub fn set_icons(&mut self, icons: Icons) {
143        self.icons = icons;
144    }
145
146    /// Set (or clear) the live RAG client — the single injection point
147    /// `PanelSet::set_ask_client` drives. A present client enables the
148    /// composer; `None` disables it without touching the thread.
149    pub fn set_client(&mut self, client: Option<Arc<RagClient>>) {
150        self.client = client;
151    }
152
153    /// Whether a live RAG client is set — i.e. the composer can submit.
154    pub fn has_client(&self) -> bool {
155        self.client.is_some()
156    }
157
158    /// Move keyboard focus to the question composer (leader `a a` / the Ask
159    /// shortcut land here).
160    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    /// The source row a citation click asked to be focused, if any — cleared
173    /// on read.
174    pub fn take_citation_target(&mut self) -> Option<usize> {
175        self.citation_target.take()
176    }
177
178    // ── Input ────────────────────────────────────────────────────────────
179
180    /// Handle an input event. Submission/regeneration derive from the panel's
181    /// own `client`: with no client the composer is disabled and nothing is
182    /// ever spawned (no orphaned `Thinking` turn).
183    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            // Bottom-follow only when the turn that just completed is the one
194            // being read: regenerating an older turn while reading another must
195            // not yank the scroll to the completed (unselected) turn's end.
196            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                        // The answer landed: bring its (now full) content into
201                        // view so a long answer doesn't complete off-screen.
202                        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        // `ReaderNote` is addressed to the source reader (Task 10), not here.
213    }
214
215    /// Arm bottom-follow: the next render scrolls the selected turn's end into
216    /// view. Also re-arms selection-follow so a prior manual scroll doesn't
217    /// suppress it.
218    fn follow_bottom(&mut self) {
219        self.follow_selection = true;
220        self.bottom_follow_pending = true;
221    }
222
223    /// Scroll the content by `delta` rows, taking the offset over from the
224    /// selection-follow anchor (mirrors `PreviewPane`'s user-owned scroll). The
225    /// upper bound is clamped by the next render against the wrapped-row total.
226    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        // Page size for content scrolling, leaving a little overlap (mirrors
260        // AttachmentView / the note preview).
261        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                // A selection move re-arms keep-in-view over any manual scroll.
266                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            // Content scrolling for reading within a long turn — plain,
275            // selection-independent, like the preview/attachment surfaces.
276            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    /// Resolve a click inside `turns_rect` against the last render's
343    /// `row_map`: selects the clicked turn, and — for a click landing on an
344    /// answer line — resolves the column to a citation, marking
345    /// `citation_target` when it's in range of the turn's sources.
346    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        // Resolve `[n]` through the pairing seam (by ordinal, not vec position);
371        // store the ordinal itself — the Sources panel translates it to a row.
372        if turn.source_for_citation(citation_idx).is_some() {
373            self.citation_target = Some(citation_idx);
374        }
375    }
376
377    /// Move the thread's selection to turn `id`. No-op when `id` is already
378    /// selected or unknown.
379    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    // ── Turn actions ─────────────────────────────────────────────────────
390
391    /// Pre-spawn half of `submit`, factored out for testability: validates
392    /// that a client is present and the composer text is non-empty, pushes a
393    /// `Thinking` turn, and returns what the spawn needs. `None` — and no
394    /// thread mutation — when there is no client or the composer is
395    /// (effectively) empty. Checking the client here (not just in `submit`) is
396    /// what keeps a clientless submit from orphaning a forever-`Thinking` turn.
397    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        // Read history before `ask()` pushes the new turn — `Thread::history`
405        // already excludes the in-flight turn either way, so this ordering
406        // isn't load-bearing, but it matches the eventual spawn's intent.
407        let history = self.thread.history();
408        let turn_id = self.thread.ask(question.clone());
409        // The new turn is selected; bring it (and its incoming answer) into view.
410        self.follow_bottom();
411        Some((question, history, turn_id))
412    }
413
414    /// Submit the composer's question. `begin_turn` already guarantees a
415    /// client is present (else it pushes no turn), so this spawns the ask job,
416    /// delivering `AppEvent::Ask(AskData::AnswerReady)` on completion.
417    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    /// Rewind the selected turn back to `Thinking` and re-ask its question.
428    /// The server always re-retrieves context, so the completion carries fresh
429    /// sources — the `[n]` markers in the new answer are numbered against that
430    /// fresh context, and replacing the turn's sources keeps citations, reader
431    /// targets, and saved-note wikilinks aligned. (`Thread::regenerate` leaves
432    /// the old sources in place while the turn is `Thinking`, so the previous
433    /// evidence stays visible during regeneration; only completion swaps them.)
434    /// No-op without a client, or when the selected turn is currently in
435    /// flight (`Thread::regenerate` rejects that case). The client is checked
436    /// first, before any rewind, so a clientless regenerate leaves the turn
437    /// `Done` rather than orphaning it as `Thinking`. Leader `a r`.
438    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    /// Spawn the async ask job for `turn_id`: call the RAG client with the
453    /// question and history, map the answer + freshly retrieved sources, and
454    /// deliver an `AskData::AnswerReady` on completion. Shared verbatim by
455    /// `submit` and `regenerate_selected` — both re-retrieve, so both take the
456    /// fresh sources from the response.
457    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                    // Normalize the wire ordinal ONCE here (position → 1-based
471                    // fallback for an older server); downstream sees real ordinals.
472                    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    /// Copy the selected turn's answer (citation markers stripped) to the OS
486    /// clipboard, reusing the shared [`crate::components::yank`] seam.
487    /// Leader `a y`.
488    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    /// Open the create-note dialog pre-filled with the selected turn saved as
497    /// a note (**Saved answer**). The dialog owns validation and
498    /// the actual create call — this only supplies the path/content. Leader `a e`.
499    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    // ── Render ───────────────────────────────────────────────────────────
513
514    fn render_turns(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
515        self.turns_rect = rect;
516
517        // The question line gets a strong identity (accent + bold + a chevron
518        // prompt glyph); turns are parted by a theme-dimmed horizontal rule.
519        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        // Auto-scroll (while following): bottom-follow pins the selected turn's
552        // end to view (new content just landed); otherwise keep its start in
553        // view. A manual scroll clears `follow_selection`, leaving the offset
554        // alone but for the clamp below.
555        if let Some(sel) = self.thread.selected()
556            && let Some(&(_, start)) = turn_start_row.iter().find(|(id, _)| *id == sel.id)
557        {
558            // End row of the selected turn: one before the next turn's start,
559            // or the last row for the final turn.
560            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    // `handle_input` keeps the `Component` default (no-op): the real input
673    // path is the inherent `ThreadPanel::handle_input` above, which needs a
674    // `client` the trait signature has no room for. See the module doc.
675}
676
677/// Render one turn's rows (question + status/body), appending to `out`.
678/// Free function (no `&self` needed) — the "one method per concern" split
679/// `render_turns` delegates to.
680#[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    // A theme-dimmed rule parts each turn from the one above (never before the
694    // first turn). It belongs to this turn — clicking it selects the turn.
695    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
730/// A full-width theme-dimmed horizontal rule (`─`) parting two turns.
731fn separator_line(width: u16, style: Style) -> Line<'static> {
732    Line::from(Span::styled("─".repeat(width as usize), style))
733}
734
735/// Render a `Done` turn's answer as styled markdown rows. The whole answer is
736/// handed to the editor's buffer-aware markdown model in one pass
737/// (`markdown_lines::classify_block_kinds`), which labels every logical source
738/// line's block role; each line is then word-wrapped and each wrapped slice
739/// keeps its byte range into `turn.answer` (so `RowSlot::Answer` hit-testing
740/// stays aligned) and is styled by `markdown_lines::style_slice_mapped`. That
741/// styler hides balanced emphasis sigils, so the rendered columns no longer map
742/// 1:1 to the source — the slice's `col_map` (stored on the `RowSlot`) carries
743/// `rendered col → source byte` for the citation hit-test to walk.
744fn 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    // One buffer-aware classification pass over the whole answer: split into
752    // logical (newline-free) lines and let the editor model decide each line's
753    // block role (fences, setext, lazy blockquotes all resolved there). The
754    // `split_inclusive` walk below visits the same lines in the same order, so
755    // `kinds` aligns index-for-index.
756    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
783/// Map a mouse click's column (relative to the wrapped line's own left edge) to
784/// the citation it landed on, if any. `slice` is the wrapped line's source
785/// text and `map` its `rendered char index → byte offset in slice` column map
786/// (from `style_slice_mapped`): walking `map` by rendered display width steps
787/// past any hidden emphasis sigils, so a click on or after them still resolves
788/// to the right source byte — and thence the right `[n]`.
789fn 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
805/// Greedy word-wrap: break `text` into lines no wider than `width` display
806/// columns, wrapping at spaces (a single word wider than `width` overflows
807/// its own line rather than being split). Existing newlines force a break.
808/// Returns byte ranges into `text`, trimmed of the separating whitespace, so
809/// both rendering (`render_turn`) and mouse hit-testing
810/// (`citation_at_column`) stay in lock-step by construction — there's no
811/// second wrapping pass (e.g. `Paragraph::wrap`) to disagree with this one.
812fn 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
826/// Word-wrap a single (newline-free) paragraph range, appending to `out`.
827fn 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
851/// Byte ranges of each space-separated word within `range`. Splits on ASCII
852/// space only (`b' '` never appears as a UTF-8 continuation byte, so this is
853/// always a safe char-boundary split).
854fn 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    /// A throwaway RAG client — never actually called (localhost:0), just
880    /// present so the composer is enabled.
881    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    /// An offline panel (no client) with pending composer text.
890    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(); // "q" pending, no client
936        let _ = p_handle_enter(&mut p);
937        assert!(p.thread().is_empty(), "no client → no turn");
938
939        // A client enables submission; the composer text (untouched by the
940        // clientless attempt) now pushes a Thinking turn. The spawned job runs
941        // in the background — we only assert the synchronous half here.
942        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(); // no client
976        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        // Without a client, regenerate must not even rewind the turn — the
1033        // client check comes before the rewind, so no orphaned Thinking turn.
1034        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    /// Identity column map (rendered col == byte offset) for a slice with no
1086    /// hidden sigils — the common test fixture.
1087    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    /// With emphasis sigils hidden, a click on `[1]`'s *rendered* column must
1102    /// still resolve to citation 1 — the col_map steps past the dropped `**`.
1103    /// Exercises a line with an emphasis run before the citation.
1104    #[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        // The rendered `[1]` starts at column 10 ("bold then " = 10 cols).
1113        let col = text.find("[1]").unwrap() as u16 + 1; // inside the marker
1114        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        // Currently selected: `second`. Simulate a render so row_map/turns_rect exist.
1136        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, // inside "[1]"
1151            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        // Stores the citation ordinal (`[1]`), resolved through the pairing seam.
1157        assert_eq!(p.take_citation_target(), Some(1));
1158    }
1159
1160    /// A theme-dimmed rule parts turns (never before the first, never trailing
1161    /// the last), and the question line stands out — accent+bold with the
1162    /// prompt glyph.
1163    #[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        // Two turns → exactly one divider, opening the second turn, never last.
1190        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        // The question row: prompt glyph + accent-bold styling.
1197        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        /// Clicking the separator row that opens a turn selects that turn — the
1225        /// rule joins `row_map` as a `Turn(id)` slot.
1226        #[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            // Select the first turn so a separator click can move selection.
1236            p.thread_mut().select_index(0);
1237            draw(&mut p, &theme, 40, 12, true);
1238
1239            // The first visible row mapped to `b` is its opening separator.
1240            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())); // enabled composer render path
1261            draw(&mut p, &theme, 40, 10, true); // empty thread
1262
1263            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); // Thinking
1267
1268            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); // Done, focused on Turns
1276            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); // Error
1282
1283            p.set_client(None);
1284            draw(&mut p, &theme, 40, 10, false); // disabled, unfocused
1285
1286            draw(&mut p, &theme, 3, 3, true); // degenerate tiny rect
1287            draw(&mut p, &theme, 0, 0, true); // zero rect
1288        }
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        /// A markdown answer (heading + prose citation + fenced code block)
1299        /// renders through the row map, and the prose citation stays clickable:
1300        /// the rendered answer slices still map 1:1 to the source, so
1301        /// `citation_at_column` resolves the marker. (Per-block styling is
1302        /// unit-tested in `markdown_lines`.) Covers I2's hit-testing constraint.
1303        #[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            // Find the rendered answer row carrying the prose `[1]` and hit-test
1325            // the marker's column through its stored col_map.
1326            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        /// A completed answer taller than the viewport scrolls so its end is
1342        /// visible (bottom-follow), not stuck showing the question.
1343        #[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            // 10 answer lines. Rows: question(1) + 10 + trailing blank(1) = 12.
1351            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            // Terminal height 8 − composer(3) = 5 turn rows. Rows total 12
1360            // (question 1 + 10 answer + trailing blank 1) → end pins at 12 − 5 = 7.
1361            draw(&mut p, &theme, 60, 8, true);
1362            assert_eq!(p.scroll, 7, "bottom-follow shows the answer's end");
1363        }
1364
1365        /// Completing an UNSELECTED turn (e.g. regenerating an old turn while
1366        /// reading another) must not arm bottom-follow — the reader's scroll
1367        /// and follow flags stay exactly where they were.
1368        #[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            // Read the newer turn; take a manual scroll position so we can prove
1377            // it survives.
1378            p.thread_mut().select_last();
1379            p.scroll = 4;
1380            p.follow_selection = false;
1381            p.bottom_follow_pending = false;
1382
1383            // The OLDER (unselected) turn completes a regeneration.
1384            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            // Completing the SELECTED turn does arm bottom-follow, as before.
1393            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        /// Selecting an off-screen turn brings it into view; content-scroll keys
1405        /// clamp to the wrapped-row total.
1406        #[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            // First turn: question(1)+answer(1)+blank(1) = 3 rows. Each later
1416            // turn adds a leading separator: separator(1)+question(1)+answer(1)+
1417            // blank(1) = 4 rows. 8 turns → 3 + 7×4 = 31 rows.
1418            // Terminal height 9 − composer(3) = 6 turn rows.
1419            draw(&mut p, &theme, 60, 9, true); // selection at the last turn
1420
1421            // Jump the selection to the first turn: it scrolls to the top.
1422            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            // End scrolls to the bottom, clamped to total − height (31 − 6 = 25).
1432            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}