diff --git a/HANDOFF.md b/HANDOFF.md index 6d3f159..e95cee5 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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>>>` (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 ` (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`. ## 다음 task 후보 diff --git a/README.md b/README.md index 319585a..e4f76ff 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). 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 회귀 측정 | diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index c9164b0..51e4d36 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -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, @@ -335,10 +388,13 @@ impl App { /// `kebab-app::list_docs_with_config` does not block startup. pub fn new(config: Config) -> anyhow::Result { 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); + } +} diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index a99652d..669a851 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -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}; diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index b9e103b..4771734 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -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, + } +} diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 451e583..92163af 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -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 — diff --git a/tasks/p9/p9-fb-12-tui-mode-machine.md b/tasks/p9/p9-fb-12-tui-mode-machine.md index 3023e49..1b5b260 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: 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