feat(kebab-tui): p9-fb-12 partial — Mode enum + global i/Esc + auto switch + status label #84
@@ -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 후보
|
||||
|
||||
|
||||
@@ -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 회귀 측정 |
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
}
|
||||
|
||||
133
crates/kebab-tui/tests/mode.rs
Normal file
133
crates/kebab-tui/tests/mode.rs
Normal 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");
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user
의도된 동작이지만 사용자 surprise 가능: pane 전환 시
Mode::auto_for(p)가 사용자가 방금Esc로 manual flip 한 mode 를 덮어씁니다. 시나리오:Esc→ Normal (user 의도: navigation)사용자 입장에서 "Esc 누르면 그 모드 sticky" 가 기대일 수 있습니다. v1 으로 자동 flip 이 합리적이지만 (Search/Ask 의 주 use case 가 typing), doc 으로 명시해두면 후속 사용자 피드백 시 의도된 트레이드오프임을 보여줄 수 있습니다.
제안:
Mode::auto_fordoc 또는 run loop pane-switch 분기 주석에: