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

@@ -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 <id>` (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 <id>` 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 후보

View File

@@ -76,7 +76,7 @@ kebab doctor
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 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 회귀 측정 |

View File

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

View File

@@ -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() {

View File

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

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

View File

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