feat(kebab-tui): P9-2 Search pane #44
@@ -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<B: Backend>` 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<B: Backend>` generic 도 P9-1 과 같은 사유로 제거.
|
||||
|
||||
## 다음 task 후보
|
||||
|
||||
|
||||
@@ -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 <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab ask "<query>"` | 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`).
|
||||
|
||||
@@ -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<SearchState>` 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<kebab_core::SearchHit>,
|
||||
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<time::OffsetDateTime>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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",
|
||||
|
||||
481
crates/kebab-tui/src/search.rs
Normal file
@@ -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<B: Backend>` 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<ListItem> = 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. `<rank>. <fusion_score> <path#frag>`
|
||||
/// 2. `<heading_path joined by " / "> | section_label?`
|
||||
|
claude-reviewer-01
commented
(칭찬) §1.5 dense format (4 lines per hit) 정확히 구현. (칭찬) §1.5 dense format (4 lines per hit) 정확히 구현. `header / path | section / snippet line 1 / snippet line 2` 가 spec literal 그대로. fusion_score 4 자리 소수점 (`{:.4}`) 까지 spec 문구와 일치 — 사용자가 정확한 점수 비교 가능.
|
||||
/// 3. snippet line 1
|
||||
/// 4. snippet line 2 (or trailing blank for layout symmetry)
|
||||
fn format_hit_lines(h: &SearchHit) -> Vec<Line<'static>> {
|
||||
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(" / ")
|
||||
};
|
||||
|
claude-reviewer-01
commented
(nit) Why: lib.rs 에 How to apply (둘 중 택일):
Library 와 일관성 위해 (B) 가 약간 더 좋지만 v1 scope 으론 (A) 도 OK. (nit) `format_hit_lines` 의 `_width: usize` 인자가 사용 안 됨 — 현재 ratatui 가 자동 truncate 하므로 호출부에서 width 전달 자체가 dead. 제거하거나 width-aware truncation (`truncate_to_display_width` 의 search 버전) 으로 활용 둘 중 하나.
Why: lib.rs 에 `unicode-width` dep 가 있는 것은 wide char (한국어) 의 column 계산 위해. Library 의 `format_doc_row` 는 사용 — search 도 같은 패턴 적용하면 한국어 path / heading 의 정렬이 안 깨짐.
How to apply (둘 중 택일):
- (A) `_width` 인자 제거. ratatui 의 자동 truncate 신뢰.
- (B) `path_line` / snippet 에 `truncate_to_display_width(line, width)` 적용 (현재 library.rs 의 helper 가 `pub(crate)` 로 노출 가능 — 또는 search.rs 에 동일 함수 복제).
Library 와 일관성 위해 (B) 가 약간 더 좋지만 v1 scope 으론 (A) 도 OK.
|
||||
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)
|
||||
}
|
||||
};
|
||||
|
claude-reviewer-01
commented
(suggestion / UX) Why: 검색 입력 중 대문자 자주 발생 (영어 약어 "JSON", 고유명사 "PostgreSQL"). caps-lock 우연 발화 시 무한 좌절. How to apply: 현재 테스트는 (suggestion / UX) `j` / `k` 의 SHIFT modifier 처리. `is_typing_mod` 가 CTRL/ALT 만 차단, SHIFT 는 typing 으로 간주. 그러면 `J` (SHIFT+j) 는 `is_typing_mod(SHIFT)=false` 통과 → `move_selection` 분기 매치 → 선택 이동. 사용자가 caps lock + 검색어 "JSON" 입력하면 J 가 selection 내려가고 입력에는 안 들어감.
Why: 검색 입력 중 대문자 자주 발생 (영어 약어 "JSON", 고유명사 "PostgreSQL"). caps-lock 우연 발화 시 무한 좌절.
How to apply: `j`/`k`/`g` 분기에 `m == KeyModifiers::NONE` 추가 (SHIFT 도 차단). `j` chord 가 의도하는 vim 식 "j is move" 는 modifier 없는 keypress 만이라는 게 vim 컨벤션과 일치.
```rust
(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _) => {
move_selection(s, 1); ...
}
```
현재 테스트는 `KeyModifiers::NONE` 만 검증해서 회귀 안 잡힘 — SHIFT+j 케이스 추가 권장.
|
||||
if has_hits {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
|
||||
|
claude-reviewer-01
commented
(칭찬) j/k 와 Down/Up 의 modifier 처리 분리. 문자 키는 (칭찬) j/k 와 Down/Up 의 modifier 처리 분리. 문자 키는 `KeyModifiers::NONE` 만 (vim convention + SHIFT-J 가 "JSON" 의 J 로 들어가게), 화살표 키는 modifier 무관 (typing collision 없음). 두 분기로 나뉘어 의도가 코드 모양에 직접 표현됨.
|
||||
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<String>) {
|
||||
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());
|
||||
|
||||
|
claude-reviewer-01
commented
(칭찬) Citation::Page 분기가 path 만 열고 (칭찬) Citation::Page 분기가 path 만 열고 `tracing::debug!` 한 줄로 의도 명시. 코멘트가 "왜 마지막 push 를 안 하는지" (`# page N` 가 vim/code/cursor 에서 두 번째 파일로 해석) 를 정확히 박아둠 — 미래 contributor 가 "PDF 페이지 jump 추가하자" 라고 시도하면 코멘트 + KEBAB_EDITOR_JUMP_FORMAT P+ 힌트가 즉시 답.
|
||||
match citation {
|
||||
Citation::Line { start, .. } => {
|
||||
if editor_basename.contains("code") || editor_basename.contains("cursor") {
|
||||
// VS Code / Cursor: `code -g <path>:<line>`
|
||||
|
claude-reviewer-01
commented
(issue / 위험) Why: 이 분기는 "PDF 인용 best-effort" 이지만 잘못된 인자를 추가하는 건 best-effort 가 아니라 actual harm. 사용자가 PDF 인용 누르면 vim 이 두 파일 (실제 PDF + How to apply: 마지막 push 제거. PDF 는 line jump 의 자연스러운 의미가 없으므로 단순히 또는 (issue / 위험) `Citation::Page` 분기에서 `args.push(format!("# page {page}"))` 가 마지막 인자로 들어감. vim/nvim/code 모두 이걸 "열어야 할 두 번째 파일" 로 해석합니다 — 즉 의도와 다른 동작 (`# page 7` 라는 새 빈 파일을 만들거나 not-found 에러).
Why: 이 분기는 "PDF 인용 best-effort" 이지만 잘못된 인자를 추가하는 건 best-effort 가 아니라 actual harm. 사용자가 PDF 인용 누르면 vim 이 두 파일 (실제 PDF + `# page 7` 빈 파일) 을 split 으로 열거나 `code` 가 untitled buffer 를 새로 만듬.
How to apply: 마지막 push 제거. PDF 는 line jump 의 자연스러운 의미가 없으므로 단순히 `+1` (또는 line jump 없이 path 만) 으로 충분 — 사용자가 어차피 PDF reader 에서 페이지 직접 navigate 해야 함. tracing::info! 로 "PDF citation, opening at page 1" 정도 hint 만 로그에.
```rust
Citation::Page { page, .. } => {
tracing::debug!(target: "kebab-tui", page, "PDF citation — page jump unsupported by editors, opening file only");
args.push(path_str);
}
```
또는 `KEBAB_EDITOR_JUMP_FORMAT` env (spec § Risks 가 이미 거론) 에 PDF reader 명령 (`"pdf=evince -p {page} {path}"`) 을 받을 길을 P+ 로 남기고 v1 은 path-only.
|
||||
args.push("-g".into());
|
||||
args.push(format!("{path_str}:{start}"));
|
||||
} else {
|
||||
// vim / nvim / vi / emacs / hx all accept `+<N>`.
|
||||
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.
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `jump_to_citation` 의 `RawModeRestore` RAII guard 가 `_restore` 로 바인딩되어 함수 끝 또는 panic 시 `Drop` 이 raw_mode 재진입 + AltScreen 재진입. 이게 빠지면 사용자가 vim 종료 후 prompt 가 깨진 채 남는 UX 재앙. 명시적 RAII + Drop 안의 `let _ =` 가 errors-during-recovery 를 silent 처리 — 진짜 panic 정보를 가리지 않으면서 cleanup 만 보장.
|
||||
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;
|
||||
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `debounce_due` 가 `OffsetDateTime - OffsetDateTime → Duration` 의 `try_into` 결과를 `unwrap_or(Duration::ZERO)` 로 받음 — 시계 역행 (NTP 보정 등) 시 Negative Duration 으로 panic 안 함. 작은 invariant 지만 사용자 노트북이 절전모드 후 깨어날 때 정확히 트립할 수 있는 케이스를 사전 차단.
|
||||
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<String>) {
|
||||
// `$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<String> = 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(())
|
||||
}
|
||||
|
||||
302
crates/kebab-tui/tests/search.rs
Normal file
@@ -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::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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);
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `shift_j_stays_in_input_does_not_move_selection` + `shift_g_does_not_trigger_editor_jump` 두 회귀 테스트가 "왜 R1 의 SHIFT 차단이 필요한지" 정확히 표현. 미래에 누군가 modifier 처리를 "단순화" 시도하면 즉시 빨개짐. 테스트 이름이 invariant 그대로 — naming 자체가 문서.
|
||||
})
|
||||
.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));
|
||||
}
|
||||
@@ -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<B: ratatui::backend::Backend>(...)`. 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
(칭찬)
KeyOutcome::SwitchPane(p)에서if p == Pane::Search && app.search.is_none() { app.search = Some(SearchState::default()); }의 lazy-init 한 줄. p9-3/4 가 머지되면 같은 패턴으로 한 줄씩만 추가 (각자 자기 module 의Default사용) — 머지 conflict 표면 최소.