diff --git a/HANDOFF.md b/HANDOFF.md index e95cee5..c3f9428 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -57,6 +57,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-17)** — Multi-turn chat session 영속화 (storage 만 — UI 는 p9-fb-18). SQLite V005 migration (spec 의 V004 가 p9-fb-19 의 kv 와 충돌해서 V005 로 시프트, HOTFIXES) 으로 `chat_sessions` (session_id PK + created_at + updated_at + title + config_snapshot_json) + `chat_turns` (turn_id PK + session_id FK ON DELETE CASCADE + turn_index + question + answer + citations_json + created_at, UNIQUE(session_id, turn_index)) + `idx_chat_turns_session` 추가. `kebab_core::ChatSessionRepo` trait 6 메서드 (create_session / get_session / list_sessions / delete_session / append_turn / list_turns) + `kebab_core::{ChatSessionRow, ChatTurnRow}` 신규 export. `kebab-store-sqlite::SqliteStore` impl (별 `chat_sessions.rs` 모듈) — append_turn 이 insert + parent updated_at bump 을 같은 conn 에서 처리. frozen design §5 storage 에 §5.7a chat_sessions/turns 절 신설. spec: `tasks/p9/p9-fb-17-chat-session-storage.md`. unblocks p9-fb-18 (CLI session/repl). - **2026-05-03 P9 도그푸딩 후속 (p9-fb-18)** — CLI `kebab ask --session ` (multi-turn). p9-fb-17 의 ChatSessionRepo 위에 `kebab-app::App::ask_with_session(session_id, query, opts) -> Answer` 메서드. 첫 호출 시 자동으로 `chat_sessions` row 생성 (title = 첫 question NFC trim 40 chars), 이후 호출은 `list_turns` 로 prior history 받아 `RagPipeline::ask_with_history` 호출 + 새 turn append. `App` 의 helper: `first_question_title(question)` (NFC + trim + 40 char cap, fallback `"untitled"`) + `blake3_truncate(input)` (32-hex `turn_id` 생성). facade `kebab_app::ask_with_session_with_config` + CLI `--session ` flag 추가. `--repl` 은 spec 명시 사항이지만 stdin loop fixture 부담 으로 후속 task 로 deferral (out of scope per HANDOFF). spec: `tasks/p9/p9-fb-18-cli-ask-session-repl.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 partial)** — TUI vim-style mode machine (절반 ship — heuristic 제거는 follow-up). `kebab_tui::Mode::{Normal, Insert}` enum + `Mode::auto_for(pane)` (Library/Inspect/Jobs → Normal, Search/Ask → Insert) + `Mode::label()` (`"-- NORMAL --"` / `"-- INSERT --"`) + `App.mode: Mode` field. run loop `mode_intercept(app, key)` 가 dispatch 전 intercept — Insert 에서 `Esc` → Normal (어디서나), Normal 에서 `i` → Insert (Library/Inspect/Jobs 만, Search/Ask 는 자동 Insert 라 `i` 가 typed char). 헤더 우측에 mode label colored (Insert = Role::Success green, Normal = Role::Heading cyan+bold). pane 전환 시 `app.mode = Mode::auto_for(p)` 자동 flip. **Deferred (HOTFIXES entry)**: `is_typing_mod` (search) + input-empty heuristic (ask) 는 후속 PR 에서 mode-authoritative 로 교체 — 현재는 user-visible signal (label + auto flip + i/Esc) 만 ship, 키 dispatch 는 heuristic 유지. spec status `in_progress` (not `completed`). spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`. +- **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 follow-up)** — heuristic 제거 (partial PR 의 deferred 부분 finalize). `search::is_typing_mod` (CTRL/ALT chord filter) 함수 삭제 + `ask::handle_key_ask` 의 input-empty heuristic 삭제. 새 dispatch: `search::handle_key_search` 의 `i` (chunk inspect) / `g` (editor jump) pre-pass 가 `state.mode == Mode::Normal` 일 때만 fire (Insert 에서는 typed char). main match 의 `j`/`k`/Char(c) 가 `state.mode` 로 분기 (Normal → 선택 이동, Insert → input.push). `ask::handle_key_ask` 의 `e`/`j`/`k` 도 동일 패턴 — Normal 에서 toggle/scroll, Insert 에서 input typing. 테스트 fixture (`tests/search.rs::fresh_app`, `tests/ask.rs::fresh_app`) 가 `app.mode = Mode::auto_for(focus)` 로 run-loop 동작 mirror. 기존 nav 테스트 (j_k_move, g_key_enqueues, e_toggles) 는 explicit `app.mode = Mode::Normal` 추가, 신규 4 테스트 (j_in_insert_types / arbitrary_char_in_normal_noop / e_types_in_insert / jk_scroll-in-normal-type-in-insert) 가 mode-authoritative 동작 pin. spec status `in_progress` → `completed`. spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`. ## 다음 task 후보 diff --git a/README.md b/README.md index e4f76ff..664b61f 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ kebab doctor | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask "" [--show-citations / --hide-citations] [--session ]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session ` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe | | `kebab doctor` | 설정/모델/DB 헬스 체크 | -| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (Library/Inspect 만), `Esc` 로 Insert→Normal 어디서나. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) | +| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (Library/Inspect 만), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/i/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) | | `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | | `kebab eval run / compare` | golden query 회귀 측정 | diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index 2021d47..41fc7f5 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -341,41 +341,24 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { spawn_ask_worker(state); KeyOutcome::Continue } - // `e` only as a plain (no-modifier) press — typing 'e' in a - // word like "explain" must still reach the input buffer. - // The spec lists `e` as the explain-toggle; we apply the same - // SHIFT-aware convention as P9-2's `g` jump. - (KeyCode::Char('e'), KeyModifiers::NONE) => { - // Ambiguity with typing — distinguish via empty input as - // a heuristic: when input is empty, `e` toggles; while - // typing, `e` reaches the buffer. Vim users will recognise - // this "command vs insert" split applied at the keystroke - // level. + // p9-fb-12 follow-up: `e` / `j` / `k` are mode-gated. Normal + // mode → toggle explain / scroll up/down. Insert mode → typed + // into input buffer. The pre-fb-12 input-empty heuristic + // ("if input.is_empty() then command else type") is gone — + // Mode is authoritative. + (KeyCode::Char('e'), KeyModifiers::NONE) if state.mode == crate::app::Mode::Normal => { let s = state.ask.as_mut().unwrap(); - if s.input.is_empty() { - s.explain = !s.explain; - KeyOutcome::Continue - } else { - s.input.push('e'); - KeyOutcome::Continue - } - } - (KeyCode::Char('j'), KeyModifiers::NONE) => { - let s = state.ask.as_mut().unwrap(); - if s.input.is_empty() { - s.scroll = s.scroll.saturating_add(1); - } else { - s.input.push('j'); - } + s.explain = !s.explain; KeyOutcome::Continue } - (KeyCode::Char('k'), KeyModifiers::NONE) => { + (KeyCode::Char('j'), KeyModifiers::NONE) if state.mode == crate::app::Mode::Normal => { let s = state.ask.as_mut().unwrap(); - if s.input.is_empty() { - s.scroll = s.scroll.saturating_sub(1); - } else { - s.input.push('k'); - } + s.scroll = s.scroll.saturating_add(1); + KeyOutcome::Continue + } + (KeyCode::Char('k'), KeyModifiers::NONE) if state.mode == crate::app::Mode::Normal => { + let s = state.ask.as_mut().unwrap(); + s.scroll = s.scroll.saturating_sub(1); KeyOutcome::Continue } (KeyCode::Backspace, _) => { @@ -383,11 +366,18 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { s.input.pop(); KeyOutcome::Continue } - (KeyCode::Char(c), _) => { + // Insert mode: every non-chord Char (incl. e/j/k) types into + // input. CTRL/ALT chords stay reserved. + (KeyCode::Char(c), m) + if state.mode == crate::app::Mode::Insert + && !m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) => + { let s = state.ask.as_mut().unwrap(); s.input.push(c); KeyOutcome::Continue } + // Normal mode + un-handled Char → no-op (no typing in Normal). _ => KeyOutcome::Continue, } } diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index dbf31e3..d945d9a 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -177,17 +177,18 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { 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. - // `i` (chunk inspect) — pre-pass like `g`. Only fires on plain - // press, so typing 'i' in queries like "instance" still reaches - // the input buffer (P9-2 SHIFT/none convention). - if matches!( - (key.code, key.modifiers), - (KeyCode::Char('i'), KeyModifiers::NONE) - ) { + // p9-fb-12 follow-up: `i` (chunk inspect) + `g` (editor jump) are + // Normal-mode commands. In Insert they type as characters into + // the query buffer (mode-authoritative dispatch — replaces the + // pre-fb-12 SHIFT/none heuristic). + let is_normal = state.mode == crate::app::Mode::Normal; + + if is_normal + && matches!( + (key.code, key.modifiers), + (KeyCode::Char('i'), KeyModifiers::NONE) + ) + { let chunk_id = { let s = state.search.as_ref().unwrap(); if s.hits.is_empty() { @@ -207,13 +208,12 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { return KeyOutcome::Continue; } - // `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) - ) { + if is_normal + && 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() { @@ -245,6 +245,12 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { let s = state.search.as_mut().unwrap(); + // p9-fb-12 follow-up: mode-authoritative dispatch. The pre-fb-12 + // `is_typing_mod` heuristic (SHIFT-aware char filter) is gone — + // mode now decides whether a Char goes to the input buffer or + // becomes a navigation command. `Tab` (mode cycle), `Enter` + // (refresh), `Backspace`, arrow keys, Esc work in both modes + // because they have no typing ambiguity. match (key.code, key.modifiers) { (KeyCode::Esc, _) => KeyOutcome::SwitchPane(Pane::Library), (KeyCode::Tab, _) => { @@ -265,27 +271,12 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { 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) => { + (KeyCode::Down, _) => { 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) => { + (KeyCode::Up, _) => { move_selection(s, -1); s.preview = None; KeyOutcome::Continue @@ -297,14 +288,47 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { } 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. + // p9-fb-12 follow-up: Char dispatch is mode-gated. Normal + // mode → j/k navigate, other Char fall through (no typing). + // Insert mode → every non-chord Char is typed. + (KeyCode::Char('j'), KeyModifiers::NONE) if !is_normal => { + // 'j' in Insert types literally; falls through to the + // catch-all Insert branch below. (This guard short- + // circuits so the navigation arm doesn't fire.) + s.input.push('j'); + s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + KeyOutcome::Continue + } + (KeyCode::Char('k'), KeyModifiers::NONE) if !is_normal => { + s.input.push('k'); + s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + KeyOutcome::Continue + } + (KeyCode::Char('j'), KeyModifiers::NONE) => { + move_selection(s, 1); + s.preview = None; + KeyOutcome::Continue + } + (KeyCode::Char('k'), KeyModifiers::NONE) => { + move_selection(s, -1); + s.preview = None; + KeyOutcome::Continue + } + (KeyCode::Char(c), m) + if !is_normal + && !m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) => + { + // Insert mode: every plain or SHIFT-only Char goes to + // input. CTRL/ALT chords stay reserved for future + // bindings (and don't currently match any Search + // command, so they're a safe fall-through to Continue). s.input.push(c); s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); KeyOutcome::Continue } + // Normal mode + un-handled Char → no-op (no typing in + // Normal). Modifier chords always no-op. _ => KeyOutcome::Continue, } } @@ -317,11 +341,9 @@ fn cycle_mode(m: SearchMode) -> SearchMode { } } -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) -} +// p9-fb-12 follow-up: `is_typing_mod` removed. Mode field on `App` +// is now authoritative — the dispatch above gates Char handling on +// `state.mode`, not on modifier-vs-input heuristics. fn move_selection(s: &mut SearchState, delta: i32) { if s.hits.is_empty() { diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index cf40455..8929710 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -23,6 +23,10 @@ fn fresh_app() -> App { config.workspace.root = "/tmp/kebab-tui-ask-tests-noop/workspace".to_string(); let mut app = App::new(config).expect("App::new"); app.focus = Pane::Ask; + // p9-fb-12 follow-up: mirror the run loop's auto-flip on pane + // switch — Search/Ask auto-Insert. Tests that want Normal-mode + // navigation behaviour set `app.mode = Mode::Normal` explicitly. + app.mode = kebab_tui::Mode::auto_for(Pane::Ask); app.ask = Some(AskState::default()); app } @@ -116,9 +120,62 @@ fn backspace_pops_input() { assert_eq!(app.ask.as_ref().unwrap().input, "abc"); } +/// p9-fb-12 follow-up: `e` types into input in Insert mode (does +/// NOT toggle explain). Replaces the pre-fb-12 heuristic +/// "input.is_empty() then toggle else type" with mode-authoritative +/// dispatch. #[test] -fn e_toggles_explain_when_input_empty() { +fn e_types_in_insert_mode_does_not_toggle_explain() { let mut app = fresh_app(); + // Insert auto for Ask, but explicit for clarity. + app.mode = kebab_tui::Mode::Insert; + assert!(!app.ask.as_ref().unwrap().explain); + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.input, "e", "e must type in Insert mode"); + assert!(!s.explain, "explain must NOT toggle in Insert mode"); +} + +/// p9-fb-12 follow-up: `j` / `k` are scroll commands in Normal mode. +/// In Insert they type. Replaces input-empty heuristic. +#[test] +fn jk_scroll_in_normal_mode_type_in_insert() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().scroll, 1, "j scrolls down in Normal"); + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().scroll, 0, "k scrolls up in Normal"); + // Now Insert — j/k type. + app.mode = kebab_tui::Mode::Insert; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().input, "jk"); + assert_eq!(app.ask.as_ref().unwrap().scroll, 0, "no scroll in Insert"); +} + +/// p9-fb-12 follow-up: `e` toggles explain in Normal mode (was +/// previously gated on `input.is_empty()` heuristic). Test forces +/// Normal explicitly to mirror the run-loop flow (user pressed Esc). +#[test] +fn e_toggles_explain_in_normal_mode() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; assert!(!app.ask.as_ref().unwrap().explain); handle_key_ask( &mut app, diff --git a/crates/kebab-tui/tests/search.rs b/crates/kebab-tui/tests/search.rs index 349856d..f1edc99 100644 --- a/crates/kebab-tui/tests/search.rs +++ b/crates/kebab-tui/tests/search.rs @@ -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))]; diff --git a/tasks/p9/p9-fb-12-tui-mode-machine.md b/tasks/p9/p9-fb-12-tui-mode-machine.md index 1b5b260..1ed4a9a 100644 --- a/tasks/p9/p9-fb-12-tui-mode-machine.md +++ b/tasks/p9/p9-fb-12-tui-mode-machine.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-tui task_id: p9-fb-12 title: "TUI mode state machine (NORMAL / INSERT)" -status: in_progress +status: completed depends_on: [] unblocks: [p9-fb-10, p9-fb-13] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md