feat(kebab-tui): p9-fb-12 follow-up — heuristic 제거, mode-authoritative dispatch

p9-fb-12 partial (PR #84) 의 deferred 부분 finalize. spec contract 의
\"기존 P9-3 ask 의 e/j/k input-empty heuristic 제거 — mode 로 명확히\"
완료. spec status `in_progress` → `completed`.

## 핵심 변경

- **`search::is_typing_mod`** (CTRL/ALT chord filter) 함수 삭제.
  search Char dispatch 가 `state.mode` 로 분기:
  - Normal + plain `j`/`k` → 선택 이동 (Char 이라도 Normal 이면
    navigation)
  - Insert + plain `j`/`k`/Char(c) (chord 제외) → input.push
  - Insert + CTRL/ALT chord → no-op (예약 — 향후 binding 위해)
  - Normal + 그 외 Char → no-op (no typing in Normal)
- **`search::handle_key_search` 의 `i` (chunk inspect) / `g` (editor
  jump) pre-pass** 가 `state.mode == Mode::Normal` 일 때만 fire.
  Insert 모드면 typed char (input 에 push). 기존 SHIFT-aware
  matches!() 가드는 Normal-mode 진입 가드로 흡수.
- **`ask::handle_key_ask`** 의 input-empty heuristic 삭제. e/j/k:
  - Normal + `e` → toggle explain
  - Normal + `j` → scroll down (saturating_add)
  - Normal + `k` → scroll up (saturating_sub)
  - Insert + 모든 plain Char (chord 제외) → input.push
- **테스트 fixture** (`tests/search.rs::fresh_app`,
  `tests/ask.rs::fresh_app`) 에 `app.mode = Mode::auto_for(focus)`
  추가 — run loop 의 auto-flip 동작을 테스트가 mirror.
- **기존 nav 테스트** (`j_k_move_selection_within_bounds`,
  `g_key_enqueues_pending_editor_request`, `e_toggles_explain_in_
  normal_mode`) 가 `app.mode = Mode::Normal` 명시.
- **신규 4 테스트** mode-authoritative 동작 회귀 방지:
  - search: `j_in_insert_types_does_not_move_selection`,
    `arbitrary_char_in_normal_mode_is_noop`
  - ask: `e_types_in_insert_mode_does_not_toggle_explain`,
    `jk_scroll_in_normal_mode_type_in_insert`

## 테스트

- 기존 109 + 신규 4 = 113 TUI 테스트 통과 (38 lib + 20 ask + 12
  inspect + 10 library + 6 mode + 25 search + 2 chat — search 23→25,
  ask 18→20)
- `cargo test --workspace --no-fail-fast -j 1` exit 0
- `cargo clippy --workspace --all-targets -- -D warnings` clean

## 문서

- README `kebab tui` 행: \"mode-authoritative dispatch — Search 의
  j/k/i/g, Ask 의 e/j/k 는 NORMAL 모드에서만 명령으로 동작, INSERT
  에서는 입력 문자로 typing\" 명시
- HANDOFF: 2026-05-03 follow-up entry
- spec status `in_progress` → `completed`

## HOTFIXES

p9-fb-12 partial PR (#84) 의 \"Deferred\" 항목이 본 PR 로 finalized
— HOTFIXES 새 entry 불필요 (기존 entry 가 이미 deferral 사유 + 해결
조건 명시).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 07:50:04 +00:00
parent 39f20988d7
commit 765ffc97c5
7 changed files with 201 additions and 79 deletions

View File

@@ -21,6 +21,11 @@ fn fresh_app() -> App {
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;
// p9-fb-12 follow-up: mirror the run loop's auto-flip — Search
// pane auto-Insert. Tests that exercise Normal-mode navigation
// (j/k move selection, i / g pre-pass) set Mode::Normal
// explicitly.
app.mode = kebab_tui::Mode::auto_for(Pane::Search);
app.search = Some(SearchState::default());
app
}
@@ -138,6 +143,10 @@ fn enter_with_empty_query_is_continue() {
#[test]
fn j_k_move_selection_within_bounds() {
let mut app = fresh_app();
// p9-fb-12 follow-up: j/k navigate only in Normal mode. Search
// pane auto-Insert via fresh_app, flip to Normal explicitly to
// exercise the navigation branch.
app.mode = kebab_tui::Mode::Normal;
{
let s = app.search.as_mut().unwrap();
s.hits = vec![
@@ -248,6 +257,46 @@ fn empty_state_renders_without_panic() {
.unwrap();
}
/// p9-fb-12 follow-up: in Insert mode, plain `j` types into input
/// (does NOT move selection). Replaces the pre-fb-12 heuristic
/// "is_typing_mod" with mode-authoritative dispatch.
#[test]
fn j_in_insert_types_does_not_move_selection() {
let mut app = fresh_app();
// Insert is auto for Search, but explicit for clarity.
app.mode = kebab_tui::Mode::Insert;
{
let s = app.search.as_mut().unwrap();
s.hits = vec![
make_hit(1, "a.md", "snip", line_citation("a.md", 1)),
make_hit(2, "b.md", "snip", line_citation("b.md", 1)),
];
s.selected_hit = 0;
}
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
let s = app.search.as_ref().unwrap();
assert_eq!(s.input, "j", "j must type in Insert mode");
assert_eq!(s.selected_hit, 0, "selection must NOT move in Insert");
}
/// p9-fb-12 follow-up: in Normal mode, plain Char other than j/k/i/g
/// is a no-op (no typing in Normal). Pin so a future char binding
/// addition has to think about Normal-mode behavior.
#[test]
fn arbitrary_char_in_normal_mode_is_noop() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Normal;
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
);
let s = app.search.as_ref().unwrap();
assert_eq!(s.input, "", "Normal-mode Char must NOT type");
}
#[test]
fn shift_j_stays_in_input_does_not_move_selection() {
// R1 fix: SHIFT-J / SHIFT-K must reach the typing branch so
@@ -295,6 +344,9 @@ fn shift_g_does_not_trigger_editor_jump() {
#[test]
fn g_key_enqueues_pending_editor_request() {
let mut app = fresh_app();
// p9-fb-12 follow-up: `g` (editor jump) is a Normal-mode command;
// in Insert mode it types as 'g'. Flip explicitly.
app.mode = kebab_tui::Mode::Normal;
{
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(1, "notes/x.md", "snippet", line_citation("notes/x.md", 42))];