feat(tui): search pane t-key opens TracePopup (fb-37)

This commit is contained in:
th-kim0823
2026-05-10 13:39:11 +09:00
parent 653e432a30
commit 5687cbc0e2
6 changed files with 230 additions and 0 deletions

View File

@@ -387,6 +387,8 @@ pub struct App {
pub ask: Option<AskState>,
/// Populated by p9-4.
pub inspect: Option<InspectState>,
/// p9-fb-37: trace popup state, `Some` while open.
pub trace_popup: Option<crate::trace_popup::TracePopupState>,
/// 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,

View File

@@ -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"),
]);

View File

@@ -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};

View File

@@ -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",

View File

@@ -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"

View File

@@ -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<Line> = 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);
}
}