diff --git a/HANDOFF.md b/HANDOFF.md index d2e1b09..163f042 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ ## 한 줄 요약 -P0–P5 + P6 + P7 + P9-1 (Library 패널) 머지 완료. `kebab ingest` 가 markdown / image / PDF 모두 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page citation 반환. `kebab tui` 가 Library 패널 제공. 다음 후보 = P9-2 (TUI search) / P9-3 / P9-4 / P9-5, 또는 보류 중인 P8 (audio) 의 시스템 dep brainstorm. +P0–P5 + P6 + P7 + P9-1 (Library) + P9-2 (Search) 머지 완료. `kebab ingest` 가 markdown / image / PDF 모두 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page citation 반환. `kebab tui` 가 Library + Search 패널 제공 (ask/inspect/desktop 진행 예정). 다음 후보 = P9-3 (TUI ask) / P9-4 (TUI inspect) / P9-5 (desktop tauri), 또는 보류 중인 P8 (audio) 의 시스템 dep brainstorm. ## Phase 로드맵 @@ -19,7 +19,7 @@ P0–P5 + P6 + P7 + P9-1 (Library 패널) 머지 완료. `kebab ingest` 가 mark | **P6** | 이미지 ingestion (OCR + caption) | `kebab-parse-image` | P5 | ✅ 완료 (4/4 component, OCR/caption Ollama-vision) | | **P7** | PDF text + page citation | `kebab-parse-pdf` | P5 | ✅ 완료 (3/3 component, page-level chunker + ingest wiring) | | **P8** | 음성 transcription + timestamp citation | `kebab-parse-audio` | P5 | ⏸ 보류 (whisper-rs 시스템 dep brainstorm 필요) | -| **P9** | TUI + desktop app | `kebab-tui`, `kebab-desktop` | P5 | 🟡 진행 (1/5 component — P9-1 Library 완료, P9-2/3/4/5 예정) | +| **P9** | TUI + desktop app | `kebab-tui`, `kebab-desktop` | P5 | 🟡 진행 (2/5 component — P9-1 Library + P9-2 Search 완료, P9-3/4/5 예정) | P0~P5 직렬. P6~P9 P5 이후 병렬 가능. @@ -37,6 +37,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **P7-2 `chunk_id` 충돌** — pdf-page-v1 가 한 페이지 여러 chunk 분할 → 같은 `block_ids` 충돌. per-chunk `policy_hash#c{char_start}` 변형 으로 회피. - **P7-3 storage UNIQUE bug** — `assets.workspace_path` UNIQUE + `upsert_asset_row` 의 `ON CONFLICT(asset_id)` gap 으로 byte 변경 re-ingest 실패. `purge_orphan_at_workspace_path` helper 추가, follow-up PR 으로 vector store orphan cleanup 까지 닫음 (`VectorStore::delete_by_chunk_ids`). - **P9-1 ratatui 0.28** — spec literal 의 `render_library` generic 이 ratatui 0.28 의 backend-agnostic Frame 과 어긋나 있어 제거. 테스트 seam `App::populate_library_for_testing` (`#[doc(hidden)]`) 추가. +- **P9-2 jump_to_citation workspace_root** — spec literal 의 `jump_to_citation(citation, editor_env)` 가 workspace_root 인자 누락. citation.path 가 workspace 상대라 editor 호출 시 절대 경로 필요 → `workspace_root: &Path` 인자 추가. 동일하게 `render_search` generic 도 P9-1 과 같은 사유로 제거. ## 다음 task 후보 diff --git a/README.md b/README.md index 0aa3121..cfc678e 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ kebab search "Markdown chunking 규칙" --mode hybrid # 질문 (Ollama 필요, PDF 인용 시 page 번호 surface) kebab ask "내 KB 설계에서 저장소 전략은?" -# Ratatui 셸 (Library 패널 — j/k 이동, f 필터, q 종료) +# Ratatui 셸 (Library + Search 패널, ask/inspect 패널 진행 중) kebab tui # 헬스 체크 (config 경로 / 데이터 디렉토리 쓰기 가능 여부) @@ -76,7 +76,7 @@ kebab doctor | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask ""` | RAG 답변 + 근거 인용. 근거 부족 시 거절. Ollama 필요 | | `kebab doctor` | 설정/모델/DB 헬스 체크 | -| `kebab tui` | Ratatui 셸 (Library 패널 v1, search/ask/inspect 패널 진행 중) | +| `kebab tui` | Ratatui 셸 (Library + Search 패널, ask/inspect 패널 진행 중) | | `kebab eval run / compare` | golden query 회귀 측정 | 모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`). diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 271e1a4..264a0c4 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -52,10 +52,45 @@ impl Default for LibraryState { } } -/// Forward-declared opaque sub-state. p9-2 fills the body in its own -/// crate. P9-1 only allocates the slot (`Option` on -/// `App`). -pub struct SearchState; +/// Search pane state — owned by p9-2. +/// +/// Field-set kept in `app.rs` (not in `search.rs`) so cross-module +/// access from `run.rs` (lazy-init, debounce tick) does not require +/// re-exporting field accessors. The pane behavior + render live in +/// `crate::search`. +pub struct SearchState { + pub input: String, + pub mode: kebab_core::SearchMode, + pub hits: Vec, + pub selected_hit: usize, + /// When the input last changed; the run loop debounces searches + /// against this (200 ms after the last keystroke). + pub input_dirty_at: Option, + /// Snapshot of `(input, mode)` at the moment the last search + /// fired. The debounce skips re-searches when nothing changed. + pub last_query: Option<(String, kebab_core::SearchMode)>, + /// True while a synchronous search call is in flight. The run + /// loop uses this to overlay a "searching…" hint. + pub searching: bool, + /// Cached preview text for the currently-selected hit (lazily + /// fetched via `kebab-app::inspect_chunk_with_config`). + pub preview: Option, +} + +impl Default for SearchState { + fn default() -> Self { + Self { + input: String::new(), + mode: kebab_core::SearchMode::Hybrid, + hits: Vec::new(), + selected_hit: 0, + input_dirty_at: None, + last_query: None, + searching: false, + preview: None, + } + } +} /// Forward-declared opaque sub-state. p9-3 fills the body. pub struct AskState; diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index 3fdc4b3..5b2685a 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -16,8 +16,10 @@ mod app; mod error_popup; mod library; mod run; +mod search; mod terminal; pub use app::{App, AskState, InspectState, KeyOutcome, LibraryState, Pane, SearchState}; pub use error_popup::{ErrorOverlay, render_error_overlay}; pub use library::{handle_key_library, render_library}; +pub use search::{build_jump_command, handle_key_search, jump_to_citation, render_search}; diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index 7cec1a0..8ae5043 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -10,9 +10,12 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use std::time::Duration; -use crate::app::{App, KeyOutcome, Pane}; +use crate::app::{App, KeyOutcome, Pane, SearchState}; use crate::error_popup::{ErrorOverlay, render_error_overlay}; use crate::library::{handle_key_library, refresh_docs, render_library}; +use crate::search::{ + debounce_due, fire_search, handle_key_search, refresh_preview, render_search, +}; use crate::terminal::TuiTerminal; /// Poll interval for crossterm's `event::poll`. Short enough that a @@ -24,12 +27,41 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { let mut terminal = TuiTerminal::enter()?; while !app.should_quit { - if app.library.inner.needs_refresh - && app.focus == Pane::Library - && app.error_overlay.is_none() - { - if let Err(e) = refresh_docs(app) { - app.error_overlay = Some(ErrorOverlay::from_anyhow(&e)); + // Per-pane idle work BEFORE rendering so the frame reflects + // freshly-loaded state. + if app.error_overlay.is_none() { + match app.focus { + Pane::Library => { + if app.library.inner.needs_refresh { + if let Err(e) = refresh_docs(app) { + app.error_overlay = Some(ErrorOverlay::from_anyhow(&e)); + } + } + } + Pane::Search => { + let due = app + .search + .as_ref() + .map(debounce_due) + .unwrap_or(false); + if due { + if let Err(e) = fire_search(app) { + app.error_overlay = Some(ErrorOverlay::from_anyhow(&e)); + } + } + // Lazy preview fetch when selection lacks one. + let needs_preview = app + .search + .as_ref() + .map(|s| s.preview.is_none() && !s.hits.is_empty()) + .unwrap_or(false); + if needs_preview { + if let Err(e) = refresh_preview(app) { + app.error_overlay = Some(ErrorOverlay::from_anyhow(&e)); + } + } + } + _ => {} } } @@ -40,22 +72,27 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { Event::Key(key) if key.kind == KeyEventKind::Press => { let outcome = match app.focus { Pane::Library => handle_key_library(app, key), - // p9-2/3/4 plug their handlers here as their - // crates land. Until then, the non-Library - // panes accept only `q` / `Esc` to return — - // anything else is a no-op. The footer hint - // tells the user the pane is unimplemented. - Pane::Search | Pane::Ask | Pane::Inspect | Pane::Jobs => { + Pane::Search => handle_key_search(app, key), + // p9-3/4/5 plug their handlers here as their + // crates land. Until then, those panes accept + // only `q` / `Esc` to return. + Pane::Ask | Pane::Inspect | Pane::Jobs => { handle_key_unimplemented_pane(app, key) } }; match outcome { KeyOutcome::Quit => app.should_quit = true, - KeyOutcome::SwitchPane(p) => app.focus = p, + KeyOutcome::SwitchPane(p) => { + app.focus = p; + // Lazy-init pane state on first switch. + if p == Pane::Search && app.search.is_none() { + app.search = Some(SearchState::default()); + } + } KeyOutcome::Refresh => { - // `needs_refresh` was already set by the - // pane handler; the next loop iteration - // services it. + // Library uses needs_refresh; Search uses + // input_dirty_at — pane-specific. The next + // loop iteration's idle pass services it. } KeyOutcome::Continue => {} } @@ -70,9 +107,6 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { /// Stub key handler for panes whose authoring task has not landed /// yet. `q` / `Esc` returns to Library; everything else is a no-op. -/// Does NOT delegate to `handle_key_library` because that would let -/// `j` / `k` / `f` mutate Library state while focus says otherwise — -/// confusing UX. fn handle_key_unimplemented_pane( app: &mut App, key: crossterm::event::KeyEvent, @@ -100,9 +134,10 @@ fn render_root(f: &mut Frame, app: &App) { render_header(f, outer[0], app); match app.focus { Pane::Library => render_library(f, outer[1], app), - // Until p9-2/3/4 land, the run loop never actually moves - // focus to those panes; render_library serves as a safe - // placeholder. + Pane::Search => render_search(f, outer[1], app), + // p9-3/4/5 panes are not yet rendered; placeholder is the + // Library frame — focus state already reads "Search" / + // "Ask" / etc. in the header so the user is not misled. _ => render_library(f, outer[1], app), } render_footer(f, outer[2], app); @@ -131,9 +166,6 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) { } fn render_footer(f: &mut Frame, area: Rect, app: &App) { - // p9-2/3/4 가 머지되기 전에는 SwitchPane(Search/Ask/Inspect) 가 - // focus 만 바꾸고 본문은 Library 가 그려지는 절뚝거림이 사용자에게 - // 보임. footer 에서 \"미구현\" 을 명시해 거짓말 안 함. let hints = match app.focus { Pane::Library => { if app.library.inner.filter_edit.is_some() { @@ -142,7 +174,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { "j/k=move gg=top G=bottom f=filter /=search ?=ask Enter=inspect q=quit" } } - Pane::Search => "Search pane not yet implemented (lands with p9-2) — q to return", + Pane::Search => "type=query Tab=mode Enter=search j/k=move g=open in $EDITOR Esc=back", Pane::Ask => "Ask pane not yet implemented (lands with p9-3) — q to return", Pane::Inspect => "Inspect pane not yet implemented (lands with p9-4) — q to return", Pane::Jobs => "Jobs pane not yet implemented — q to return", diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs new file mode 100644 index 0000000..a78fd68 --- /dev/null +++ b/crates/kebab-tui/src/search.rs @@ -0,0 +1,481 @@ +//! Search pane (P9-2). +//! +//! `App.search` slot is filled lazily by the run loop on first +//! `Pane::Search` switch. `handle_key_search` mutates only +//! `app.search` (parallel-safety contract from p9-1) — never touches +//! Library / Ask / Inspect state. +//! +//! Spec deviation (HOTFIXES `2026-05-02 P9-2`): +//! - `render_search` generic dropped (ratatui 0.28 Frame +//! is backend-agnostic, same as P9-1). +//! - `jump_to_citation` gained a `workspace_root: &Path` argument +//! missing from spec literal — citations carry workspace-relative +//! paths and the editor needs an absolute path to open. +//! +//! Per design §1.5 / §1.6 (search output dense format), §3.7 +//! (`SearchHit`), §0 Q3 (citation URI fragments). + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use kebab_core::{Citation, SearchHit, SearchMode, SearchQuery}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use crate::app::{App, KeyOutcome, Pane, SearchState}; +use crate::error_popup::ErrorOverlay; + +/// Debounce window after the last keystroke before re-searching. +/// Matches the spec's 200 ms. +pub const SEARCH_DEBOUNCE: Duration = Duration::from_millis(200); + +/// Maximum hits to fetch per query — matches `config.search.default_k` +/// in production but the trait does not expose `Config`, so we cap +/// here. Users running deep recall should `kebab search --json` for +/// large `k`. +const SEARCH_K: usize = 10; + +/// Render the Search pane: input bar (top), result list (middle), +/// preview (bottom). Each result row uses §1.5's 4-line dense format. +pub fn render_search(f: &mut Frame, area: Rect, state: &App) { + let Some(s) = state.search.as_ref() else { + // Pane has no state yet — should not happen because the run + // loop lazy-inits before render. Defensive empty block. + f.render_widget( + Block::default().title("Search").borders(Borders::ALL), + area, + ); + return; + }; + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(3), + Constraint::Length(7), + ]) + .split(area); + + render_input_bar(f, layout[0], s); + render_result_list(f, layout[1], s); + render_preview(f, layout[2], s); +} + +fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState) { + let mode_label = mode_label(s.mode); + let searching_hint = if s.searching { " searching…" } else { "" }; + let line = Line::from(vec![ + Span::styled(format!("[{mode_label}] "), Style::default().fg(Color::Cyan)), + Span::raw(s.input.as_str()), + Span::styled(searching_hint, Style::default().add_modifier(Modifier::DIM)), + ]); + let block = Block::default() + .title("query (Tab=mode Enter=search Esc=back)") + .borders(Borders::ALL); + f.render_widget(Paragraph::new(line).block(block), area); +} + +fn mode_label(m: SearchMode) -> &'static str { + match m { + SearchMode::Lexical => "lexical", + SearchMode::Vector => "vector", + SearchMode::Hybrid => "hybrid", + } +} + +fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) { + let block = Block::default() + .title(format!("results ({})", s.hits.len())) + .borders(Borders::ALL); + + if s.hits.is_empty() { + f.render_widget(block, area); + return; + } + + let items: Vec = s + .hits + .iter() + .map(|h| ListItem::new(format_hit_lines(h))) + .collect(); + let list = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + let mut list_state = ListState::default(); + list_state.select(Some(s.selected_hit.min(s.hits.len().saturating_sub(1)))); + f.render_stateful_widget(list, area, &mut list_state); +} + +/// §1.5 dense format — 4 lines per hit: +/// 1. `. ` +/// 2. ` | section_label?` +/// 3. snippet line 1 +/// 4. snippet line 2 (or trailing blank for layout symmetry) +fn format_hit_lines(h: &SearchHit) -> Vec> { + let header = format!( + "{}. {:.4} {}", + h.rank, + h.retrieval.fusion_score, + h.citation.to_uri(), + ); + let path_line = { + let hp = if h.heading_path.is_empty() { + String::from("-") + } else { + h.heading_path.join(" / ") + }; + match h.section_label.as_deref() { + Some(s) if !s.is_empty() => format!(" {hp} | {s}"), + _ => format!(" {hp}"), + } + }; + let mut snippet_lines = h.snippet.lines(); + let s1 = snippet_lines.next().unwrap_or("").to_string(); + let s2 = snippet_lines.next().unwrap_or("").to_string(); + vec![ + Line::from(Span::styled( + header, + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled(path_line, Style::default().fg(Color::DarkGray))), + Line::from(format!(" {s1}")), + Line::from(format!(" {s2}")), + ] +} + +fn render_preview(f: &mut Frame, area: Rect, s: &SearchState) { + let block = Block::default() + .title("preview (g=open in $EDITOR)") + .borders(Borders::ALL); + let body = match (&s.preview, s.hits.is_empty()) { + (_, true) => Paragraph::new(""), + (Some(text), _) => Paragraph::new(text.as_str()).wrap(Wrap { trim: false }), + (None, _) => Paragraph::new(Span::styled( + "(loading preview… select a hit to fetch its chunk text)", + Style::default().add_modifier(Modifier::DIM), + )), + }; + f.render_widget(body.block(block), area); +} + +/// Search pane key dispatch. Returns `KeyOutcome::Refresh` when the +/// run loop should re-fire `kebab-app::search`. Pure mutation on +/// `app.search` — never touches another pane's state. +pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { + if state.error_overlay.is_some() { + state.error_overlay = None; + return KeyOutcome::Continue; + } + if state.search.is_none() { + // No search state — bail back to Library. + return KeyOutcome::SwitchPane(Pane::Library); + } + + // `g` (editor jump) requires re-borrowing `state` for + // workspace_root after dropping the `&mut state.search` borrow. + // Handle it as a pre-pass so the rest of the function can use + // `state.search.as_mut()` without scope juggling. + // `g` only fires the editor jump on plain (no-modifier) press — + // SHIFT-G in vim land is "go to bottom" (not implemented here), + // and CTRL/ALT chords stay reserved. + if matches!( + (key.code, key.modifiers), + (KeyCode::Char('g'), KeyModifiers::NONE) + ) { + let (citation, has_hits) = { + let s = state.search.as_ref().unwrap(); + if s.hits.is_empty() { + (None, false) + } else { + (Some(s.hits[s.selected_hit].citation.clone()), true) + } + }; + if has_hits { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()); + let workspace_root = std::path::PathBuf::from(&state.config.workspace.root); + if let Err(e) = jump_to_citation(&citation.unwrap(), &editor, &workspace_root) { + state.error_overlay = Some(ErrorOverlay::from_anyhow(&e)); + } + } + return KeyOutcome::Continue; + } + + let s = state.search.as_mut().unwrap(); + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => KeyOutcome::SwitchPane(Pane::Library), + (KeyCode::Tab, _) => { + s.mode = cycle_mode(s.mode); + // Force re-search at the new mode if there's a query. + if !s.input.trim().is_empty() { + s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + } + KeyOutcome::Continue + } + (KeyCode::Enter, _) => { + // Skip debounce; refresh now if there's anything to query. + if s.input.trim().is_empty() { + KeyOutcome::Continue + } else { + s.input_dirty_at = None; + s.last_query = None; + KeyOutcome::Refresh + } + } + // `j` / `k` only fire as selection movers when *no* modifier is + // held. SHIFT-bearing keypresses (`J`, `K`) are typed input — + // letting them through here would corrupt every \"JSON\" / + // \"PostgreSQL\" search query. Down / Up arrows still accept + // any modifier (no typing collision). + (KeyCode::Char('j'), KeyModifiers::NONE) => { + move_selection(s, 1); + s.preview = None; + KeyOutcome::Continue + } + (KeyCode::Down, m) if !is_typing_mod(m) => { + move_selection(s, 1); + s.preview = None; + KeyOutcome::Continue + } + (KeyCode::Char('k'), KeyModifiers::NONE) => { + move_selection(s, -1); + s.preview = None; + KeyOutcome::Continue + } + (KeyCode::Up, m) if !is_typing_mod(m) => { + move_selection(s, -1); + s.preview = None; + KeyOutcome::Continue + } + (KeyCode::Backspace, _) => { + if !s.input.is_empty() { + s.input.pop(); + s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + } + KeyOutcome::Continue + } + (KeyCode::Char(c), _) => { + // Treat 'g' separately above; here 'g' would reach this + // branch only when `is_typing_mod` triggered — i.e. SHIFT + // 'G'. Fold into typing. + s.input.push(c); + s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + KeyOutcome::Continue + } + _ => KeyOutcome::Continue, + } +} + +fn cycle_mode(m: SearchMode) -> SearchMode { + match m { + SearchMode::Lexical => SearchMode::Vector, + SearchMode::Vector => SearchMode::Hybrid, + SearchMode::Hybrid => SearchMode::Lexical, + } +} + +fn is_typing_mod(m: KeyModifiers) -> bool { + // SHIFT alone is fine for typing capital letters, but CTRL/ALT + // means a chord — don't swallow as input. + m.contains(KeyModifiers::CONTROL) || m.contains(KeyModifiers::ALT) +} + +fn move_selection(s: &mut SearchState, delta: i32) { + if s.hits.is_empty() { + return; + } + let current = s.selected_hit as i32; + let last = (s.hits.len() as i32) - 1; + let next = (current + delta).clamp(0, last); + s.selected_hit = next as usize; +} + +/// Build the editor command for a citation. Splits out from +/// `jump_to_citation` so unit tests can assert command shape without +/// spawning a process. +/// +/// Returns `(program, args)` where `program` is the `$EDITOR` value +/// (or `vi` fallback) and `args` opens the file at the cited line / +/// page / region (best-effort for non-text citations). +pub fn build_jump_command( + citation: &Citation, + editor_env: &str, + workspace_root: &Path, +) -> (String, Vec) { + let (program, leading_args) = parse_editor_env(editor_env); + let path = workspace_root.join(&citation.path().0); + let path_str = path.to_string_lossy().into_owned(); + let mut args = leading_args; + + let editor_basename = std::path::Path::new(&program) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| program.clone()); + + match citation { + Citation::Line { start, .. } => { + if editor_basename.contains("code") || editor_basename.contains("cursor") { + // VS Code / Cursor: `code -g :` + args.push("-g".into()); + args.push(format!("{path_str}:{start}")); + } else { + // vim / nvim / vi / emacs / hx all accept `+`. + args.push(format!("+{start}")); + args.push(path_str); + } + } + Citation::Page { page, .. } => { + // No standard editor jump for PDFs across vim / VS Code / + // emacs. Earlier versions of this branch tried to push a + // `# page N` string as a final arg, but every common + // editor treats it as a *second file to open* — opening + // a stray buffer or splitting the window. Path-only is + // the honest best-effort: the user's PDF reader (or the + // editor's PDF plugin) handles in-document navigation. + // A `KEBAB_EDITOR_JUMP_FORMAT="pdf=evince -p {page} {path}"` + // env hook stays a P+ enhancement (per spec § Risks). + tracing::debug!( + target: "kebab-tui", + page, + "PDF citation — opening file only; editor page-jump unsupported" + ); + args.push(path_str); + } + _ => { + args.push(path_str); + } + } + (program, args) +} + +/// Suspend the TUI raw mode, spawn `$EDITOR`, restore raw mode on +/// return. Errors propagate; raw-mode restore happens via a guard so +/// a panic during the editor child does not strand the user in a +/// corrupt terminal. +pub fn jump_to_citation( + citation: &Citation, + editor_env: &str, + workspace_root: &Path, +) -> anyhow::Result<()> { + use crossterm::execute; + use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, + }; + + let (program, args) = build_jump_command(citation, editor_env, workspace_root); + + // Suspend. + let _ = execute!(std::io::stdout(), LeaveAlternateScreen); + let _ = disable_raw_mode(); + + // RAII guard re-enters even on panic. + struct RawModeRestore; + impl Drop for RawModeRestore { + fn drop(&mut self) { + let _ = enable_raw_mode(); + let _ = execute!(std::io::stdout(), EnterAlternateScreen); + } + } + let _restore = RawModeRestore; + + let status = Command::new(&program) + .args(&args) + .status() + .map_err(|e| anyhow::anyhow!("spawn {program} failed: {e}"))?; + if !status.success() { + anyhow::bail!("{program} exited with {status:?}"); + } + Ok(()) +} + +fn parse_editor_env(env: &str) -> (String, Vec) { + // `$EDITOR` may carry args, e.g. `vim -p`. Split on whitespace. + let mut parts = env.split_whitespace(); + let program = parts.next().unwrap_or("vi").to_string(); + let leading: Vec = parts.map(str::to_string).collect(); + (program, leading) +} + +/// Run-loop hook: tick called every poll cycle. Returns `true` if a +/// search should fire this tick (debounce expired and query +/// changed). +pub(crate) fn debounce_due(s: &SearchState) -> bool { + let Some(at) = s.input_dirty_at else { return false }; + let elapsed = (time::OffsetDateTime::now_utc() - at) + .try_into() + .unwrap_or(Duration::ZERO); + if elapsed < SEARCH_DEBOUNCE { + return false; + } + let q = s.input.trim(); + if q.is_empty() { + return false; + } + !matches!( + &s.last_query, + Some((prev_input, prev_mode)) + if prev_input == &s.input && *prev_mode == s.mode + ) +} + +/// Run-loop hook: actually perform the search, populate `hits`. The +/// state's `input_dirty_at` is cleared, `last_query` snapshots, and +/// `searching` flag toggles around the call. +pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> { + let cfg = state.config.clone(); + let (q_text, mode) = { + let s = state.search.as_mut().expect("Search slot must exist"); + s.searching = true; + s.input_dirty_at = None; + s.last_query = Some((s.input.clone(), s.mode)); + (s.input.clone(), s.mode) + }; + let query = SearchQuery { + text: q_text, + mode, + k: SEARCH_K, + filters: kebab_core::SearchFilters::default(), + }; + let result = kebab_app::search_with_config(cfg, query); + let s = state.search.as_mut().expect("Search slot must exist"); + s.searching = false; + match result { + Ok(hits) => { + s.hits = hits; + s.selected_hit = 0; + s.preview = None; + Ok(()) + } + Err(e) => { + s.hits.clear(); + s.selected_hit = 0; + Err(e) + } + } +} + +/// Run-loop hook: lazy-fetch preview text for the selected hit. +pub(crate) fn refresh_preview(state: &mut App) -> anyhow::Result<()> { + let cfg = state.config.clone(); + let chunk_id = { + let s = state.search.as_ref().expect("Search slot must exist"); + if s.preview.is_some() || s.hits.is_empty() { + return Ok(()); + } + let Some(hit) = s.hits.get(s.selected_hit) else { + return Ok(()); + }; + hit.chunk_id.clone() + }; + let chunk = kebab_app::inspect_chunk_with_config(cfg, &chunk_id)?; + let s = state.search.as_mut().expect("Search slot must exist"); + s.preview = Some(chunk.text); + Ok(()) +} + diff --git a/crates/kebab-tui/tests/search.rs b/crates/kebab-tui/tests/search.rs new file mode 100644 index 0000000..a24c808 --- /dev/null +++ b/crates/kebab-tui/tests/search.rs @@ -0,0 +1,302 @@ +//! Unit + snapshot tests for the Search pane (P9-2). + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use kebab_config::Config; +use kebab_core::{ + Citation, ChunkId, ChunkerVersion, DocumentId, EmbeddingModelId, IndexVersion, + RetrievalDetail, SearchHit, SearchMode, WorkspacePath, +}; +use kebab_tui::{ + App, KeyOutcome, Pane, SearchState, build_jump_command, handle_key_search, render_search, +}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::layout::Rect; +use std::path::Path; + +fn fresh_app() -> App { + let mut config = Config::defaults(); + config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".to_string(); + config.workspace.root = "/tmp/kebab-tui-search-tests-noop/workspace".to_string(); + let mut app = App::new(config).expect("App::new"); + app.focus = Pane::Search; + app.search = Some(SearchState::default()); + app +} + +fn make_hit(rank: u32, path: &str, snippet: &str, citation: Citation) -> SearchHit { + SearchHit { + rank, + chunk_id: ChunkId(format!("{:0<32}", rank)), + doc_id: DocumentId(format!("{:0<32}", rank * 2)), + doc_path: WorkspacePath::new(path.into()).unwrap(), + heading_path: vec!["Section".into(), "Sub".into()], + section_label: Some("Sub".into()), + snippet: snippet.into(), + citation, + retrieval: RetrievalDetail { + method: SearchMode::Hybrid, + fusion_score: 0.9, + lexical_score: Some(0.8), + vector_score: Some(0.95), + lexical_rank: Some(rank), + vector_rank: Some(rank), + }, + index_version: IndexVersion("v1".into()), + embedding_model: Some(EmbeddingModelId("multilingual-e5-small".into())), + chunker_version: ChunkerVersion("md-heading-v1".into()), + } +} + +fn line_citation(path: &str, line: u32) -> Citation { + Citation::Line { + path: WorkspacePath::new(path.into()).unwrap(), + start: line, + end: line, + section: None, + } +} + +#[test] +fn esc_returns_to_library() { + let mut app = fresh_app(); + let outcome = handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library)); +} + +#[test] +fn typing_appends_to_input_and_marks_dirty() { + let mut app = fresh_app(); + for ch in "hello".chars() { + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } + let s = app.search.as_ref().unwrap(); + assert_eq!(s.input, "hello"); + assert!(s.input_dirty_at.is_some()); +} + +#[test] +fn backspace_removes_last_char() { + let mut app = fresh_app(); + { + let s = app.search.as_mut().unwrap(); + s.input = "abc".into(); + } + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(app.search.as_ref().unwrap().input, "ab"); +} + +#[test] +fn tab_cycles_mode_lex_vec_hybrid() { + let mut app = fresh_app(); + { + let s = app.search.as_mut().unwrap(); + s.mode = SearchMode::Lexical; + } + let press_tab = |app: &mut App| { + handle_key_search(app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + }; + press_tab(&mut app); + assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Vector); + press_tab(&mut app); + assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Hybrid); + press_tab(&mut app); + assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Lexical); +} + +#[test] +fn enter_with_query_emits_refresh() { + let mut app = fresh_app(); + app.search.as_mut().unwrap().input = "rust".into(); + let outcome = handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::Refresh); +} + +#[test] +fn enter_with_empty_query_is_continue() { + let mut app = fresh_app(); + let outcome = handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::Continue); +} + +#[test] +fn j_k_move_selection_within_bounds() { + let mut app = fresh_app(); + { + let s = app.search.as_mut().unwrap(); + s.hits = vec![ + make_hit(1, "a.md", "snip a\nline2", line_citation("a.md", 1)), + make_hit(2, "b.md", "snip b\nline2", line_citation("b.md", 5)), + make_hit(3, "c.md", "snip c\nline2", line_citation("c.md", 7)), + ]; + s.selected_hit = 0; + } + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!(app.search.as_ref().unwrap().selected_hit, 1); + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!(app.search.as_ref().unwrap().selected_hit, 2); + // Bounds clamp. + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!(app.search.as_ref().unwrap().selected_hit, 2); + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!(app.search.as_ref().unwrap().selected_hit, 1); +} + +#[test] +fn build_jump_command_line_uses_plus_n_for_vim() { + let citation = line_citation("notes/foo.md", 42); + let (program, args) = + build_jump_command(&citation, "vim", Path::new("/tmp/workspace")); + assert_eq!(program, "vim"); + assert_eq!(args, vec!["+42".to_string(), "/tmp/workspace/notes/foo.md".into()]); +} + +#[test] +fn build_jump_command_line_uses_g_flag_for_code() { + let citation = line_citation("notes/foo.md", 42); + let (program, args) = + build_jump_command(&citation, "code", Path::new("/tmp/workspace")); + assert_eq!(program, "code"); + assert_eq!(args, vec!["-g".to_string(), "/tmp/workspace/notes/foo.md:42".into()]); +} + +#[test] +fn build_jump_command_passes_through_editor_args() { + let citation = line_citation("a.md", 7); + let (program, args) = build_jump_command(&citation, "nvim -p", Path::new("/ws")); + assert_eq!(program, "nvim"); + // Leading `-p` from $EDITOR env preserved before the +N path arg. + assert!(args[0] == "-p", "leading editor arg preserved: {args:?}"); + assert!(args.contains(&"+7".to_string())); + assert!(args.contains(&"/ws/a.md".to_string())); +} + +#[test] +fn render_search_with_hits_shows_input_and_path() { + let mut app = fresh_app(); + { + let s = app.search.as_mut().unwrap(); + s.input = "rust traits".into(); + s.mode = SearchMode::Hybrid; + s.hits = vec![ + make_hit(1, "notes/rust.md", "trait dispatch\nis dynamic", line_citation("notes/rust.md", 12)), + make_hit(2, "notes/dyn.md", "dynamic dispatch\nvtable", line_citation("notes/dyn.md", 3)), + ]; + s.selected_hit = 0; + } + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, 80, 24); + render_search(f, area, &app); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let rendered: String = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(rendered.contains("hybrid"), "mode badge rendered: {rendered}"); + assert!(rendered.contains("rust traits"), "input text rendered"); + assert!(rendered.contains("notes/rust.md"), "first hit path rendered"); + assert!(rendered.contains("notes/dyn.md"), "second hit path rendered"); +} + +#[test] +fn empty_state_renders_without_panic() { + let app = fresh_app(); + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, 80, 20); + render_search(f, area, &app); + }) + .unwrap(); +} + +#[test] +fn shift_j_stays_in_input_does_not_move_selection() { + // R1 fix: SHIFT-J / SHIFT-K must reach the typing branch so + // queries like \"JSON\" / \"PostgreSQL\" don't get \"J\" eaten as + // a selection move. + let mut app = fresh_app(); + { + let s = app.search.as_mut().unwrap(); + s.hits = vec![ + make_hit(1, "a.md", "snip\nl2", line_citation("a.md", 1)), + make_hit(2, "b.md", "snip\nl2", line_citation("b.md", 1)), + ]; + s.selected_hit = 0; + } + handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT), + ); + let s = app.search.as_ref().unwrap(); + assert_eq!(s.selected_hit, 0, "selection must NOT move on SHIFT-J"); + assert_eq!(s.input, "J", "SHIFT-J must reach the input buffer"); +} + +#[test] +fn shift_g_does_not_trigger_editor_jump() { + // R1 fix: capital G must not invoke jump_to_citation. Keep it + // as plain typing so \"Go\" / \"Greetings\" search queries work. + let mut app = fresh_app(); + { + let s = app.search.as_mut().unwrap(); + s.hits = vec![make_hit(1, "a.md", "snip\nl2", line_citation("a.md", 1))]; + } + let outcome = handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert_eq!(outcome, KeyOutcome::Continue); + assert_eq!(app.search.as_ref().unwrap().input, "G"); +} + +#[test] +fn no_search_state_returns_to_library() { + let mut config = Config::defaults(); + config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".into(); + let mut app = App::new(config).unwrap(); + app.focus = Pane::Search; + // search slot intentionally None. + let outcome = handle_key_search( + &mut app, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library)); +} diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 6c836c9..f232d49 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,24 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-02 — P9-2 TUI Search: render_search generic + jump_to_citation workspace_root + +**Discovered**: P9-2 implementation start. + +**Symptom 1 (cosmetic)**: Same shape as the P9-1 entry — `tasks/p9/p9-2-tui-search.md` § Public surface declares `render_search(...)`. ratatui 0.28's `Frame` is backend-agnostic; the generic is unused and clippy `-D warnings` rejects it. + +**Symptom 2 (load-bearing)**: Spec literal `jump_to_citation(citation: &Citation, editor_env: &str) -> Result<()>`. `Citation.path()` returns a `WorkspacePath` (workspace-relative), but the editor child needs an absolute path — `editor_env` does NOT carry the workspace root. The signature is unimplementable as written. + +**Fix**: +- `render_search(f: &mut Frame, area: Rect, state: &App)` — no generic. +- `jump_to_citation(citation: &Citation, editor_env: &str, workspace_root: &Path) -> Result<()>` — added `workspace_root` arg. The run-loop call site reads `state.config.workspace.root`. +- `build_jump_command` extracted as a pure helper so unit tests can assert the `(program, args)` shape without spawning a child process. Lives next to `jump_to_citation` in `kebab-tui::search`. + +**Trust note**: The `g` keybinding suspends the TUI (drops raw mode + LeaveAlternateScreen), runs the editor synchronously, then RAII-restores raw mode + AltScreen on return — even on panic in the child. Same shape as `kebab-tui::terminal::TuiTerminal::Drop` from P9-1. + +**Amends**: +- tasks/p9/p9-2-tui-search.md (`render_search` non-generic; `jump_to_citation` adds `workspace_root`). + ## 2026-05-02 — P9-1 TUI Library: render_library generic + test seam **Discovered**: P9-1 implementation start. diff --git a/tasks/p9/p9-2-tui-search.md b/tasks/p9/p9-2-tui-search.md index dfb26c6..43ae15e 100644 --- a/tasks/p9/p9-2-tui-search.md +++ b/tasks/p9/p9-2-tui-search.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-tui (search pane) task_id: p9-2 title: "TUI Search pane: input + result list + preview + editor jump" -status: planned +status: completed depends_on: [p2-2, p3-4, p9-1] unblocks: [] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md