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:
@@ -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 후보
|
||||
|
||||
|
||||
@@ -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 회귀 측정 |
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user