feat(kebab-tui): p9-fb-12 partial — Mode enum + global i/Esc + auto switch + status label

도그푸딩 item 10 — vim 비익숙 사용자도 \"지금 키가 입력 vs 명령\" 명확히
구분 가능. 절반 ship: 사용자 가시 signal (mode label + auto flip + i/Esc
global) 만 land, 키 dispatch 의 input-empty heuristic 제거는 follow-up.

## 핵심 변경

- **`kebab_tui::Mode { Normal, Insert }`** enum + `Default = Normal`.
  - `Mode::label()` → `"-- NORMAL --"` / `"-- INSERT --"` (status bar
    문자열, 테스트로 핀).
  - `Mode::auto_for(pane)` → Library/Inspect/Jobs = Normal,
    Search/Ask = Insert. pane 전환 시 자동 적용.
- **`App.mode: Mode`** field. `App::new` 가 starting pane 의 auto
  mode 로 init.
- **run loop `mode_intercept(app, key)`** — pane dispatch 전에 호출:
  - Insert + `Esc` → Normal (어디서나, modifier 없음)
  - Normal + `i` (Library/Inspect/Jobs 만) → Insert
  - Search/Ask 의 `i` 는 fall-through (이미 Insert 라 typed char)
  - 그 외 fall-through
- **pane 전환 시** `app.mode = Mode::auto_for(p)` 자동 flip — 사용자가
  Tab 으로 Search 가면 자동으로 Insert.
- **status bar (header)** 에 mode label colored — Insert = Role::
  Success (green), Normal = Role::Heading (cyan + bold). a11y: 색은
  reinforcement, 글자가 authoritative signal.

## Deferred (HOTFIXES entry 추가)

spec p9-fb-12 의 \"기존 P9-3 ask 의 e/j/k input-empty heuristic 제거 —
mode 로 명확히\" 는 별 PR 로. 현재 dispatch 는 여전히:
- search.rs 의 `is_typing_mod` (SHIFT 만 typing 으로, CTRL/ALT 는 chord)
- ask.rs 의 input.is_empty() 가 e/j/k 를 navigation 으로 분기

테스트가 heuristic 에 의존해 있어, 회귀 surface 좁게 유지하려고 splitting.
spec status `in_progress` 유지 (not `completed`) — follow-up PR 가
heuristic 제거 + 완전 mode-authoritative 후 `completed` flip.

## 테스트

- 신규 3 unit (`Mode::auto_for` 모든 pane, label literals 핀,
  default = Normal)
- 기존 98 TUI 테스트 모두 통과 (heuristic 그대로라 회귀 0)
- workspace 전체 `cargo test --workspace --no-fail-fast -j 1` exit 0
- `cargo clippy --workspace --all-targets -- -D warnings` clean

## 문서

- README `kebab tui` 행: vim-style mode + auto NORMAL/INSERT + i/Esc
  안내
