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

Merged
altair823 merged 2 commits from feat/p9-fb-12-mode into main 2026-05-03 07:29:06 +00:00
8 changed files with 332 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,61 @@ 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.
///
/// **Auto-flip overrides any prior user-flipped mode on pane
/// switch** — if a user pressed `Esc` on Search to read scroll-
/// back, then Tab'd back into Ask, the next focus auto-flips
/// to Insert (clobbering the user's Normal). This is
/// intentional: the typing case is the dominant one for
/// Search/Ask, and a sticky-per-pane mode adds state most
/// users don't ask for. Sticky mode is a future task —
/// current heuristic optimizes for the common case.
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 +335,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 +397,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 +454,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};
@@ -50,3 +50,7 @@ pub use search::{build_jump_command, handle_key_search, render_search};
// the test) and can pin the in-flight-skip invariant of debounce.
pub use search::poll_worker as poll_search_worker;
pub use search::debounce_due as search_debounce_due;
// p9-fb-12: expose the global mode-toggle intercept so integration
// tests can pin the i/Esc behavior without standing up the full
// run loop.
pub use run::mode_intercept;

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

의도된 동작이지만 사용자 surprise 가능: pane 전환 시 Mode::auto_for(p) 가 사용자가 방금 Esc 로 manual flip 한 mode 를 덮어씁니다. 시나리오:

  1. Search 에서 Esc → Normal (user 의도: navigation)
  2. Tab → Library → Normal (자동, 의도와 일치)
  3. Tab → Ask → Insert (자동) ← user 의도와 어긋남

사용자 입장에서 "Esc 누르면 그 모드 sticky" 가 기대일 수 있습니다. v1 으로 자동 flip 이 합리적이지만 (Search/Ask 의 주 use case 가 typing), doc 으로 명시해두면 후속 사용자 피드백 시 의도된 트레이드오프임을 보여줄 수 있습니다.

제안: Mode::auto_for doc 또는 run loop pane-switch 분기 주석에:

// Auto-flip overrides any prior user-flipped mode on pane switch.
// Sticky mode (remember last user flip per pane) is a future task
// — current heuristic optimizes for the common case (Search/Ask =
// typing, others = navigation).
**의도된 동작이지만 사용자 surprise 가능**: pane 전환 시 `Mode::auto_for(p)` 가 사용자가 방금 `Esc` 로 manual flip 한 mode 를 덮어씁니다. 시나리오: 1. Search 에서 `Esc` → Normal (user 의도: navigation) 2. Tab → Library → Normal (자동, 의도와 일치) 3. Tab → Ask → Insert (자동) **← user 의도와 어긋남** 사용자 입장에서 "Esc 누르면 그 모드 sticky" 가 기대일 수 있습니다. v1 으로 자동 flip 이 합리적이지만 (Search/Ask 의 주 use case 가 typing), doc 으로 명시해두면 후속 사용자 피드백 시 의도된 트레이드오프임을 보여줄 수 있습니다. 제안: `Mode::auto_for` doc 또는 run loop pane-switch 분기 주석에: ``` // Auto-flip overrides any prior user-flipped mode on pane switch. // Sticky mode (remember last user flip per pane) is a future task // — current heuristic optimizes for the common case (Search/Ask = // typing, others = navigation). ```
// 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,44 @@ 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.
///
/// `pub` so integration tests + future TUI consumers can drive the
/// intercept paths by constructing KeyEvents directly without
/// standing up the full run loop.
pub 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

