feat(kebab-tui): P9-2 Search pane #44

Merged
altair823 merged 2 commits from feat/p9-2-tui-search into main 2026-05-02 15:05:47 +00:00
9 changed files with 907 additions and 36 deletions

View File

@@ -4,7 +4,7 @@
## 한 줄 요약
P0P5 + 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.
P0P5 + 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 @@ P0P5 + 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 후보

View File

@@ -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`).

View File

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

View File

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

View File

@@ -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) 에서 if p == Pane::Search && app.search.is_none() { app.search = Some(SearchState::default()); } 의 lazy-init 한 줄. p9-3/4 가 머지되면 같은 패턴으로 한 줄씩만 추가 (각자 자기 module 의 Default 사용) — 머지 conflict 표면 최소.

(칭찬) `KeyOutcome::SwitchPane(p)` 에서 `if p == Pane::Search && app.search.is_none() { app.search = Some(SearchState::default()); }` 의 lazy-init 한 줄. p9-3/4 가 머지되면 같은 패턴으로 한 줄씩만 추가 (각자 자기 module 의 `Default` 사용) — 머지 conflict 표면 최소.
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",

View 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?`

(칭찬) §1.5 dense format (4 lines per hit) 정확히 구현. header / path | section / snippet line 1 / snippet line 2 가 spec literal 그대로. fusion_score 4 자리 소수점 ({:.4}) 까지 spec 문구와 일치 — 사용자가 정확한 점수 비교 가능.

(칭찬) §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(" / ")
};

(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.

(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)
}
};

(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 컨벤션과 일치.

(KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _) => {
    move_selection(s, 1); ...
}

현재 테스트는 KeyModifiers::NONE 만 검증해서 회귀 안 잡힘 — SHIFT+j 케이스 추가 권장.

(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());

(칭찬) j/k 와 Down/Up 의 modifier 처리 분리. 문자 키는 KeyModifiers::NONE 만 (vim convention + SHIFT-J 가 "JSON" 의 J 로 들어가게), 화살표 키는 modifier 무관 (typing collision 없음). 두 분기로 나뉘어 의도가 코드 모양에 직접 표현됨.

(칭찬) 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());

(칭찬) Citation::Page 분기가 path 만 열고 tracing::debug! 한 줄로 의도 명시. 코멘트가 "왜 마지막 push 를 안 하는지" (# page N 가 vim/code/cursor 에서 두 번째 파일로 해석) 를 정확히 박아둠 — 미래 contributor 가 "PDF 페이지 jump 추가하자" 라고 시도하면 코멘트 + KEBAB_EDITOR_JUMP_FORMAT P+ 힌트가 즉시 답.

(칭찬) 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>`

(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 만 로그에.

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.

(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.

(칭찬) jump_to_citationRawModeRestore RAII guard 가 _restore 로 바인딩되어 함수 끝 또는 panic 시 Drop 이 raw_mode 재진입 + AltScreen 재진입. 이게 빠지면 사용자가 vim 종료 후 prompt 가 깨진 채 남는 UX 재앙. 명시적 RAII + Drop 안의 let _ = 가 errors-during-recovery 를 silent 처리 — 진짜 panic 정보를 가리지 않으면서 cleanup 만 보장.

(칭찬) `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;

(칭찬) debounce_dueOffsetDateTime - OffsetDateTime → Durationtry_into 결과를 unwrap_or(Duration::ZERO) 로 받음 — 시계 역행 (NTP 보정 등) 시 Negative Duration 으로 panic 안 함. 작은 invariant 지만 사용자 노트북이 절전모드 후 깨어날 때 정확히 트립할 수 있는 케이스를 사전 차단.

(칭찬) `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(())
}

View 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);

(칭찬) shift_j_stays_in_input_does_not_move_selection + shift_g_does_not_trigger_editor_jump 두 회귀 테스트가 "왜 R1 의 SHIFT 차단이 필요한지" 정확히 표현. 미래에 누군가 modifier 처리를 "단순화" 시도하면 즉시 빨개짐. 테스트 이름이 invariant 그대로 — naming 자체가 문서.

(칭찬) `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));
}

View File

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

View File

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