- HANDOFF entry (partial-ship 명시)
- HOTFIXES entry (heuristic 제거 deferral 사유)
- spec status planned → in_progress (NOT completed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 07:24:16 +00:00
parent 3ab289a2cc
commit 666eaa9210
7 changed files with 182 additions and 5 deletions

View File

@@ -56,6 +56,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-19)** — In-process LRU search cache + `corpus_revision` 카운터. SQLite V004 migration 으로 `kv (key TEXT PK, value TEXT)` 테이블 + `corpus_revision = '0'` seed. `SqliteStore::corpus_revision()` / `bump_corpus_revision()` 메서드 (`UPDATE ... CAST AS INTEGER + 1` 으로 atomic). `kebab-app::ingest_with_config_cancellable``new + updated > 0` 시 bump — no-op reingest 는 cache 보존. `App.search_cache: Option<Mutex<LruCache<SearchCacheKey, Vec<SearchHit>>>>` (capacity from `config.search.cache_capacity`, default 256, 0 = 비활성). `SearchCacheKey` = `query_norm` (NFKC + trim + lowercase) + `mode` + `k` + `snippet_chars` + `embedding_version` + `chunker_version` + `corpus_revision` snapshot. `App::search` 가 lookup → miss 시 `search_uncached` → put. `search_uncached_with_config` facade 추가, CLI `kebab search --no-cache` 로 bypass (디버깅용). frozen design §9 versioning 표에 `corpus_revision` row 추가. spec: `tasks/p9/p9-fb-19-search-cache.md`.
- **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`.
## 다음 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). 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 어디서나. 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

@@ -20,6 +20,52 @@ pub enum Pane {
Jobs,
}
/// p9-fb-12 (partial): vim-style modal interface.
///
/// `Normal` is the navigation / command mode; `Insert` is for typing
/// queries / questions. The run loop intercepts `i` / `Esc` globally
/// to flip between them, and pane switches auto-select the natural
/// mode for the destination (Library/Inspect → Normal; Search/Ask →
/// Insert). The status bar shows the active mode label so the user
/// always knows which keys do what.
///
/// **Scope deviation from spec p9-fb-12** (recorded in HOTFIXES):
/// the existing `is_typing_mod` heuristic in `search::handle_key_search`
/// and the input-empty heuristic in `ask::handle_key_ask` are NOT
/// removed in this PR — they continue to gate j/k/e between
/// "navigation" and "typing" based on input buffer state. Removing
/// them lands in a follow-up PR so the test surface (which leans on
/// the heuristics) gets a focused review. The mode label is
/// authoritative for the user-visible signal in the status bar; the
/// dispatch is still heuristic-driven.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum Mode {
#[default]
Normal,
Insert,
}
impl Mode {
/// Status-bar label (`-- NORMAL --` / `-- INSERT --`).
pub fn label(self) -> &'static str {
match self {
Mode::Normal => "-- NORMAL --",
Mode::Insert => "-- INSERT --",
}
}
/// p9-fb-12: which mode a freshly-focused pane should auto-enter.
/// Library / Inspect are read-only navigation panes (`Normal`);
/// Search / Ask are typing panes so we pre-flip to `Insert` so
/// the user doesn't have to press `i` after every Tab.
pub fn auto_for(pane: Pane) -> Self {
match pane {
Pane::Search | Pane::Ask => Mode::Insert,
Pane::Library | Pane::Inspect | Pane::Jobs => Mode::Normal,
}
}
}
/// Outcome of a key handler — what the run loop should do next.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KeyOutcome {
@@ -280,6 +326,13 @@ pub struct App {
/// `Style::default().fg(Color::*)`.
pub theme: crate::theme::Theme,
pub focus: Pane,
/// p9-fb-12 (partial): vim-style modal interface. Run loop
/// intercepts `i` / `Esc` to toggle, pane switches auto-flip via
/// `Mode::auto_for(pane)`. Status bar renders the label. The
/// per-pane key handlers still use their pre-fb-12 input-empty
/// heuristics for j/k vs typing — full mode-authoritative
/// dispatch is a follow-up PR.
pub mode: Mode,
pub library: LibraryState,
/// Populated by p9-2 (None until that crate links in).
pub search: Option<SearchState>,
@@ -335,10 +388,13 @@ impl App {
/// `kebab-app::list_docs_with_config` does not block startup.
pub fn new(config: Config) -> anyhow::Result<Self> {
let theme = crate::theme::Theme::from_name(&config.ui.theme);
let initial_pane = Pane::Library;
Ok(Self {
config,
theme,
focus: Pane::Library,
focus: initial_pane,
// p9-fb-12: starting pane = Library → Normal mode.
mode: Mode::auto_for(initial_pane),
library: LibraryState::new(),
search: None,
ask: None,
@@ -389,3 +445,36 @@ impl App {
}
}
}
#[cfg(test)]
mod mode_tests {
use super::*;
/// p9-fb-12: Library / Inspect / Jobs auto-Normal; Search / Ask
/// auto-Insert. Pin so a future pane addition has to think
/// explicitly about its starting mode.
#[test]
fn auto_for_pane_routes_to_natural_mode() {
assert_eq!(Mode::auto_for(Pane::Library), Mode::Normal);
assert_eq!(Mode::auto_for(Pane::Inspect), Mode::Normal);
assert_eq!(Mode::auto_for(Pane::Jobs), Mode::Normal);
assert_eq!(Mode::auto_for(Pane::Search), Mode::Insert);
assert_eq!(Mode::auto_for(Pane::Ask), Mode::Insert);
}
/// p9-fb-12: status-bar label literals are part of the contract
/// (the user sees them; tests / docs reference them).
#[test]
fn label_literals_stable() {
assert_eq!(Mode::Normal.label(), "-- NORMAL --");
assert_eq!(Mode::Insert.label(), "-- INSERT --");
}
/// p9-fb-12: default `Mode` = `Normal` (the safe non-typing
/// state). Pin so a future #[derive(Default)] tweak doesn't
/// silently flip.
#[test]
fn default_is_normal() {
assert_eq!(Mode::default(), Mode::Normal);
}
}

View File

@@ -27,8 +27,8 @@ mod theme;
pub use theme::{Palette, Role, Theme};
pub use app::{
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane,
SearchState, SearchWorkerMessage, TERMINAL_LINE_HOLD_SECS,
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Mode,
Pane, SearchState, SearchWorkerMessage, TERMINAL_LINE_HOLD_SECS,
};
pub use ask::{handle_key_ask, render_ask};
pub use error_popup::{ErrorOverlay, render_error_overlay};

View File

@@ -130,6 +130,17 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
if event::poll(POLL_INTERVAL)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
// p9-fb-12: global mode toggle. `Esc` from
// Insert → Normal is intercepted here so it
// works on every pane uniformly. `i` from
// Normal → Insert is also intercepted, but
// ONLY on Library/Inspect (where `i` has no
// pre-fb-12 meaning); on Search/Ask the user
// is already in Insert by Mode::auto_for, so
// `i` falls through as a typed character.
if mode_intercept(app, key) {
continue;
}
let outcome = match app.focus {
Pane::Library => handle_key_library(app, key),
Pane::Search => handle_key_search(app, key),
@@ -143,6 +154,11 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
KeyOutcome::Quit => app.should_quit = true,
KeyOutcome::SwitchPane(p) => {
app.focus = p;
// p9-fb-12: auto-flip mode on switch.
// Library/Inspect/Jobs → Normal,
// Search/Ask → Insert. User can still
// press i/Esc to override.
app.mode = crate::app::Mode::auto_for(p);
// Lazy-init pane state on first switch.
if p == Pane::Search && app.search.is_none() {
app.search = Some(SearchState::default());
@@ -277,10 +293,19 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) {
Pane::Inspect => "Inspect",
Pane::Jobs => "Jobs",
};
// p9-fb-12: mode label colored — Insert = Success (green), Normal
// = Heading (cyan + bold). The literal text is the user-visible
// signal; color is reinforcement (a11y: never color-only).
let mode_role = match app.mode {
crate::app::Mode::Insert => crate::theme::Role::Success,
crate::app::Mode::Normal => crate::theme::Role::Heading,
};
let line = Line::from(vec![
Span::styled("kebab", app.theme.style(crate::theme::Role::Title)),
Span::raw(" / "),
Span::raw(pane_label),
Span::raw(" "),
Span::styled(app.mode.label(), app.theme.style(mode_role)),
]);
f.render_widget(Paragraph::new(line), area);
}
@@ -308,3 +333,40 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
area,
);
}
/// p9-fb-12: global mode toggle interception. Returns `true` when
/// the key was consumed (caller should `continue` and skip pane
/// dispatch); `false` when the key should fall through to the
/// active pane's handler.
///
/// Rules:
/// - **`Esc` in Insert mode** → flip to Normal. Consumed (do NOT
/// forward as a back-out signal to the pane). Library/Inspect
/// start in Normal so this is a no-op there.
/// - **`i` in Normal mode on Library / Inspect / Jobs** → flip to
/// Insert. Consumed. (`i` has no pre-fb-12 meaning on these
/// panes; on Search/Ask the pane is already Insert by
/// `Mode::auto_for`, so the global `i` interception would
/// swallow what should be a typed character. We let `i` fall
/// through there.)
/// - Everything else → not consumed.
fn mode_intercept(app: &mut crate::app::App, key: crossterm::event::KeyEvent) -> bool {
use crossterm::event::{KeyCode, KeyModifiers};
use crate::app::{Mode, Pane};
// Modifier-bearing keys (Ctrl-Esc etc.) are not the toggle.
if !key.modifiers.is_empty() && key.modifiers != KeyModifiers::SHIFT {
return false;
}
match (key.code, app.mode, app.focus) {
(KeyCode::Esc, Mode::Insert, _) => {
app.mode = Mode::Normal;
true
}
(KeyCode::Char('i'), Mode::Normal, Pane::Library | Pane::Inspect | Pane::Jobs) => {
app.mode = Mode::Insert;
true
}
_ => false,
}
}

View File

@@ -14,6 +14,31 @@ historical contract that was implemented; this file accumulates the
deltas so phase 5+ readers can find the live behavior without diffing
git history.
## 2026-05-03 — p9-fb-12 partial: mode machine without dispatch removal
**Spec amended**: `tasks/p9/p9-fb-12-tui-mode-machine.md` (status stays
`in_progress`, NOT `completed`). Original contract: introduce vim
NORMAL/INSERT modes globally AND remove `is_typing_mod` (search) +
input-empty heuristic (ask) so the per-pane key dispatch becomes
mode-authoritative.
**What shipped**: Mode enum + `App.mode` field + global `i`/`Esc`
interception in run loop + auto mode flip on pane switch
(`Mode::auto_for(pane)`) + status-bar mode label (color-graded via
`Role::Success` for Insert, `Role::Heading` for Normal). Status bar
literals (`-- NORMAL --` / `-- INSERT --`) pinned.
**Deferred to follow-up PR**: removal of the existing input-empty
heuristics in `search::handle_key_search` and `ask::handle_key_ask`.
These continue to gate j/k vs typing based on input buffer state.
Tests rely on those heuristics, so the removal warrants its own
focused PR (separate review, separate test sweep).
**Why partial-ship**: the user-visible signal (mode label + auto
flip + i/Esc) is the most load-bearing part of the spec; the
heuristic removal is cleanup that doesn't change behavior anyone
currently observes. Splitting keeps the PR review surface small.
## 2026-05-03 — p9-fb-17 migration number V004 → V005
**Spec amended**: `tasks/p9/p9-fb-17-chat-session-storage.md` (frozen —

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-tui
task_id: p9-fb-12
title: "TUI mode state machine (NORMAL / INSERT)"
status: planned
status: in_progress
depends_on: []
unblocks: [p9-fb-10, p9-fb-13]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md