@@ -0,0 +1,133 @@
//! p9-fb-12: integration tests for `mode_intercept`. Drives the
//! global i/Esc dispatch by constructing KeyEvents directly without
//! standing up the full run loop (terminal-side).
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_tui::{App, Mode, Pane, mode_intercept};
fn fresh_app(focus: Pane) -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-mode-tests-noop".to_string();
config.workspace.root = "/tmp/kebab-tui-mode-tests-noop/workspace".to_string();
let mut app = App::new(config).expect("App::new");
app.focus = focus;
app.mode = Mode::auto_for(focus);
app
}
/// p9-fb-12: `Esc` from Insert mode flips to Normal on any pane.
/// Returns `true` (consumed) so the pane handler doesn't ALSO see
/// the Esc as a "back to Library" signal.
#[test]
fn esc_in_insert_flips_to_normal_and_consumes() {
for &pane in &[Pane::Library, Pane::Search, Pane::Ask, Pane::Inspect] {
let mut app = fresh_app(pane);
app.mode = Mode::Insert;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(consumed, "Esc in Insert must be consumed (pane: {pane:?})");
assert_eq!(app.mode, Mode::Normal, "mode flipped to Normal (pane: {pane:?})");
}
}
/// p9-fb-12: `Esc` from Normal mode is a no-op (not consumed) so the
/// pane's existing Esc handler (e.g. Library `Esc` → quit) keeps
/// working.
#[test]
fn esc_in_normal_mode_falls_through() {
let mut app = fresh_app(Pane::Library);
assert_eq!(app.mode, Mode::Normal);
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(!consumed, "Esc in Normal must fall through to pane");
assert_eq!(app.mode, Mode::Normal, "mode unchanged");
}
/// p9-fb-12: `i` in Normal mode on Library / Inspect / Jobs flips
/// to Insert. (`i` has no pre-fb-12 meaning on those panes, so the
/// global interception is safe.)
#[test]
fn i_in_normal_on_library_inspect_jobs_flips_to_insert() {
for &pane in &[Pane::Library, Pane::Inspect, Pane::Jobs] {
let mut app = fresh_app(pane);
assert_eq!(app.mode, Mode::Normal, "auto_for({pane:?}) should be Normal");
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(consumed, "i in Normal on {pane:?} must be consumed");
assert_eq!(app.mode, Mode::Insert, "mode flipped to Insert (pane: {pane:?})");
}
}
/// p9-fb-12: `i` on Search / Ask falls through (the pane is already
/// in Insert via Mode::auto_for, so the global `i` interception
/// would swallow what should be a typed character).
#[test]
fn i_on_search_or_ask_falls_through_to_pane() {
for &pane in &[Pane::Search, Pane::Ask] {
let mut app = fresh_app(pane);
assert_eq!(app.mode, Mode::Insert, "auto_for({pane:?}) should be Insert");
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(!consumed, "i on {pane:?} must fall through to pane");
assert_eq!(app.mode, Mode::Insert, "mode unchanged");
}
}
/// p9-fb-12: modifier-bearing keys (Ctrl+Esc, Alt+i) are NOT the
/// mode toggle. Falls through so chord handlers downstream get a
/// shot.
#[test]
fn modifier_keys_do_not_trigger_intercept() {
let mut app = fresh_app(Pane::Library);
app.mode = Mode::Insert;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::CONTROL),
);
assert!(!consumed, "Ctrl+Esc must fall through");
assert_eq!(app.mode, Mode::Insert, "mode unchanged");
app.mode = Mode::Normal;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::ALT),
);
assert!(!consumed, "Alt+i must fall through");
assert_eq!(app.mode, Mode::Normal, "mode unchanged");
}
/// p9-fb-12: SHIFT alone is allowed (the toggle keys are unshifted
/// `i` / `Esc`, but a future `Shift+Esc` chord is unlikely; pre-
/// allow SHIFT so capital-letter typing in Search/Ask doesn't
/// accidentally fall into the modifier-block branch).
#[test]
fn shift_modifier_passes_modifier_filter() {
// SHIFT+Esc is a strange combo but the filter passes it. (The
// actual outcome — does mode flip? — depends on the case
// matching i/Esc. SHIFT+Esc still matches KeyCode::Esc, so it
// toggles. SHIFT+I would be KeyCode::Char('I') (capital), NOT
// 'i', so it falls through. Both are intentional.)
let mut app = fresh_app(Pane::Library);
app.mode = Mode::Insert;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT),
);
assert!(consumed, "Shift+Esc still toggles (modifier filter allows SHIFT)");
let mut app = fresh_app(Pane::Library);
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT),
);
assert!(!consumed, "Shift+I (capital) falls through — only lowercase 'i' toggles");
}

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