feat(kebab-tui): P9-2 Search pane #44
Reference in New Issue
Block a user
Delete Branch "feat/p9-2-tui-search"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
P9-1 Library 의
/키 활성화.App.searchslot 채움 (parallel-safety contract 그대로). 사용자가kebab tui에서/→ 입력 → Tab 으로 mode 순환 → Enter 또는 200 ms debounce 로 자동 검색 → j/k 이동 → g 로$EDITORjump → Esc Library 복귀.핵심 결정
app.rs의 forward decl 본체 채움. cross-module 접근 (run.rs 가 debounce 시 시간 검사) 위해 fieldspub.debounce_due검사 후fire_search호출. blocking 검색 동안 "searching…" 헤더에 표시.+<line> <path>, VS Code/Cursor 는-g <path>:<line>.$EDITORenv 의 leading args (nvim -p) 그대로 보존. RAII raw-mode + AltScreen suspend/restore — panic 안전.inspect_chunk_with_config로 fetch.Spec deviation (HOTFIXES
2026-05-02 P9-2)render_search<B: Backend>generic 제거 — ratatui 0.28 Frame backend-agnostic. P9-1 와 같은 사유.jump_to_citation가workspace_root: &Path인자 추가 — citation.path 가 workspace 상대라 editor 호출 시 절대 경로 필요. spec literal 시그니처 unimplementable.테스트 (13개,
tests/search.rs)esc_returns_to_library/no_search_state_returns_to_librarytyping_appends_to_input_and_marks_dirtybackspace_removes_last_chartab_cycles_mode_lex_vec_hybridenter_with_query_emits_refresh/enter_with_empty_query_is_continuej_k_move_selection_within_bounds(clamp 검증)build_jump_command_line_uses_plus_n_for_vimbuild_jump_command_line_uses_g_flag_for_codebuild_jump_command_passes_through_editor_args(nvim -pleading 보존)render_search_with_hits_shows_input_and_path(TestBackend)empty_state_renders_without_panicDocs (sync rule)
kebab tui코멘트 갱신.completed.(ARCHITECTURE 변경 없음 — crate 구성 동일.)
검증
cargo test -p kebab-tui23 passed (10 library + 13 search)cargo clippy --workspace --all-targets -- -D warningscleancargo build --release -p kebab-clicleanTest plan
-D warnings통과kebab tui→/→ 입력 → Tab → Enter →gOut of scope (spec)
--explainretrieval trace UI.Library 의 / 키가 활성화. App.search slot 이 lazy 채워지고 (run loop 가 SwitchPane(Search) 받을 때), debounce 200 ms 후 kebab-app::search 호출, 선택된 hit 의 chunk 를 preview pane 에 표시. g 키로 $EDITOR (vim/nvim/code/cursor 자동 감지) 에서 citation 위치 열림. 핵심: - SearchState 본체 (`app.rs` 의 forward decl 채움) — input / mode / hits / selected_hit / input_dirty_at / last_query / searching / preview. - `src/search.rs` (신규): - `render_search(f, area, state)` — 3-pane layout (input bar / 결과 리스트 / preview). 각 hit 는 §1.5 dense 4-line format (rank.score URI / heading / snippet). - `handle_key_search`: typing → input + dirty mark. Tab → mode 순환. Enter → immediate refresh. j/k → 선택 이동 + preview invalidate. g → editor jump (RAII raw-mode suspend). Esc → Library 복귀. - `build_jump_command(citation, editor_env, workspace_root)` 가 vim 류 `+<line> path` / VS Code `code -g path:line` / cursor `cursor -g` 자동 분기. unit test 로 잠금. - `jump_to_citation` 가 raw-mode + AltScreen 을 RAII 로 suspend/restore (panic 안전). - run-loop hook 4 함수: `debounce_due` / `fire_search` / `refresh_preview` (private to crate). - run.rs: - Pane::Search arm 이 `handle_key_search` 로 dispatch + `render_search`. - SwitchPane(Search) 시 `app.search = Some(SearchState::default())` lazy init. - Idle tick 마다 debounce_due → fire_search, preview None → refresh_preview. - 테스트 13개 (`tests/search.rs`) — Esc/typing/backspace/Tab cycle/Enter refresh/j-k 이동/jump cmd vim+code+args/render w/hits/empty render/no slot. Spec deviation (HOTFIXES `2026-05-02 P9-2`): - `render_search<B: Backend>` generic 제거 (P9-1 와 동일 사유 — ratatui 0.28 Frame backend-agnostic). - `jump_to_citation` 가 `workspace_root: &Path` 인자 추가. Citation.path 가 workspace 상대 라 editor 호출 시 절대 경로 필요. spec literal 의 시그니처 는 unimplementable. Docs (sync rule): - README: TUI 행 \"Library + Search 패널, ask/inspect 진행 중\" + Quick start 의 `kebab tui` 코멘트 갱신. - HANDOFF: 한 줄 요약 + Phase status (P9 1/5 → 2/5) + deviation 한 줄 추가. - HOTFIXES: P9-2 entry 추가. - tasks/p9/p9-2 status: completed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — 두 actionable: (1) Citation::Page 분기가 마지막 인자 '# page N' 을 push 해서 vim/code 가 두 번째 파일로 해석 = 의도 외 동작 (issue). (2) j/k 가 SHIFT modifier 도 selection 으로 흡수해 "JSON"/"PostgreSQL" 같은 대문자 검색어 입력 깨짐 (suggestion). 그 외 nit 1 (unused width 인자) + 칭찬 (RAII guard, lazy-init, dense format).
@@ -50,4 +81,4 @@}};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 표면 최소.@@ -0,0 +114,4 @@}/// §1.5 dense format — 4 lines per hit:/// 1. `<rank>. <fusion_score> <path#frag>`(칭찬) §1.5 dense format (4 lines per hit) 정확히 구현.
header / path | section / snippet line 1 / snippet line 2가 spec literal 그대로. fusion_score 4 자리 소수점 ({:.4}) 까지 spec 문구와 일치 — 사용자가 정확한 점수 비교 가능.@@ -0,0 +129,4 @@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-widthdep 가 있는 것은 wide char (한국어) 의 column 계산 위해. Library 의format_doc_row는 사용 — search 도 같은 패턴 적용하면 한국어 path / heading 의 정렬이 안 깨짐.How to apply (둘 중 택일):
_width인자 제거. ratatui 의 자동 truncate 신뢰.path_line/ snippet 에truncate_to_display_width(line, width)적용 (현재 library.rs 의 helper 가pub(crate)로 노출 가능 — 또는 search.rs 에 동일 함수 복제).Library 와 일관성 위해 (B) 가 약간 더 좋지만 v1 scope 으론 (A) 도 OK.
@@ -0,0 +195,4 @@}};if has_hits {let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());(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 도 차단).jchord 가 의도하는 vim 식 "j is move" 는 modifier 없는 keypress 만이라는 게 vim 컨벤션과 일치.현재 테스트는
KeyModifiers::NONE만 검증해서 회귀 안 잡힘 — SHIFT+j 케이스 추가 권장.@@ -0,0 +321,4 @@// search target. Best-effort.args.push("+1".to_string());args.push(path_str);args.push(format!("# page {page}"));(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 만 로그에.또는
KEBAB_EDITOR_JUMP_FORMATenv (spec § Risks 가 이미 거론) 에 PDF reader 명령 ("pdf=evince -p {page} {path}") 을 받을 길을 P+ 로 남기고 v1 은 path-only.@@ -0,0 +357,4 @@let _ = enable_raw_mode();let _ = execute!(std::io::stdout(), EnterAlternateScreen);}}(칭찬)
jump_to_citation의RawModeRestoreRAII guard 가_restore로 바인딩되어 함수 끝 또는 panic 시Drop이 raw_mode 재진입 + AltScreen 재진입. 이게 빠지면 사용자가 vim 종료 후 prompt 가 깨진 채 남는 UX 재앙. 명시적 RAII + Drop 안의let _ =가 errors-during-recovery 를 silent 처리 — 진짜 panic 정보를 가리지 않으면서 cleanup 만 보장.@@ -0,0 +383,4 @@/// 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)(칭찬)
debounce_due가OffsetDateTime - OffsetDateTime → Duration의try_into결과를unwrap_or(Duration::ZERO)로 받음 — 시계 역행 (NTP 보정 등) 시 Negative Duration 으로 panic 안 함. 작은 invariant 지만 사용자 노트북이 절전모드 후 깨어날 때 정확히 트립할 수 있는 케이스를 사전 차단.1. **Citation::Page 분기 fix** — `args.push(format!(\"# page {page}\"))` 가 vim/code/cursor 에 \"두 번째 파일\" 로 해석돼 의도 외 동작 (split / new buffer). 마지막 push 제거, path 만 열고 `tracing::debug!` 한 줄. PDF 페이지 jump 는 사용자 PDF reader 책임 — `KEBAB_EDITOR_JUMP_FORMAT` env hook 은 P+ enhancement. 2. **j/k/g 의 SHIFT modifier 차단** — `is_typing_mod` 가 SHIFT 를 typing 으로 취급하던 부분이 J/K/G 를 selection 키로 흡수해 \"JSON\" / \"PostgreSQL\" / \"Go\" 같은 대문자 검색어 깨짐. arrow 키 (Down/Up) 는 modifier 무관 유지, 문자 키 (j/k/g) 는 `KeyModifiers::NONE` 만. SHIFT-J / SHIFT-G 회귀 테스트 2건 추가. 3. **`format_hit_lines` 의 unused `_width` 인자 제거** — ratatui 자동 truncate 신뢰 (Library 의 한국어 column 정렬은 별도 path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 2 — 회차 1 지적 3건 (PDF citation extra arg / SHIFT modifier 차단 / unused width param) 모두 반영. SHIFT-J / SHIFT-G 회귀 테스트 2개 추가. 25 tests pass + clippy clean. 머지 가능. P9-2 완료 → 사용자 / 키 활성, ask/inspect 패널 후속.
@@ -0,0 +197,4 @@}};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 없음). 두 분기로 나뉘어 의도가 코드 모양에 직접 표현됨.@@ -0,0 +317,4 @@.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+ 힌트가 즉시 답.@@ -0,0 +242,4 @@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 자체가 문서.