diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 1d53d0c..a87f8c8 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -387,6 +387,8 @@ pub struct App { pub ask: Option, /// Populated by p9-4. pub inspect: Option, + /// p9-fb-37: trace popup state, `Some` while open. + pub trace_popup: Option, /// Populated by p9-fb-03 when the user kicks off an in-shell /// ingest (Library `r`). Cleared by the run loop a few seconds /// after the run reaches a terminal event. @@ -461,6 +463,7 @@ impl App { search: None, ask: None, inspect: None, + trace_popup: None, ingest_state: None, error_overlay: None, should_quit: false, diff --git a/crates/kebab-tui/src/cheatsheet.rs b/crates/kebab-tui/src/cheatsheet.rs index 1af1751..f490ff9 100644 --- a/crates/kebab-tui/src/cheatsheet.rs +++ b/crates/kebab-tui/src/cheatsheet.rs @@ -80,6 +80,7 @@ pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) { ("Delete", "remove char at cursor"), ("g", "open hit's citation in $EDITOR (Normal)"), ("o", "inspect selected hit's chunk (Normal — was `i` pre-fb-21)"), + ("t", "open retrieval trace popup (Normal — p9-fb-37)"), ("i", "Normal → Insert (toggle back to typing)"), ("Esc", "back to Library"), ]); diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index d61c6f2..1457c1e 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -27,6 +27,7 @@ mod run; mod search; mod terminal; mod theme; +pub mod trace_popup; pub use input::{InputBuffer, display_width, place_cursor_x, truncate_to_display_width}; pub use theme::{Palette, Role, Theme}; diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index cc2db24..fb24b22 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -130,6 +130,21 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { if event::poll(POLL_INTERVAL)? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { + // p9-fb-37: trace popup eats keys while open. + // Sits ahead of cheatsheet + mode + pane dispatch + // so Esc / j / k / arrows route to the popup + // instead of leaking through to the search pane. + if app.trace_popup.is_some() { + let close = if let Some(popup) = app.trace_popup.as_mut() { + crate::trace_popup::handle_key_trace_popup(popup, key) + } else { + false + }; + if close { + app.trace_popup = None; + } + continue; + } // p9-fb-13: cheatsheet popup toggle takes // precedence over both mode + pane dispatch. // F1 toggles open/close. While visible, Esc @@ -255,6 +270,12 @@ fn render_root(f: &mut Frame, app: &App) { } render_status_bar(f, outer[2], app); render_key_hints(f, outer[3], app); + // p9-fb-37: trace popup overlays on top of pane content but + // below the error overlay (errors are higher-priority modal). + if let Some(popup) = &app.trace_popup { + let popup_area = centered_rect(80, 80, f.area()); + crate::trace_popup::render_trace_popup(f, popup_area, popup); + } if let Some(err) = &app.error_overlay { render_error_overlay(f, f.area(), err, &app.theme); } @@ -263,6 +284,28 @@ fn render_root(f: &mut Frame, app: &App) { } } +/// p9-fb-37: centered sub-rect helper for the trace popup. Returns +/// a rect of `percent_x` × `percent_y` percent of `r`, centered. +fn centered_rect(percent_x: u16, percent_y: u16, r: ratatui::layout::Rect) -> ratatui::layout::Rect { + use ratatui::layout::{Constraint, Direction, Layout}; + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + fn render_header(f: &mut Frame, area: Rect, app: &App) { let pane_label = match app.focus { Pane::Library => "Library", diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index cd1fb99..9166fe3 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -209,6 +209,49 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { // pre-fb-12 SHIFT/none heuristic). let is_normal = state.mode == crate::app::Mode::Normal; + // p9-fb-37: `t` opens the trace popup. Re-runs the last submitted + // query with SearchOpts.trace = true. Bypasses cache by going + // through `search_with_opts_with_config` (Task 5 wires opts.trace + // to skip the LRU cache). + if is_normal + && matches!( + (key.code, key.modifiers), + (KeyCode::Char('t'), KeyModifiers::NONE) + ) + { + let (last_query, has_results) = { + let s = state.search.as_ref().unwrap(); + (s.last_query.clone(), !s.hits.is_empty()) + }; + if !has_results { + return KeyOutcome::Continue; + } + if let Some((q_text, q_mode)) = last_query { + let q = kebab_core::SearchQuery { + text: q_text, + mode: q_mode, + k: state.config.search.default_k, + filters: kebab_core::SearchFilters::default(), + }; + let opts = kebab_core::SearchOpts { + trace: true, + ..Default::default() + }; + match kebab_app::search_with_opts_with_config(state.config.clone(), q, opts) { + Ok(resp) => { + if let Some(t) = resp.trace { + state.trace_popup = Some(crate::trace_popup::TracePopupState::new(t)); + } + } + Err(_) => { + // Silent failure — trace is debug-only; user + // can still see search hits without it. + } + } + } + return KeyOutcome::Continue; + } + // p9-fb-21: chunk-inspect rebound from `i` to `o` (vim "open"). // The `i` key is now the universal Normal→Insert toggle (handled // in `mode_intercept`), so it cannot also mean "inspect chunk" diff --git a/crates/kebab-tui/src/trace_popup.rs b/crates/kebab-tui/src/trace_popup.rs new file mode 100644 index 0000000..5374936 --- /dev/null +++ b/crates/kebab-tui/src/trace_popup.rs @@ -0,0 +1,139 @@ +//! p9-fb-37: TUI trace popup. Opens from Search pane via `t` key +//! when results are visible. Re-runs the current query with +//! `SearchOpts.trace = true` and displays the lex / vec / rrf union +//! + per-stage timing as a single scroll list. + +use crossterm::event::{KeyCode, KeyEvent}; +use kebab_core::SearchTrace; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +#[derive(Debug, Clone)] +pub struct TracePopupState { + pub trace: SearchTrace, + pub scroll: u16, +} + +impl TracePopupState { + pub fn new(trace: SearchTrace) -> Self { + Self { trace, scroll: 0 } + } +} + +pub fn render_trace_popup(f: &mut Frame, area: Rect, state: &TracePopupState) { + let mut lines: Vec = Vec::new(); + let bold = Style::default().add_modifier(Modifier::BOLD); + + lines.push(Line::from(Span::styled( + format!( + "Lexical ({} hits, {} ms)", + state.trace.lexical.len(), + state.trace.timing.lexical_ms, + ), + bold, + ))); + for c in &state.trace.lexical { + lines.push(Line::from(format!( + " #{:>2} score={:.4} chunk={}", + c.rank, c.score, c.chunk_id.0 + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!( + "Vector ({} hits, {} ms)", + state.trace.vector.len(), + state.trace.timing.vector_ms, + ), + bold, + ))); + for c in &state.trace.vector { + lines.push(Line::from(format!( + " #{:>2} score={:.4} chunk={}", + c.rank, c.score, c.chunk_id.0 + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!( + "RRF inputs ({} entries, {} ms fusion)", + state.trace.rrf_inputs.len(), + state.trace.timing.fusion_ms, + ), + bold, + ))); + for e in &state.trace.rrf_inputs { + lines.push(Line::from(format!( + " chunk={} lex={:?} vec={:?} fusion={:.4}", + e.chunk_id.0, e.lexical_rank, e.vector_rank, e.fusion_score + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("Total: {} ms", state.trace.timing.total_ms), + bold, + ))); + + let block = Block::default() + .title("Trace — Esc to close, j/k or ↑↓ to scroll") + .borders(Borders::ALL); + let p = Paragraph::new(lines) + .block(block) + .scroll((state.scroll, 0)) + .wrap(Wrap { trim: false }); + f.render_widget(p, area); +} + +/// Handle keys while popup is open. Returns true if the popup should close. +pub fn handle_key_trace_popup(state: &mut TracePopupState, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc => true, + KeyCode::Char('j') | KeyCode::Down => { + state.scroll = state.scroll.saturating_add(1); + false + } + KeyCode::Char('k') | KeyCode::Up => { + state.scroll = state.scroll.saturating_sub(1); + false + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyModifiers; + use kebab_core::TraceTiming; + + fn dummy_state() -> TracePopupState { + TracePopupState::new(SearchTrace { + lexical: vec![], + vector: vec![], + rrf_inputs: vec![], + timing: TraceTiming::default(), + }) + } + + #[test] + fn esc_closes() { + let mut s = dummy_state(); + assert!(handle_key_trace_popup( + &mut s, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + )); + } + + #[test] + fn j_scrolls_down() { + let mut s = dummy_state(); + assert!(!handle_key_trace_popup( + &mut s, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + )); + assert_eq!(s.scroll, 1); + } +}