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::hint_row::HintRow;
34use crate::components::panel::panel_block;
35use crate::components::single_line_input::{InputOutcome, SingleLineInput};
36use crate::settings::icons::Icons;
37use crate::settings::themes::Theme;
38
39/// Height (in rows) of the docked composer box, borders included.
40const COMPOSER_HEIGHT: u16 = 3;
41
42/// Rows a PageUp/PageDown leaves visible from the previous view (shared
43/// convention with `AttachmentView`).
44const PAGE_OVERLAP: u16 = 2;
45
46/// The synchronous half of a turn kickoff (see `ThreadPanel::begin_turn`):
47/// the question, the history to send with it, and the new turn's id.
48type PendingTurn = (String, Vec<(String, String)>, u64);
49
50/// Which part of the Ask workspace has keyboard focus within the editor
51/// area: the question composer, or the turn list above it.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ThreadFocus {
54    Composer,
55    Turns,
56}
57
58/// What a single rendered row of the turn list belongs to — the last
59/// `render_turns` call's row → data mapping, used for mouse hit-testing
60/// (`handle_mouse`). `row_map[i]` describes the row at `turns_rect.y + i`.
61enum RowSlot {
62    /// A turn's question/status line — clicking anywhere on it selects the
63    /// turn.
64    Turn(u64),
65    /// One word-wrapped line of a turn's answer body: the turn it belongs to,
66    /// the line's byte range into `turn.answer`, and the line's column map
67    /// (`rendered char index → byte offset within the sliced range`, from
68    /// `markdown_lines::style_slice_mapped`). Because emphasis sigils are hidden
69    /// in the rendered answer, a click's column no longer maps 1:1 to the source
70    /// bytes — the map resolves it back so `citation_at_column` still lands on
71    /// the right `[n]`.
72    Answer {
73        turn_id: u64,
74        range: Range<usize>,
75        col_map: Vec<usize>,
76    },
77}
78
79/// The Ask workspace's editor-area content: the conversation `Thread` plus
80/// the docked question composer. See the module doc for lifetime notes.
81pub struct ThreadPanel {
82    thread: Thread,
83    composer: SingleLineInput,
84    /// The live RAG client when the Kimün server can answer questions, else
85    /// `None`. Its presence is the single source of truth for whether the
86    /// composer is enabled: losing the client disables submission without
87    /// evicting the thread (the answers are already local — CONTEXT.md:
88    /// **Ask workspace**).
89    client: Option<Arc<RagClient>>,
90    focus: ThreadFocus,
91    /// Topmost visible row of the flattened turn-lines list. While
92    /// `follow_selection` is set the render keeps the selected turn in view;
93    /// content-scroll keys (`PageUp`/`PageDown`/`Home`/`End`) and the wheel take
94    /// it over.
95    scroll: u16,
96    /// True while the render owns `scroll` (keep the selected turn in view). A
97    /// content-scroll key or wheel tick clears it; a selection move (`j`/`k`)
98    /// re-arms it. Mirrors `PreviewPane`'s anchored/user-owned split.
99    follow_selection: bool,
100    /// One-shot: the next render scrolls so the selected turn's *end* is visible
101    /// (bottom-follow), set when a turn is added or its answer completes so new
102    /// content comes into view. Cleared by the render that honors it.
103    bottom_follow_pending: bool,
104    /// The turn list's viewport height from the last render — the page size for
105    /// `PageUp`/`PageDown`.
106    turns_height: u16,
107    /// Citation `[n]` ordinal a click asked the Sources drawer to focus (NOT a
108    /// vec position — the drawer resolves ordinal → row). Cleared on read via
109    /// `take_citation_target`.
110    citation_target: Option<usize>,
111    /// The turn list's rect from the last render — mouse hit-testing base.
112    turns_rect: Rect,
113    /// The composer's rect from the last render — mouse hit-testing base.
114    composer_rect: Rect,
115    /// The action chips between the turns and the composer.
116    actions: HintRow,
117    /// Row → data mapping from the last render, scoped to `turns_rect`.
118    row_map: Vec<RowSlot>,
119    /// Glyph set (question-prompt chevron, …) resolved from `use_nerd_fonts`.
120    /// Defaults to the ASCII set; `set_icons` swaps in the configured one.
121    icons: Icons,
122}
123
124impl ThreadPanel {
125    pub fn new() -> Self {
126        Self {
127            thread: Thread::default(),
128            composer: SingleLineInput::new(),
129            client: None,
130            focus: ThreadFocus::Composer,
131            scroll: 0,
132            follow_selection: true,
133            bottom_follow_pending: false,
134            turns_height: 0,
135            citation_target: None,
136            turns_rect: Rect::default(),
137            composer_rect: Rect::default(),
138            actions: HintRow::new(&[
139                (KeyCode::Enter, "⏎", "Send"),
140                (KeyCode::Char('y'), "y", "Copy"),
141                (KeyCode::Char('e'), "e", "Save as note"),
142                (KeyCode::Char('r'), "r", "Regenerate"),
143                (KeyCode::Char('n'), "n", "New"),
144            ])
145            .with_indent(1),
146            row_map: Vec::new(),
147            icons: Icons::new(false),
148        }
149    }
150
151    /// Swap in the configured glyph set (nerd-font vs ASCII) — the Ask panel is
152    /// resident, so `PanelSet` refreshes it here whenever icons are (re)built.
153    pub fn set_icons(&mut self, icons: Icons) {
154        self.icons = icons;
155    }
156
157    /// Set (or clear) the live RAG client — the single injection point
158    /// `PanelSet::set_ask_client` drives. A present client enables the
159    /// composer; `None` disables it without touching the thread.
160    pub fn set_client(&mut self, client: Option<Arc<RagClient>>) {
161        self.client = client;
162    }
163
164    /// Whether a live RAG client is set — i.e. the composer can submit.
165    pub fn has_client(&self) -> bool {
166        self.client.is_some()
167    }
168
169    /// Move keyboard focus to the question composer (leader `a a` / the Ask
170    /// shortcut land here).
171    pub fn focus_composer(&mut self) {
172        self.focus = ThreadFocus::Composer;
173    }
174
175    pub fn thread(&self) -> &Thread {
176        &self.thread
177    }
178
179    pub fn thread_mut(&mut self) -> &mut Thread {
180        &mut self.thread
181    }
182
183    /// The source row a citation click asked to be focused, if any — cleared
184    /// on read.
185    pub fn take_citation_target(&mut self) -> Option<usize> {
186        self.citation_target.take()
187    }
188
189    // ── Input ────────────────────────────────────────────────────────────
190
191    /// Handle an input event. Submission/regeneration derive from the panel's
192    /// own `client`: with no client the composer is disabled and nothing is
193    /// ever spawned (no orphaned `Thinking` turn).
194    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
195        match event {
196            InputEvent::Key(key) => self.handle_key(key, tx),
197            InputEvent::Mouse(mouse) => self.handle_mouse(mouse, tx),
198            InputEvent::Paste(_) => EventState::NotConsumed,
199        }
200    }
201
202    pub fn handle_data(&mut self, data: AskData) {
203        if let AskData::AnswerReady { turn_id, result } = data {
204            // Bottom-follow only when the turn that just completed is the one
205            // being read: regenerating an older turn while reading another must
206            // not yank the scroll to the completed (unselected) turn's end.
207            let completed_is_selected = self.thread.selected().map(|t| t.id) == Some(turn_id);
208            match result {
209                Ok((answer, sources)) => {
210                    if self.thread.complete(turn_id, answer, sources) && completed_is_selected {
211                        // The answer landed: bring its (now full) content into
212                        // view so a long answer doesn't complete off-screen.
213                        self.follow_bottom();
214                    }
215                }
216                Err(e) => {
217                    if self.thread.fail(turn_id, e) && completed_is_selected {
218                        self.follow_bottom();
219                    }
220                }
221            }
222        }
223        // `ReaderNote` is addressed to the source reader, not here.
224    }
225
226    /// Arm bottom-follow: the next render scrolls the selected turn's end into
227    /// view. Also re-arms selection-follow so a prior manual scroll doesn't
228    /// suppress it.
229    fn follow_bottom(&mut self) {
230        self.follow_selection = true;
231        self.bottom_follow_pending = true;
232    }
233
234    /// Scroll the content by `delta` rows, taking the offset over from the
235    /// selection-follow anchor (mirrors `PreviewPane`'s user-owned scroll). The
236    /// upper bound is clamped by the next render against the wrapped-row total.
237    fn content_scroll_by(&mut self, delta: i32) {
238        self.follow_selection = false;
239        self.bottom_follow_pending = false;
240        self.scroll = if delta < 0 {
241            self.scroll.saturating_sub((-delta) as u16)
242        } else {
243            self.scroll.saturating_add(delta as u16)
244        };
245    }
246
247    fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
248        match self.focus {
249            ThreadFocus::Composer => self.handle_composer_key(key, tx),
250            ThreadFocus::Turns => self.handle_turns_key(key, tx),
251        }
252    }
253
254    fn handle_composer_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
255        if key.code == KeyCode::Esc {
256            self.focus = ThreadFocus::Turns;
257            return EventState::Consumed;
258        }
259        match self.composer.handle_key(key) {
260            InputOutcome::Submit => {
261                self.submit(tx);
262                EventState::Consumed
263            }
264            InputOutcome::NotConsumed => EventState::NotConsumed,
265            _ => EventState::Consumed,
266        }
267    }
268
269    fn handle_turns_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
270        // Page size for content scrolling, leaving a little overlap (mirrors
271        // AttachmentView / the note preview).
272        let page = self.turns_height.saturating_sub(PAGE_OVERLAP).max(1) as i32;
273        match key.code {
274            KeyCode::Up | KeyCode::Char('k') => {
275                self.thread.select_prev();
276                // A selection move re-arms keep-in-view over any manual scroll.
277                self.follow_selection = true;
278                EventState::Consumed
279            }
280            KeyCode::Down | KeyCode::Char('j') => {
281                self.thread.select_next();
282                self.follow_selection = true;
283                EventState::Consumed
284            }
285            // Content scrolling for reading within a long turn — plain,
286            // selection-independent, like the preview/attachment surfaces.
287            KeyCode::PageUp => {
288                self.content_scroll_by(-page);
289                EventState::Consumed
290            }
291            KeyCode::PageDown => {
292                self.content_scroll_by(page);
293                EventState::Consumed
294            }
295            KeyCode::Home => {
296                self.content_scroll_by(-(u16::MAX as i32));
297                EventState::Consumed
298            }
299            KeyCode::End => {
300                self.content_scroll_by(u16::MAX as i32);
301                EventState::Consumed
302            }
303            KeyCode::Char('i') | KeyCode::Char('/') => {
304                self.focus = ThreadFocus::Composer;
305                EventState::Consumed
306            }
307            KeyCode::Char('y') => {
308                self.copy_selected(tx);
309                EventState::Consumed
310            }
311            KeyCode::Char('e') => {
312                self.save_selected(tx);
313                EventState::Consumed
314            }
315            KeyCode::Char('r') => {
316                self.regenerate_selected(tx);
317                EventState::Consumed
318            }
319            _ => EventState::NotConsumed,
320        }
321    }
322
323    fn handle_mouse(&mut self, mouse: &MouseEvent, tx: &AppTx) -> EventState {
324        if let Some(key) = self.actions.hit(mouse) {
325            self.run_action(key.code, tx);
326            return EventState::Consumed;
327        }
328        let pos = Position {
329            x: mouse.column,
330            y: mouse.row,
331        };
332        match mouse.kind {
333            MouseEventKind::Down(MouseButton::Left) => {
334                if self.composer_rect.contains(pos) {
335                    self.focus = ThreadFocus::Composer;
336                    return EventState::Consumed;
337                }
338                if !self.turns_rect.contains(pos) {
339                    return EventState::NotConsumed;
340                }
341                self.focus = ThreadFocus::Turns;
342                self.click_turns(mouse);
343                EventState::Consumed
344            }
345            MouseEventKind::ScrollUp if self.turns_rect.contains(pos) => {
346                self.content_scroll_by(-1);
347                EventState::Consumed
348            }
349            MouseEventKind::ScrollDown if self.turns_rect.contains(pos) => {
350                self.content_scroll_by(1);
351                EventState::Consumed
352            }
353            _ => EventState::NotConsumed,
354        }
355    }
356
357    /// Run an action chip. The chips act on the thread whatever holds the
358    /// keyboard — unlike the keys, which belong to Turns (`y`/`e`/`r`) or the
359    /// composer (Enter) — so each calls its action directly.
360    fn run_action(&mut self, code: KeyCode, tx: &AppTx) {
361        match code {
362            KeyCode::Enter => self.submit(tx),
363            KeyCode::Char('y') => self.copy_selected(tx),
364            KeyCode::Char('e') => self.save_selected(tx),
365            KeyCode::Char('r') => self.regenerate_selected(tx),
366            // Starting over also resets the Sources drawer, which this panel
367            // does not own — the leader action does both.
368            KeyCode::Char('n') => {
369                tx.send(AppEvent::ExecuteLeaderAction(
370                    crate::keys::leader::LeaderAction::AskNew,
371                ))
372                .ok();
373            }
374            _ => {}
375        }
376    }
377
378    /// Enable the chips that can act right now: Send needs a server and a
379    /// question; the turn actions and New need a turn.
380    fn sync_actions(&mut self) {
381        let has_turn = self.thread.selected().is_some();
382        self.actions.set_enabled(
383            0,
384            self.client.is_some() && !self.composer.value().trim().is_empty(),
385        );
386        for i in 1..=4 {
387            self.actions.set_enabled(i, has_turn);
388        }
389    }
390
391    /// Resolve a click inside `turns_rect` against the last render's
392    /// `row_map`: selects the clicked turn, and — for a click landing on an
393    /// answer line — resolves the column to a citation, marking
394    /// `citation_target` when it's in range of the turn's sources.
395    fn click_turns(&mut self, mouse: &MouseEvent) {
396        let idx = (mouse.row - self.turns_rect.y) as usize;
397        let hit = self.row_map.get(idx).map(|slot| match slot {
398            RowSlot::Turn(id) => (*id, None),
399            RowSlot::Answer {
400                turn_id,
401                range,
402                col_map,
403            } => (*turn_id, Some((range.clone(), col_map.clone()))),
404        });
405        let Some((turn_id, answer_hit)) = hit else {
406            return;
407        };
408        self.select_turn(turn_id);
409        let Some((range, col_map)) = answer_hit else {
410            return;
411        };
412        let col = mouse.column.saturating_sub(self.turns_rect.x);
413        let Some(turn) = self.thread.selected() else {
414            return;
415        };
416        let Some(citation_idx) = citation_at_column(&turn.answer[range], &col_map, col) else {
417            return;
418        };
419        // Resolve `[n]` through the pairing seam (by ordinal, not vec position);
420        // store the ordinal itself — the Sources panel translates it to a row.
421        if turn.source_for_citation(citation_idx).is_some() {
422            self.citation_target = Some(citation_idx);
423        }
424    }
425
426    /// Move the thread's selection to turn `id`. No-op when `id` is already
427    /// selected or unknown.
428    fn select_turn(&mut self, id: u64) {
429        if self.thread.selected().map(|t| t.id) == Some(id) {
430            return;
431        }
432        let Some(target_idx) = self.thread.turns().iter().position(|t| t.id == id) else {
433            return;
434        };
435        self.thread.select_index(target_idx);
436    }
437
438    // ── Turn actions ─────────────────────────────────────────────────────
439
440    /// Pre-spawn half of `submit`, factored out for testability: validates
441    /// that a client is present and the composer text is non-empty, pushes a
442    /// `Thinking` turn, and returns what the spawn needs. `None` — and no
443    /// thread mutation — when there is no client or the composer is
444    /// (effectively) empty. Checking the client here (not just in `submit`) is
445    /// what keeps a clientless submit from orphaning a forever-`Thinking` turn.
446    fn begin_turn(&mut self) -> Option<PendingTurn> {
447        self.client.as_ref()?;
448        let question = self.composer.take_text();
449        let question = question.trim().to_string();
450        if question.is_empty() {
451            return None;
452        }
453        // Read history before `ask()` pushes the new turn — `Thread::history`
454        // already excludes the in-flight turn either way, so this ordering
455        // isn't load-bearing, but it matches the eventual spawn's intent.
456        let history = self.thread.history();
457        let turn_id = self.thread.ask(question.clone());
458        // The new turn is selected; bring it (and its incoming answer) into view.
459        self.follow_bottom();
460        Some((question, history, turn_id))
461    }
462
463    /// Submit the composer's question. `begin_turn` already guarantees a
464    /// client is present (else it pushes no turn), so this spawns the ask job,
465    /// delivering `AppEvent::Ask(AskData::AnswerReady)` on completion.
466    fn submit(&mut self, tx: &AppTx) {
467        let Some((question, history, turn_id)) = self.begin_turn() else {
468            return;
469        };
470        let Some(client) = self.client.clone() else {
471            return;
472        };
473        Self::spawn_ask(tx, &client, question, history, turn_id);
474    }
475
476    /// Rewind the selected turn back to `Thinking` and re-ask its question.
477    /// The server always re-retrieves context, so the completion carries fresh
478    /// sources — the `[n]` markers in the new answer are numbered against that
479    /// fresh context, and replacing the turn's sources keeps citations, reader
480    /// targets, and saved-note wikilinks aligned. (`Thread::regenerate` leaves
481    /// the old sources in place while the turn is `Thinking`, so the previous
482    /// evidence stays visible during regeneration; only completion swaps them.)
483    /// No-op without a client, or when the selected turn is currently in
484    /// flight (`Thread::regenerate` rejects that case). The client is checked
485    /// first, before any rewind, so a clientless regenerate leaves the turn
486    /// `Done` rather than orphaning it as `Thinking`. Leader `a r`.
487    pub(crate) fn regenerate_selected(&mut self, tx: &AppTx) {
488        let Some(client) = self.client.clone() else {
489            return;
490        };
491        let Some(id) = self.thread.selected().map(|t| t.id) else {
492            return;
493        };
494        let Some(question) = self.thread.regenerate(id) else {
495            return;
496        };
497        let history = self.thread.history();
498        Self::spawn_ask(tx, &client, question, history, id);
499    }
500
501    /// Spawn the async ask job for `turn_id`: call the RAG client with the
502    /// question and history, map the answer + freshly retrieved sources, and
503    /// deliver an `AskData::AnswerReady` on completion. Shared verbatim by
504    /// `submit` and `regenerate_selected` — both re-retrieve, so both take the
505    /// fresh sources from the response.
506    fn spawn_ask(
507        tx: &AppTx,
508        client: &Arc<RagClient>,
509        question: String,
510        history: Vec<(String, String)>,
511        turn_id: u64,
512    ) {
513        let (tx, client) = (tx.clone(), client.clone());
514        tokio::spawn(async move {
515            let result = client
516                .ask(&question, &history, None)
517                .await
518                .map(|a| {
519                    // Normalize the wire ordinal ONCE here (position → 1-based
520                    // fallback for an older server); downstream sees real ordinals.
521                    let sources = a
522                        .sources
523                        .into_iter()
524                        .enumerate()
525                        .map(|(i, c)| AskSource::from_chunk(i, c))
526                        .collect();
527                    (a.answer, sources)
528                })
529                .map_err(|e| e.to_string());
530            let _ = tx.send(AppEvent::Ask(AskData::AnswerReady { turn_id, result }));
531        });
532    }
533
534    /// Copy the selected turn's answer (citation markers stripped) to the OS
535    /// clipboard, reusing the shared [`crate::components::yank`] seam.
536    /// Leader `a y`.
537    pub(crate) fn copy_selected(&self, tx: &AppTx) {
538        let Some(turn) = self.thread.selected() else {
539            return;
540        };
541        let text = citations::strip(&turn.answer);
542        crate::components::yank(text, "answer copied", tx);
543    }
544
545    /// Open the create-note dialog pre-filled with the selected turn saved as
546    /// a note (**Saved answer**). The dialog owns validation and
547    /// the actual create call — this only supplies the path/content. Leader `a e`.
548    pub(crate) fn save_selected(&self, tx: &AppTx) {
549        let Some(turn) = self.thread.selected() else {
550            return;
551        };
552        let path = save::suggested_path(&turn.question);
553        let content = save::note_content(turn);
554        tx.send(AppEvent::FileOp(FileOp::ShowCreateWithContent {
555            path,
556            content,
557        }))
558        .ok();
559    }
560
561    // ── Render ───────────────────────────────────────────────────────────
562
563    fn render_turns(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
564        self.turns_rect = rect;
565
566        // The question line gets a strong identity (accent + bold + a chevron
567        // prompt glyph); turns are parted by a theme-dimmed horizontal rule.
568        let question = Style::default()
569            .fg(theme.accent.to_ratatui())
570            .add_modifier(Modifier::BOLD);
571        let separator = Style::default()
572            .fg(theme.gray.to_ratatui())
573            .add_modifier(Modifier::DIM);
574        let dim = Style::default().fg(theme.gray.to_ratatui());
575        let err = Style::default().fg(theme.red.to_ratatui());
576        let md = crate::components::markdown_lines::MdStyles::from_theme(theme);
577        let prompt = self.icons.question_prompt;
578
579        let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
580        let mut turn_start_row: Vec<(u64, u16)> = Vec::new();
581        for (i, turn) in self.thread.turns().iter().enumerate() {
582            turn_start_row.push((turn.id, rows.len() as u16));
583            render_turn(
584                turn,
585                rect.width,
586                i == 0,
587                prompt,
588                question,
589                separator,
590                dim,
591                err,
592                &md,
593                &mut rows,
594            );
595        }
596        let total = rows.len() as u16;
597        let height = rect.height;
598        self.turns_height = height;
599
600        // Auto-scroll (while following): bottom-follow pins the selected turn's
601        // end to view (new content just landed); otherwise keep its start in
602        // view. A manual scroll clears `follow_selection`, leaving the offset
603        // alone but for the clamp below.
604        if let Some(sel) = self.thread.selected()
605            && let Some(&(_, start)) = turn_start_row.iter().find(|(id, _)| *id == sel.id)
606        {
607            // End row of the selected turn: one before the next turn's start,
608            // or the last row for the final turn.
609            let end = turn_start_row
610                .iter()
611                .map(|(_, s)| *s)
612                .filter(|s| *s > start)
613                .min()
614                .unwrap_or(total)
615                .saturating_sub(1);
616            if self.bottom_follow_pending {
617                if height > 0 {
618                    self.scroll = end.saturating_sub(height - 1);
619                }
620            } else if self.follow_selection {
621                if start < self.scroll {
622                    self.scroll = start;
623                } else if height > 0 && start >= self.scroll + height {
624                    self.scroll = start.saturating_sub(height - 1);
625                }
626            }
627        }
628        self.bottom_follow_pending = false;
629        self.scroll = self.scroll.min(total.saturating_sub(height));
630
631        let selected_id = self.thread.selected().map(|t| t.id);
632        self.row_map.clear();
633        let mut lines: Vec<Line<'static>> = Vec::new();
634        for (slot, line) in rows
635            .into_iter()
636            .skip(self.scroll as usize)
637            .take(height as usize)
638        {
639            let row_turn_id = match &slot {
640                RowSlot::Turn(id) => *id,
641                RowSlot::Answer { turn_id, .. } => *turn_id,
642            };
643            let line = if focused && Some(row_turn_id) == selected_id {
644                line.style(Style::default().bg(theme.selection_bg.to_ratatui()))
645            } else {
646                line
647            };
648            self.row_map.push(slot);
649            lines.push(line);
650        }
651        f.render_widget(Paragraph::new(lines), rect);
652    }
653
654    fn render_composer(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
655        self.composer_rect = rect;
656
657        let enabled = self.client.is_some();
658        let title = if enabled {
659            "Ask a question"
660        } else {
661            "server unavailable"
662        };
663        let block = panel_block(title, theme, focused);
664        let inner = block.inner(rect);
665        f.render_widget(block, rect);
666
667        let style = if enabled {
668            Style::default().fg(theme.fg.to_ratatui())
669        } else {
670            Style::default()
671                .fg(theme.gray.to_ratatui())
672                .add_modifier(Modifier::DIM)
673        };
674        self.composer.render(f, inner, style, 0, focused && enabled);
675    }
676}
677
678impl Default for ThreadPanel {
679    fn default() -> Self {
680        Self::new()
681    }
682}
683
684impl Component for ThreadPanel {
685    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
686        let chunks = Layout::default()
687            .direction(Direction::Vertical)
688            .constraints([
689                Constraint::Min(0),
690                Constraint::Length(1),
691                Constraint::Length(COMPOSER_HEIGHT),
692            ])
693            .split(rect);
694        self.render_turns(
695            f,
696            chunks[0],
697            theme,
698            focused && self.focus == ThreadFocus::Turns,
699        );
700        self.sync_actions();
701        self.actions.render(
702            f,
703            chunks[1],
704            Style::default().fg(theme.gray.to_ratatui()),
705            theme,
706        );
707        self.render_composer(
708            f,
709            chunks[2],
710            theme,
711            focused && self.focus == ThreadFocus::Composer,
712        );
713    }
714
715    fn hint_shortcuts(&self) -> Vec<(String, String)> {
716        match self.focus {
717            ThreadFocus::Composer => vec![
718                ("Enter".into(), "Ask".into()),
719                ("Esc".into(), "Turns".into()),
720            ],
721            ThreadFocus::Turns => vec![
722                ("j/k".into(), "Select".into()),
723                ("PgUp/PgDn".into(), "Scroll".into()),
724                ("i//".into(), "Compose".into()),
725                ("y".into(), "Copy".into()),
726                ("e".into(), "Save as note".into()),
727                ("r".into(), "Regenerate".into()),
728            ],
729        }
730    }
731
732    // `handle_input` keeps the `Component` default (no-op): the real input
733    // path is the inherent `ThreadPanel::handle_input` above, which needs a
734    // `client` the trait signature has no room for. See the module doc.
735}
736
737/// Render one turn's rows (question + status/body), appending to `out`.
738/// Free function (no `&self` needed) — the "one method per concern" split
739/// `render_turns` delegates to.
740#[allow(clippy::too_many_arguments)]
741fn render_turn(
742    turn: &Turn,
743    width: u16,
744    is_first: bool,
745    prompt: &str,
746    question_style: Style,
747    sep_style: Style,
748    dim: Style,
749    err: Style,
750    md: &crate::components::markdown_lines::MdStyles,
751    out: &mut Vec<(RowSlot, Line<'static>)>,
752) {
753    // A theme-dimmed rule parts each turn from the one above (never before the
754    // first turn). It belongs to this turn — clicking it selects the turn.
755    if !is_first {
756        out.push((RowSlot::Turn(turn.id), separator_line(width, sep_style)));
757    }
758    let question = format!("{prompt} {}", turn.question);
759    for qline in wrap_text(&question, width) {
760        out.push((
761            RowSlot::Turn(turn.id),
762            Line::from(Span::styled(question[qline].to_string(), question_style)),
763        ));
764    }
765    match &turn.status {
766        TurnStatus::Thinking | TurnStatus::Streaming => {
767            out.push((
768                RowSlot::Turn(turn.id),
769                Line::from(Span::styled("… thinking", dim)),
770            ));
771        }
772        TurnStatus::Error(msg) => {
773            let text = format!("✗ {msg}");
774            for eline in wrap_text(&text, width) {
775                out.push((
776                    RowSlot::Turn(turn.id),
777                    Line::from(Span::styled(text[eline].to_string(), err)),
778                ));
779            }
780            out.push((
781                RowSlot::Turn(turn.id),
782                Line::from(Span::styled("  [r] retry", dim)),
783            ));
784        }
785        TurnStatus::Done => render_answer(turn, width, md, out),
786    }
787    out.push((RowSlot::Turn(turn.id), Line::default()));
788}
789
790/// A full-width theme-dimmed horizontal rule (`─`) parting two turns.
791fn separator_line(width: u16, style: Style) -> Line<'static> {
792    Line::from(Span::styled("─".repeat(width as usize), style))
793}
794
795/// Render a `Done` turn's answer as styled markdown rows. The whole answer is
796/// handed to the editor's buffer-aware markdown model in one pass
797/// (`markdown_lines::classify_block_kinds`), which labels every logical source
798/// line's block role; each line is then word-wrapped and each wrapped slice
799/// keeps its byte range into `turn.answer` (so `RowSlot::Answer` hit-testing
800/// stays aligned) and is styled by `markdown_lines::style_slice_mapped`. That
801/// styler hides balanced emphasis sigils, so the rendered columns no longer map
802/// 1:1 to the source — the slice's `col_map` (stored on the `RowSlot`) carries
803/// `rendered col → source byte` for the citation hit-test to walk.
804fn render_answer(
805    turn: &Turn,
806    width: u16,
807    md: &crate::components::markdown_lines::MdStyles,
808    out: &mut Vec<(RowSlot, Line<'static>)>,
809) {
810    use crate::components::markdown_lines;
811    // One buffer-aware classification pass over the whole answer: split into
812    // logical (newline-free) lines and let the editor model decide each line's
813    // block role (fences, setext, lazy blockquotes all resolved there). The
814    // `split_inclusive` walk below visits the same lines in the same order, so
815    // `kinds` aligns index-for-index.
816    let logicals: Vec<&str> = turn
817        .answer
818        .split_inclusive('\n')
819        .map(|l| l.strip_suffix('\n').unwrap_or(l))
820        .collect();
821    let kinds = markdown_lines::classify_block_kinds(&logicals);
822    let mut offset = 0usize;
823    for (logical, &kind) in turn.answer.split_inclusive('\n').zip(kinds.iter()) {
824        let stripped = logical.strip_suffix('\n').unwrap_or(logical);
825        let line_start = offset;
826        for rel in wrap_text(stripped, width) {
827            let abs = (line_start + rel.start)..(line_start + rel.end);
828            let (line, col_map) =
829                markdown_lines::style_slice_mapped(&turn.answer[abs.clone()], kind, md);
830            out.push((
831                RowSlot::Answer {
832                    turn_id: turn.id,
833                    range: abs,
834                    col_map,
835                },
836                line,
837            ));
838        }
839        offset += logical.len();
840    }
841}
842
843/// Map a mouse click's column (relative to the wrapped line's own left edge) to
844/// the citation it landed on, if any. `slice` is the wrapped line's source
845/// text and `map` its `rendered char index → byte offset in slice` column map
846/// (from `style_slice_mapped`): walking `map` by rendered display width steps
847/// past any hidden emphasis sigils, so a click on or after them still resolves
848/// to the right source byte — and thence the right `[n]`.
849fn citation_at_column(slice: &str, map: &[usize], col: u16) -> Option<usize> {
850    let mut w: u16 = 0;
851    for &raw in map {
852        let ch = slice[raw..].chars().next()?;
853        let cw = (ch.width().unwrap_or(0) as u16).max(1);
854        if col < w + cw {
855            return citations::scan(slice)
856                .into_iter()
857                .find(|c| c.range.contains(&raw))
858                .map(|c| c.index);
859        }
860        w += cw;
861    }
862    None
863}
864
865/// Greedy word-wrap: break `text` into lines no wider than `width` display
866/// columns, wrapping at spaces (a single word wider than `width` overflows
867/// its own line rather than being split). Existing newlines force a break.
868/// Returns byte ranges into `text`, trimmed of the separating whitespace, so
869/// both rendering (`render_turn`) and mouse hit-testing
870/// (`citation_at_column`) stay in lock-step by construction — there's no
871/// second wrapping pass (e.g. `Paragraph::wrap`) to disagree with this one.
872fn wrap_text(text: &str, width: u16) -> Vec<Range<usize>> {
873    let width = width.max(1) as usize;
874    let mut lines = Vec::new();
875    let mut para_start = 0;
876    for (i, ch) in text.char_indices() {
877        if ch == '\n' {
878            wrap_paragraph(text, para_start..i, width, &mut lines);
879            para_start = i + 1;
880        }
881    }
882    wrap_paragraph(text, para_start..text.len(), width, &mut lines);
883    lines
884}
885
886/// Word-wrap a single (newline-free) paragraph range, appending to `out`.
887fn wrap_paragraph(text: &str, para: Range<usize>, width: usize, out: &mut Vec<Range<usize>>) {
888    let words = word_ranges(text, para.clone());
889    let Some(first) = words.first() else {
890        out.push(para.start..para.start);
891        return;
892    };
893    let mut line_start = first.start;
894    let mut line_end = first.end;
895    let mut line_w = text[first.clone()].width();
896    for w in &words[1..] {
897        let word_w = text[w.clone()].width();
898        if line_w + 1 + word_w > width {
899            out.push(line_start..line_end);
900            line_start = w.start;
901            line_end = w.end;
902            line_w = word_w;
903        } else {
904            line_end = w.end;
905            line_w += 1 + word_w;
906        }
907    }
908    out.push(line_start..line_end);
909}
910
911/// Byte ranges of each space-separated word within `range`. Splits on ASCII
912/// space only (`b' '` never appears as a UTF-8 continuation byte, so this is
913/// always a safe char-boundary split).
914fn word_ranges(text: &str, range: Range<usize>) -> Vec<Range<usize>> {
915    let bytes = text.as_bytes();
916    let mut words = Vec::new();
917    let mut i = range.start;
918    while i < range.end {
919        while i < range.end && bytes[i] == b' ' {
920            i += 1;
921        }
922        if i >= range.end {
923            break;
924        }
925        let start = i;
926        while i < range.end && bytes[i] != b' ' {
927            i += 1;
928        }
929        words.push(start..i);
930    }
931    words
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use ratatui::crossterm::event::KeyModifiers;
938
939    /// A throwaway RAG client — never actually called (localhost:0), just
940    /// present so the composer is enabled.
941    fn test_client() -> Arc<RagClient> {
942        Arc::new(RagClient::new(
943            "http://localhost:0".to_string(),
944            None,
945            "vault".to_string(),
946        ))
947    }
948
949    /// An offline panel (no client) with pending composer text.
950    fn test_panel() -> ThreadPanel {
951        let mut p = ThreadPanel::new();
952        p.composer.set_value("q");
953        p
954    }
955
956    fn test_panel_online() -> ThreadPanel {
957        let mut p = ThreadPanel::new();
958        p.set_client(Some(test_client()));
959        p
960    }
961
962    fn p_handle_enter(p: &mut ThreadPanel) -> EventState {
963        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
964        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
965        p.handle_input(&InputEvent::Key(key), &tx)
966    }
967
968    #[test]
969    fn new_thread_panel_starts_empty_without_client_and_composer_focus() {
970        let panel = ThreadPanel::new();
971        assert!(panel.thread().is_empty());
972        assert!(!panel.has_client());
973        assert_eq!(panel.focus, ThreadFocus::Composer);
974    }
975
976    #[test]
977    fn set_client_toggles_the_composer_enable_signal() {
978        let mut panel = ThreadPanel::new();
979        assert!(!panel.has_client());
980        panel.set_client(Some(test_client()));
981        assert!(panel.has_client());
982        panel.set_client(None);
983        assert!(!panel.has_client());
984    }
985
986    #[test]
987    fn thread_mut_allows_mutating_the_conversation() {
988        let mut panel = ThreadPanel::new();
989        panel.thread_mut().ask("q?".to_string());
990        assert_eq!(panel.thread().turns().len(), 1);
991    }
992
993    #[tokio::test]
994    async fn enter_submits_only_with_a_client() {
995        let mut p = test_panel(); // "q" pending, no client
996        let _ = p_handle_enter(&mut p);
997        assert!(p.thread().is_empty(), "no client → no turn");
998
999        // A client enables submission; the composer text (untouched by the
1000        // clientless attempt) now pushes a Thinking turn. The spawned job runs
1001        // in the background — we only assert the synchronous half here.
1002        p.set_client(Some(test_client()));
1003        let _ = p_handle_enter(&mut p);
1004        assert_eq!(p.thread().turns().len(), 1);
1005        assert!(matches!(
1006            p.thread().selected().unwrap().status,
1007            TurnStatus::Thinking
1008        ));
1009    }
1010
1011    #[test]
1012    fn answer_ready_completes_matching_turn_only() {
1013        let mut p = test_panel_online();
1014        let id = p.thread_mut().ask("q".into());
1015        p.handle_data(AskData::AnswerReady {
1016            turn_id: 999,
1017            result: Ok(("x".into(), vec![])),
1018        });
1019        assert!(matches!(
1020            p.thread().selected().unwrap().status,
1021            TurnStatus::Thinking
1022        ));
1023        p.handle_data(AskData::AnswerReady {
1024            turn_id: id,
1025            result: Ok(("a".into(), vec![])),
1026        });
1027        assert!(matches!(
1028            p.thread().selected().unwrap().status,
1029            TurnStatus::Done
1030        ));
1031    }
1032
1033    #[test]
1034    fn begin_turn_is_none_without_a_client() {
1035        let mut p = ThreadPanel::new(); // no client
1036        p.composer.set_value("hello");
1037        assert!(p.begin_turn().is_none());
1038        assert!(
1039            p.thread().is_empty(),
1040            "no client → no orphaned Thinking turn"
1041        );
1042    }
1043
1044    #[test]
1045    fn begin_turn_is_none_when_composer_empty() {
1046        let mut p = ThreadPanel::new();
1047        p.set_client(Some(test_client()));
1048        p.composer.set_value("   ");
1049        assert!(p.begin_turn().is_none());
1050        assert!(p.thread().is_empty());
1051    }
1052
1053    #[test]
1054    fn begin_turn_pushes_a_thinking_turn_and_selects_it() {
1055        let mut p = ThreadPanel::new();
1056        p.set_client(Some(test_client()));
1057        p.composer.set_value("hello");
1058        let (question, history, turn_id) = p.begin_turn().expect("client + non-empty");
1059        assert_eq!(question, "hello");
1060        assert!(history.is_empty());
1061        assert_eq!(p.thread().turns().len(), 1);
1062        assert_eq!(p.thread().selected().unwrap().id, turn_id);
1063    }
1064
1065    #[test]
1066    fn esc_in_composer_moves_focus_to_turns() {
1067        let mut p = ThreadPanel::new();
1068        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1069        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1070        let state = p.handle_input(&InputEvent::Key(key), &tx);
1071        assert_eq!(state, EventState::Consumed);
1072        assert_eq!(p.focus, ThreadFocus::Turns);
1073    }
1074
1075    #[test]
1076    fn jk_in_turns_moves_selection() {
1077        let mut p = ThreadPanel::new();
1078        let first = p.thread_mut().ask("a".into());
1079        p.thread_mut().complete(first, "a!".into(), vec![]);
1080        let second = p.thread_mut().ask("b".into());
1081        p.thread_mut().complete(second, "b!".into(), vec![]);
1082        p.focus = ThreadFocus::Turns;
1083
1084        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1085        let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
1086        p.handle_input(&InputEvent::Key(key), &tx);
1087        assert_eq!(p.thread().selected().unwrap().id, first);
1088    }
1089
1090    #[test]
1091    fn regenerate_without_a_client_does_nothing() {
1092        // Without a client, regenerate must not even rewind the turn — the
1093        // client check comes before the rewind, so no orphaned Thinking turn.
1094        let mut p = ThreadPanel::new();
1095        let id = p.thread_mut().ask("q".into());
1096        p.thread_mut().complete(id, "a".into(), vec![]);
1097        p.focus = ThreadFocus::Turns;
1098
1099        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1100        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
1101        p.handle_input(&InputEvent::Key(key), &tx);
1102        assert!(
1103            matches!(p.thread().selected().unwrap().status, TurnStatus::Done),
1104            "no client → the completed turn stays Done"
1105        );
1106        assert_eq!(p.thread().selected().unwrap().id, id);
1107    }
1108
1109    #[test]
1110    fn i_and_slash_move_focus_to_composer() {
1111        for ch in ['i', '/'] {
1112            let mut p = ThreadPanel::new();
1113            p.focus = ThreadFocus::Turns;
1114            let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1115            let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
1116            p.handle_input(&InputEvent::Key(key), &tx);
1117            assert_eq!(p.focus, ThreadFocus::Composer);
1118        }
1119    }
1120
1121    #[test]
1122    fn wrap_text_breaks_on_spaces_within_width() {
1123        let lines = wrap_text("one two three", 7);
1124        let text = "one two three";
1125        let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1126        assert_eq!(rendered, vec!["one two", "three"]);
1127    }
1128
1129    #[test]
1130    fn wrap_text_keeps_an_overlong_word_on_its_own_line() {
1131        let lines = wrap_text("a superlongword b", 5);
1132        let text = "a superlongword b";
1133        let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1134        assert_eq!(rendered, vec!["a", "superlongword", "b"]);
1135    }
1136
1137    #[test]
1138    fn wrap_text_forces_a_break_on_newline() {
1139        let lines = wrap_text("a\nb", 10);
1140        let text = "a\nb";
1141        let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1142        assert_eq!(rendered, vec!["a", "b"]);
1143    }
1144
1145    /// Identity column map (rendered col == byte offset) for a slice with no
1146    /// hidden sigils — the common test fixture.
1147    fn identity_map(slice: &str) -> Vec<usize> {
1148        slice.char_indices().map(|(i, _)| i).collect()
1149    }
1150
1151    #[test]
1152    fn citation_at_column_finds_the_marker_under_the_click() {
1153        let text = "Fact [1] more";
1154        let map = identity_map(text);
1155        let idx = citation_at_column(text, &map, 5);
1156        assert_eq!(idx, Some(1));
1157        let idx = citation_at_column(text, &map, 0);
1158        assert_eq!(idx, None);
1159    }
1160
1161    /// With emphasis sigils hidden, a click on `[1]`'s *rendered* column must
1162    /// still resolve to citation 1 — the col_map steps past the dropped `**`.
1163    /// Exercises a line with an emphasis run before the citation.
1164    #[test]
1165    fn citation_hit_test_resolves_through_hidden_emphasis() {
1166        use crate::components::markdown_lines::{self, LineKind, MdStyles};
1167        let md = MdStyles::from_theme(&Theme::default());
1168        let raw = "**bold** then [1] tail";
1169        let (line, col_map) = markdown_lines::style_slice_mapped(raw, LineKind::Normal, &md);
1170        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1171        assert_eq!(text, "bold then [1] tail");
1172        // The rendered `[1]` starts at column 10 ("bold then " = 10 cols).
1173        let col = text.find("[1]").unwrap() as u16 + 1; // inside the marker
1174        assert_eq!(citation_at_column(raw, &col_map, col), Some(1));
1175    }
1176
1177    #[test]
1178    fn click_turns_selects_turn_and_resolves_citation_target() {
1179        let mut p = ThreadPanel::new();
1180        let first = p.thread_mut().ask("a".into());
1181        p.thread_mut().complete(
1182            first,
1183            "See [1] for it".into(),
1184            vec![AskSource {
1185                path: kimun_core::nfs::VaultPath::new("a.md"),
1186                heading: "h".into(),
1187                date: None,
1188                score: 1.0,
1189                text: String::new(),
1190                ordinal: 1,
1191            }],
1192        );
1193        let second = p.thread_mut().ask("b".into());
1194        p.thread_mut().complete(second, "b!".into(), vec![]);
1195        // Currently selected: `second`. Simulate a render so row_map/turns_rect exist.
1196        p.turns_rect = Rect::new(0, 0, 40, 20);
1197        let answer_slice = "See [1] for it";
1198        p.row_map = vec![
1199            RowSlot::Turn(first),
1200            RowSlot::Answer {
1201                turn_id: first,
1202                range: 0..answer_slice.len(),
1203                col_map: identity_map(answer_slice),
1204            },
1205            RowSlot::Turn(first),
1206            RowSlot::Turn(second),
1207        ];
1208        let mouse = MouseEvent {
1209            kind: MouseEventKind::Down(MouseButton::Left),
1210            column: 4, // inside "[1]"
1211            row: 1,
1212            modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
1213        };
1214        p.click_turns(&mouse);
1215        assert_eq!(p.thread().selected().unwrap().id, first);
1216        // Stores the citation ordinal (`[1]`), resolved through the pairing seam.
1217        assert_eq!(p.take_citation_target(), Some(1));
1218    }
1219
1220    /// A theme-dimmed rule parts turns (never before the first, never trailing
1221    /// the last), and the question line stands out — accent+bold with the
1222    /// prompt glyph.
1223    #[test]
1224    fn separators_part_turns_and_question_line_stands_out() {
1225        use crate::components::markdown_lines::MdStyles;
1226        let theme = Theme::default();
1227        let md = MdStyles::from_theme(&theme);
1228        let qstyle = Style::default()
1229            .fg(theme.accent.to_ratatui())
1230            .add_modifier(Modifier::BOLD);
1231        let sep = Style::default()
1232            .fg(theme.gray.to_ratatui())
1233            .add_modifier(Modifier::DIM);
1234        let dim = Style::default();
1235        let err = Style::default();
1236
1237        let mut thread = Thread::default();
1238        let a = thread.ask("first".into());
1239        thread.complete(a, "ans a".into(), vec![]);
1240        let b = thread.ask("second".into());
1241        thread.complete(b, "ans b".into(), vec![]);
1242
1243        let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
1244        for (i, turn) in thread.turns().iter().enumerate() {
1245            render_turn(turn, 40, i == 0, ">", qstyle, sep, dim, err, &md, &mut rows);
1246        }
1247
1248        let is_sep = |l: &Line<'static>| l.spans.iter().any(|s| s.content.contains('─'));
1249        // Two turns → exactly one divider, opening the second turn, never last.
1250        assert_eq!(rows.iter().filter(|(_, l)| is_sep(l)).count(), 1);
1251        assert!(!is_sep(&rows[0].1), "no rule before the first turn");
1252        let sep_idx = rows.iter().position(|(_, l)| is_sep(l)).unwrap();
1253        assert!(matches!(rows[sep_idx].0, RowSlot::Turn(id) if id == b));
1254        assert_ne!(sep_idx, rows.len() - 1, "no rule after the last turn");
1255
1256        // The question row: prompt glyph + accent-bold styling.
1257        let (_, qline) = rows
1258            .iter()
1259            .find(|(_, l)| l.spans.iter().any(|s| s.content.contains("first")))
1260            .unwrap();
1261        assert!(
1262            qline.spans[0].content.starts_with('>'),
1263            "carries the prompt"
1264        );
1265        assert_eq!(qline.spans[0].style, qstyle, "accent + bold");
1266    }
1267
1268    mod rendering {
1269        use super::*;
1270        use crate::settings::themes::Theme;
1271        use ratatui::Terminal;
1272        use ratatui::backend::TestBackend;
1273
1274        fn draw(p: &mut ThreadPanel, theme: &Theme, width: u16, height: u16, focused: bool) {
1275            let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1276            terminal
1277                .draw(|f| {
1278                    let area = f.area();
1279                    p.render(f, area, theme, focused);
1280                })
1281                .unwrap();
1282        }
1283
1284        /// Clicking the separator row that opens a turn selects that turn — the
1285        /// rule joins `row_map` as a `Turn(id)` slot.
1286        #[test]
1287        fn clicking_a_separator_row_selects_its_turn() {
1288            let theme = Theme::default();
1289            let mut p = ThreadPanel::new();
1290            let a = p.thread_mut().ask("first".into());
1291            p.thread_mut().complete(a, "aaa".into(), vec![]);
1292            let b = p.thread_mut().ask("second".into());
1293            p.thread_mut().complete(b, "bbb".into(), vec![]);
1294            p.focus = ThreadFocus::Turns;
1295            // Select the first turn so a separator click can move selection.
1296            p.thread_mut().select_index(0);
1297            draw(&mut p, &theme, 40, 12, true);
1298
1299            // The first visible row mapped to `b` is its opening separator.
1300            let sep_row = p
1301                .row_map
1302                .iter()
1303                .position(|s| matches!(s, RowSlot::Turn(id) if *id == b))
1304                .expect("turn b has rows on screen");
1305            let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1306            let mouse = MouseEvent {
1307                kind: MouseEventKind::Down(MouseButton::Left),
1308                column: 0,
1309                row: p.turns_rect.y + sep_row as u16,
1310                modifiers: KeyModifiers::NONE,
1311            };
1312            p.handle_input(&InputEvent::Mouse(mouse), &tx);
1313            assert_eq!(p.thread().selected().unwrap().id, b);
1314        }
1315
1316        #[test]
1317        fn render_does_not_panic_across_states_and_sizes() {
1318            let theme = Theme::default();
1319            let mut p = ThreadPanel::new();
1320            p.set_client(Some(test_client())); // enabled composer render path
1321            draw(&mut p, &theme, 40, 10, true); // empty thread
1322
1323            let id = p
1324                .thread_mut()
1325                .ask("A fairly long question that should wrap across more than one line".into());
1326            draw(&mut p, &theme, 40, 10, true); // Thinking
1327
1328            p.thread_mut().complete(
1329                id,
1330                "An answer citing [1] a source and [2] another, spanning multiple \
1331                 wrapped lines to exercise citation styling."
1332                    .into(),
1333                vec![],
1334            );
1335            draw(&mut p, &theme, 40, 10, true); // Done, focused on Turns
1336            p.focus = ThreadFocus::Turns;
1337            draw(&mut p, &theme, 40, 10, true);
1338
1339            let id2 = p.thread_mut().ask("another".into());
1340            p.thread_mut().fail(id2, "boom".into());
1341            draw(&mut p, &theme, 40, 10, true); // Error
1342
1343            p.set_client(None);
1344            draw(&mut p, &theme, 40, 10, false); // disabled, unfocused
1345
1346            draw(&mut p, &theme, 3, 3, true); // degenerate tiny rect
1347            draw(&mut p, &theme, 0, 0, true); // zero rect
1348        }
1349
1350        fn turns_key(p: &mut ThreadPanel, code: KeyCode) {
1351            let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1352            p.handle_input(
1353                &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
1354                &tx,
1355            );
1356        }
1357
1358        /// A markdown answer (heading + prose citation + fenced code block)
1359        /// renders through the row map, and the prose citation stays clickable:
1360        /// the rendered answer slices still map 1:1 to the source, so
1361        /// `citation_at_column` resolves the marker. (Per-block styling is
1362        /// unit-tested in `markdown_lines`.) Covers I2's hit-testing constraint.
1363        #[test]
1364        fn markdown_answer_keeps_prose_citations_clickable() {
1365            let theme = Theme::default();
1366            let mut p = ThreadPanel::new();
1367            let id = p.thread_mut().ask("q".into());
1368            let answer = "# Title\nSee [1] here.\n```\nlet x = arr[9];\n```".to_string();
1369            p.thread_mut().complete(
1370                id,
1371                answer.clone(),
1372                vec![AskSource {
1373                    path: kimun_core::nfs::VaultPath::new("a.md"),
1374                    heading: "h".into(),
1375                    date: None,
1376                    score: 1.0,
1377                    text: String::new(),
1378                    ordinal: 1,
1379                }],
1380            );
1381            p.focus = ThreadFocus::Turns;
1382            draw(&mut p, &theme, 60, 12, true);
1383
1384            // Find the rendered answer row carrying the prose `[1]` and hit-test
1385            // the marker's column through its stored col_map.
1386            let hit = p.row_map.iter().find_map(|slot| match slot {
1387                RowSlot::Answer { range, col_map, .. } if answer[range.clone()].contains("[1]") => {
1388                    let slice = &answer[range.clone()];
1389                    let col = slice.find("[1]").unwrap() as u16 + 1;
1390                    Some(citation_at_column(slice, col_map, col))
1391                }
1392                _ => None,
1393            });
1394            assert_eq!(
1395                hit,
1396                Some(Some(1)),
1397                "the prose citation resolves through the rendered slice"
1398            );
1399        }
1400
1401        /// Render `p` and click the first cell of `text`; returns the events.
1402        fn click_text(p: &mut ThreadPanel, text: &str) -> Vec<AppEvent> {
1403            use ratatui::{Terminal, backend::TestBackend};
1404            let theme = Theme::default();
1405            let mut t = Terminal::new(TestBackend::new(80, 12)).unwrap();
1406            t.draw(|f| p.render(f, f.area(), &theme, true)).unwrap();
1407            let (x, y) = crate::test_support::find_text(t.backend().buffer(), text)
1408                .unwrap_or_else(|| panic!("{text:?} not drawn"));
1409            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1410            p.handle_input(&crate::test_support::mouse_down_at(x, y), &tx);
1411            crate::test_support::drain(&mut rx).into_iter().collect()
1412        }
1413
1414        /// The chips act on the selected turn whatever holds the keyboard —
1415        /// here the composer, where the `y`/`e` keys would be text.
1416        #[test]
1417        fn action_chips_act_from_any_focus() {
1418            let mut p = ThreadPanel::new();
1419            p.set_client(Some(test_client()));
1420            let id = p.thread_mut().ask("q".into());
1421            p.thread_mut().complete(id, "answer".into(), vec![]);
1422            p.focus = ThreadFocus::Composer;
1423            let ev = click_text(&mut p, "[e] Save as note");
1424            assert!(
1425                ev.iter().any(|e| matches!(e, AppEvent::FileOp(_))),
1426                "save as note opens the create dialog: {ev:?}"
1427            );
1428            let ev = click_text(&mut p, "[n] New");
1429            assert!(ev.iter().any(|e| matches!(
1430                e,
1431                AppEvent::ExecuteLeaderAction(crate::keys::leader::LeaderAction::AskNew)
1432            )));
1433        }
1434
1435        /// With no turn yet, only Send can act — and only with a question.
1436        #[test]
1437        fn turn_chips_are_inert_without_a_turn() {
1438            let mut p = ThreadPanel::new();
1439            p.set_client(Some(test_client()));
1440            assert!(click_text(&mut p, "[n] New").is_empty());
1441            assert!(click_text(&mut p, "[⏎] Send").is_empty(), "empty question");
1442        }
1443
1444        /// A completed answer taller than the viewport scrolls so its end is
1445        /// visible (bottom-follow), not stuck showing the question.
1446        #[test]
1447        fn completion_bottom_follows_to_show_the_answer_end() {
1448            let theme = Theme::default();
1449            let mut p = ThreadPanel::new();
1450            p.set_client(Some(test_client()));
1451            let id = p.thread_mut().ask("q".into());
1452            p.focus = ThreadFocus::Turns;
1453            // 10 answer lines. Rows: question(1) + 10 + trailing blank(1) = 12.
1454            let answer = (0..10)
1455                .map(|i| format!("line{i}"))
1456                .collect::<Vec<_>>()
1457                .join("\n");
1458            p.handle_data(AskData::AnswerReady {
1459                turn_id: id,
1460                result: Ok((answer, vec![])),
1461            });
1462            // Terminal height 8 − actions(1) − composer(3) = 4 turn rows. Rows
1463            // total 12 (question 1 + 10 answer + trailing blank 1) → end pins
1464            // at 12 − 4 = 8.
1465            draw(&mut p, &theme, 60, 8, true);
1466            assert_eq!(p.scroll, 8, "bottom-follow shows the answer's end");
1467        }
1468
1469        /// Completing an UNSELECTED turn (e.g. regenerating an old turn while
1470        /// reading another) must not arm bottom-follow — the reader's scroll
1471        /// and follow flags stay exactly where they were.
1472        #[test]
1473        fn completion_of_an_unselected_turn_leaves_scroll_untouched() {
1474            let mut p = ThreadPanel::new();
1475            p.set_client(Some(test_client()));
1476            let old = p.thread_mut().ask("old".into());
1477            p.thread_mut().complete(old, "old answer".into(), vec![]);
1478            let new = p.thread_mut().ask("new".into());
1479            p.thread_mut().complete(new, "new answer".into(), vec![]);
1480            // Read the newer turn; take a manual scroll position so we can prove
1481            // it survives.
1482            p.thread_mut().select_last();
1483            p.scroll = 4;
1484            p.follow_selection = false;
1485            p.bottom_follow_pending = false;
1486
1487            // The OLDER (unselected) turn completes a regeneration.
1488            p.handle_data(AskData::AnswerReady {
1489                turn_id: old,
1490                result: Ok(("regenerated".into(), vec![])),
1491            });
1492            assert_eq!(p.scroll, 4, "unselected completion must not move scroll");
1493            assert!(!p.follow_selection, "follow flags untouched");
1494            assert!(!p.bottom_follow_pending, "bottom-follow not armed");
1495
1496            // Completing the SELECTED turn does arm bottom-follow, as before.
1497            let newer = p.thread_mut().ask("newer".into());
1498            p.handle_data(AskData::AnswerReady {
1499                turn_id: newer,
1500                result: Ok(("visible".into(), vec![])),
1501            });
1502            assert!(
1503                p.bottom_follow_pending,
1504                "selected completion follows bottom"
1505            );
1506        }
1507
1508        /// Selecting an off-screen turn brings it into view; content-scroll keys
1509        /// clamp to the wrapped-row total.
1510        #[test]
1511        fn selection_scrolls_into_view_and_content_scroll_clamps() {
1512            let theme = Theme::default();
1513            let mut p = ThreadPanel::new();
1514            for i in 0..8 {
1515                let id = p.thread_mut().ask(format!("q{i}"));
1516                p.thread_mut().complete(id, format!("a{i}"), vec![]);
1517            }
1518            p.focus = ThreadFocus::Turns;
1519            // First turn: question(1)+answer(1)+blank(1) = 3 rows. Each later
1520            // turn adds a leading separator: separator(1)+question(1)+answer(1)+
1521            // blank(1) = 4 rows. 8 turns → 3 + 7×4 = 31 rows.
1522            // Terminal height 9 − actions(1) − composer(3) = 5 turn rows.
1523            draw(&mut p, &theme, 60, 9, true); // selection at the last turn
1524
1525            // Jump the selection to the first turn: it scrolls to the top.
1526            for _ in 0..8 {
1527                turns_key(&mut p, KeyCode::Char('k'));
1528            }
1529            draw(&mut p, &theme, 60, 9, true);
1530            assert_eq!(
1531                p.scroll, 0,
1532                "selecting the first turn scrolled it into view"
1533            );
1534
1535            // End scrolls to the bottom, clamped to total − height (31 − 5 = 26).
1536            turns_key(&mut p, KeyCode::End);
1537            draw(&mut p, &theme, 60, 9, true);
1538            assert_eq!(p.scroll, 26, "content scroll clamps to the last page");
1539        }
1540    }
1541}