refactor: kebab-tui crate + tui 서브커맨드 제거 (UI=CLI/MCP)

This commit is contained in:
2026-06-24 09:36:59 +00:00
parent 040d17f122
commit 3c3dcc86cd
33 changed files with 8 additions and 9818 deletions

View File

@@ -31,7 +31,7 @@ The dev/test profile is already trimmed (`debug = "line-tables-only"`, `split-de
## The facade rule
`kebab-app` is the only crate UI binaries (`kebab-cli`, future `kebab-tui`, `kebab-desktop`) may touch. Every user-facing entry has a `*_with_config(cfg, …)` companion that takes an explicit `Config`:
`kebab-app` is the only crate UI binaries (`kebab-cli`, future `kebab-desktop`) may touch. Every user-facing entry has a `*_with_config(cfg, …)` companion that takes an explicit `Config`:
- `kebab-cli` calls the `*_with_config` form so `--config <path>` is honored.
- The bare `kebab_app::ingest(...)` / `search(...)` / `ask(...)` form re-loads `Config::load(None)` (XDG default) and silently bypasses any explicit path. Two regressions of exactly this shape are recorded in `tasks/HOTFIXES.md` (P3-5 + P4-3 follow-ups). When wiring a new CLI subcommand, always thread the `Config` through.
@@ -54,7 +54,7 @@ Each task spec lists `Allowed dependencies` and `Forbidden dependencies` per des
- `kebab-core` MUST NOT depend on any other `kebab-*` crate. Domain types only.
- `kebab-eval`'s `metrics` and `compare` modules MUST NOT import retrieval / embedding / LLM crates directly. The runner is allowed to use `kebab-app`'s facade (P5-1 inheritance — see deviations in that task spec).
- UI crates (`kebab-cli`, `kebab-mcp`, `kebab-tui`, future `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`.
- UI crates (`kebab-cli`, `kebab-mcp`, future `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`.
Read the relevant task spec's deps section before adding an import. New crates inherit the same boundary rules.

View File

@@ -20,7 +20,6 @@ members = [
"crates/kebab-eval",
"crates/kebab-parse-image",
"crates/kebab-parse-pdf",
"crates/kebab-tui",
"crates/kebab-mcp",
"crates/kebab-parse-code",
"crates/kebab-nli",
@@ -92,7 +91,7 @@ struct_excessive_bools = "allow"
naive_bytecount = "allow"
# `#[ignore]` annotations on tests document via the test name + nearby comment.
ignore_without_reason = "allow"
# `format!` push patterns are a hot path for kebab-tui's progressive rendering;
# `format!` push patterns are common in CLI output paths;
# `write!` rewrite needs a verified-equal benchmark before swapping.
format_push_string = "allow"
# Builder-style `with_*` methods return `Self`; the existing `#[must_use]`

View File

@@ -19,7 +19,7 @@ P0P5 + P6 + P7 + P9-1/2/3/4 (Library / Search / Ask / Inspect) + P10 전체
| **P6** | 이미지 ingestion (OCR + caption) | `kebab-parse-image` | P5 | ✅ 완료 (4/4 component, OCR/caption Ollama-vision) |
| **P7** | PDF text + page citation + scanned OCR (v0.20.0 sub-item 1) | `kebab-parse-pdf` + `kebab-app::pdf_ocr_apply` | P5 + P6 | ✅ 완료 (3/3 component, page-level chunker + ingest wiring + post-extract OCR enrichment via qwen2.5vl:3b vision LLM) |
| **P8** | 음성 transcription + timestamp citation | `kebab-parse-audio` | P5 | ⏸ 보류 (whisper-rs 시스템 dep brainstorm 필요) |
| **P9** | TUI + desktop app | `kebab-tui`, `kebab-desktop` | P5 | 🟡 진행 (4/5 component — P9-1/2/3/4 완료 [Library / Search / Ask / Inspect], P9-5 desktop 예정 · 도그푸딩 피드백 **20/20 ✅**) |
| **P9** | desktop app | `kebab-desktop` | P5 | ⏸ 보류 (TUI 제거됨 — CLI/MCP 로 UI 집약, P9-5 desktop 예정) |
| **P10** | code ingest framework | `kebab-parse-code` | P5 | 🟡 진행 중 — 1A-1 ✅ (wire schema + parse-code skeleton + filter flags), 1A-2 ✅ (Rust AST chunker, `code-rust-ast-v1` — v0.7.0), 1B ✅ (Python/TS/JS AST chunkers — v0.8.0 이후), **1C-Go ✅ (Go AST chunker, `code-go-ast-v1` — v0.12.0)**, **1C-JavaKotlin ✅ (Java + Kotlin AST chunkers, `code-java-ast-v1` / `code-kotlin-ast-v1` — v0.13.0)**, **2 ✅ (Tier 2 resource-aware: yaml/k8s + dockerfile + manifest, `k8s-manifest-resource-v1` / `dockerfile-file-v1` / `manifest-file-v1` — v0.14.0)**, **3 ✅ (Tier 3 paragraph fallback: code-text-paragraph-v1 — v0.15.0)**, **1D ✅ (C + C++ AST chunkers, code-c-ast-v1 + code-cpp-ast-v1 — v0.16.0)** |
P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
@@ -78,27 +78,17 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **2026-05-02 P9 도그푸딩 후속 (p9-fb-16)** — TUI Ask conversation UI. `AskState``turns: Vec<Turn>` + `current_question` + `conversation_id` + `last_answer` 로 재설계. answer area 가 transcript (`Q1/A1`, `Q2/A2`, ...) 로 갈음, 매 Enter 가 이전 turns 를 `history` 로 worker 에 전달 (`ask_with_history`). conversation_id 는 첫 submit 시 timestamp-based 자동 생성 (`conv_<unix_nanos_hex>`). `Ctrl-L` 가 turns + conversation_id 초기화 (in-flight worker 는 그대로 finish, 결과는 새 conversation 의 stale turn 으로 silently 폐기). spec: `tasks/p9/p9-fb-16-tui-ask-conversation.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-20)** — `kebab ask` 의 CLI citation block. 답변 출력 후 `근거:` 절 — `[N] <full path>#<fragment> (score=<s>)` 한 줄씩. `--show-citations` (default ON) / `--hide-citations` (pipe 시 답변 본문만) flag. `--json` 모드는 무영향 (citations 가 항상 wire payload 에 포함). spec p9-fb-20 의 \"TUI citation pane + jump\" 부분은 P9-3 의 기존 `render_citations_or_explain` 가 일부 cover — 추가 기능 (turn 별 fold + Enter/o jump + i inspect) 은 후속 task 로 미룸 (사용자 도그푸딩 priority 5위 의 핵심 = full path 가독성 = CLI block 으로 충족). spec: `tasks/p9/p9-fb-20-citation-surface.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-07)** — Markdown title fallback chain. `kebab-normalize::derive_title(frontmatter_title, &[Block], file_stem)` — 1) frontmatter title → 2) 첫 H1 → 3) 첫 H2 → 4) 첫 paragraph 80 chars → 5) 파일 stem (모든 단계 NFC 정규화, 빈 문자열 절대 반환 안 함, 마지막 sentinel `"untitled"`). `build_canonical_document` 가 lift 후 helper 호출. parser_version 상수 `pulldown-cmark-0.x``md-frontmatter-v2` bump — 기존 doc 은 `doc_id` 가 갱신되므로 다음 ingest 가 자동 재처리 (idempotent upsert, design §9 cascade). spec: `tasks/p9/p9-fb-07-md-title-fallback.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-09)** — TUI external editor return restore. Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. `kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)` helper 가 suspend (LeaveAlternateScreen + Show cursor + disable_raw_mode) → spawn → restore (enable_raw_mode + EnterAlternateScreen + Hide cursor + `terminal.clear()`) 시퀀스를 RAII guard 로 atomic 하게 묶음. `App.pending_editor: Option<EditorRequest>` + `App.force_redraw: bool` 추가 — 키 핸들러는 EditorRequest enqueue 만, 실제 spawn 은 run loop 가 `TuiTerminal` 핸들 들고 처리. 후속 task (p9-fb-20 의 citation jump 등) 가 같은 helper 위에 build. spec: `tasks/p9/p9-fb-09-tui-editor-restore.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-14)** — TUI color theme module. `kebab-tui::theme::{Theme, Role, Palette}` 신규 — 16 개 Role (BorderActive/Title/Path/ModeLexical/ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/CitationMarker/Bullet/Body/BorderInactive) 을 dark + light 두 팔레트가 exhaustive match 로 매핑. 모든 Pane (library/search/ask/inspect/run/error_popup) 의 inline `Style::default().fg(Color::*)` 호출이 `theme.style(Role::X)` 로 격리됨. `Config.ui.theme: String` (default `"dark"`) 신규. `App.theme: Theme``App::new` 에서 `Theme::from_name(&config.ui.theme)` 로 build — 알 수 없는 값은 dark fallback (config 가 typo 로 죽지 않음). `T` 키 runtime toggle 은 mode machine (p9-fb-12) 미진행이라 skip — config 만으로 결정. p9-fb-11 (ask markdown render) 의 Theme 의존성 unblock. spec: `tasks/p9/p9-fb-14-tui-color-theme.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-11)** — TUI Ask 답변 본문 markdown 렌더. `kebab-tui::markdown::render(text, &Theme) -> Vec<Line<'static>>` 신규 — `pulldown-cmark = "0.13"` 위에서 inline (bold/italic/strikethrough/inline code/link)·block (heading H1-H6, ordered/unordered list with nesting, fenced code block, table, blockquote `▎`, horizontal rule) 변환. heading H1/H2 = `Role::Heading`, H3+ = `Role::Title`, link = `Role::CitationMarker + UNDERLINE`, code = `Role::Hint`. ask `push_turn_lines` 가 grounded 답변에서만 markdown 렌더; refusal (`Role::Warning`) / streaming (`Role::Hint`) 은 raw 로 두어 role color 시그널 보존. CLI `kebab ask` 출력은 raw markdown 그대로 (terminal 호환성). 매 frame 재 parse — pulldown 토크나이저가 µs/KB 라 비용 무시. spec: `tasks/p9/p9-fb-11-ask-markdown-render.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState``generation: u64` + `worker_thread: Option<JoinHandle>` + `worker_rx: Option<Receiver<SearchWorkerMessage>>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option<PathBuf>` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load``path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`.
- **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`.
- **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`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-10 partial)** — TUI CJK rendering helpers. `kebab-tui::input::{display_width, truncate_to_display_width}` 신규 — `unicode-width` 위에서 column-단위 width 계산 (ASCII=1, Hangul/CJK/fullwidth=2, combining=0) + char-boundary 안전 truncate (wide char 를 split 없이 keep-or-omit, ellipsis 1 col). library.rs 의 중복 `truncate_to_display_width` private fn 제거 — 단일 source. 9 unit tests (ASCII / Hangul / Japanese / mixed / truncate fits·overflow·zero-cols·wide-char-boundary / `String::pop` char-aware sanity) + 1 integration render test (Korean + Japanese fixture, TestBackend 80×20, 한글/일본어 글자가 frame 에 살아남음 확인). spec 의 `InputBuffer` struct (cursor 가 column 단위 wide-char width 추적) 도입은 follow-up — Ask/Search/Editor pane 의 String + cursor 일괄 마이그레이션이 회귀 표면이 커서 helper 만 먼저 머지. backspace 는 모든 pane 이 이미 `String::pop()` 사용 (char-aware) → byte-boundary 안전성 helper 없이도 확보. crossterm 0.28 이 native IME composing 미노출 — preedit handling out of scope. spec status `planned``in_progress`. spec: `tasks/p9/p9-fb-10-tui-cjk-input.md`.
- **2026-05-04 P9 post-도그푸딩 (p9-fb-23)** — Incremental ingest. 사용자 도그푸딩 피드백: 변하지 않은 문서는 다시 ingest 하지 않기. blake3 checksum + parser_version + chunker_version + embedding_version 4개 input 이 모두 일치할 때 parse/chunk/embed/vector upsert 모두 회피. SQLite V006 마이그레이션 — `documents``last_chunker_version` + `last_embedding_version` 컬럼 추가. 신규 `IngestItemKind::Unchanged` variant + `IngestReport.unchanged` + `AggregateCounts.unchanged` (wire schema additive). `IngestOpts { progress, cancel, force_reingest }` struct 도입 — `AskOpts` 패턴. `--force-reingest` CLI flag 로 skip 우회. 비용 dominator (fastembed) 가 변경된 / 새 doc 에만 발생. spec: `tasks/p9/p9-fb-23-incremental-ingest.md`. HOTFIXES `2026-05-04 — p9-fb-23` 항목이 version cascade 명시 동작의 source of truth.
- **2026-05-05 P9 post-도그푸딩 (p9-fb-25)** — Config 의 `workspace.include` 필드 제거 + 지원 형식 가시성. 사용자 도그푸딩 피드백: include + exclude 동시 존재가 case 4 (둘 다 매치 안 함) 의미 모호 + 어차피 처리 가능 형식 (md / png / jpg / pdf) 이 정해져 있으니 명시 필요. `WorkspaceCfg.include` 제거 (옛 config 의 `include = [...]` 은 silently 무시 + 단발 deprecation warning). `IngestItem.warnings` 가 Skipped 시 사유 (`"unsupported media type: .docx"` 등) 채움. `IngestReport.skipped_by_extension: BTreeMap<String, u32>` 신규 (additive wire — release 트리거 안 됨). CLI / TUI summary 에 breakdown 표시 (`"5 skipped: 3 docx, 1 txt, 1 epub"`). README + `kebab init` 헤더 주석에 지원 형식 명시. spec: `tasks/p9/p9-fb-25-config-include-removal.md`. HOTFIXES `2026-05-05 — p9-fb-25` 가 source of truth.
- **2026-05-04 P9 post-도그푸딩 (p9-fb-24)** — TUI status/key bar + Library 컬럼 헤더 + Ask/Inspect PgUp/PgDn. 사용자 도그푸딩 3 건 (Library 컬럼 의미 부재, 페이지 스크롤 키 부재, 상태바 + 버전 정보 항상 노출 요청) 을 단일 PR 로 통합. bottom 영역을 status bar (1 row, version + pane + docs + dynamic state) + key hint bar (1 row, 기존 `footer_hints` 그대로) 두 줄로 분할; 기존 ingest progress dedicated row 는 status bar 의 dynamic slot 에 흡수 (priority cascade: streaming → searching → indexing → idle). Library `List` 위에 `format_doc_header` 행 + Layout 분할로 헤더 표시 (TITLE / TAGS / UPDATED / CHUNKS, display-width 정렬). `kebab-tui::pager::PAGE_STEP = 10` 신규 — Ask 의 PgUp/PgDn 추가 + Inspect 의 기존 +/-10 hardcode 가 같은 상수 참조로 통일. Ask 의 page-scroll 은 `j`/`k` 와 동일하게 `follow_tail = false` 로 freeze. spec: `tasks/p9/p9-fb-24-tui-affordances.md`. HOTFIXES `2026-05-04 — p9-fb-24` 항목이 footer 단행 row (p9-fb-13) + ingest dedicated row (p9-fb-03) 와의 layout 충돌의 source of truth.
- **2026-05-04 P9 post-도그푸딩 (p9-fb-22)** — TUI 입력 cursor mid-string 편집 + Ask follow-tail auto-scroll. Gitea #94 (입력 후 커서 이동 안 됨) + #95 (새 응답 자동 스크롤 안 됨) 두 건. `InputBuffer` 의 cursor 모델을 byte-position 기반으로 재구성 — cursor 가 끝일 때 기존 append 동작과 backwards-compatible, mid-string 일 때는 `←/→/Home/End/Delete` 로 편집. `AskState``follow_tail: bool` (default true). `Paragraph::line_count(width)` (ratatui `unstable-rendered-line-info` feature 활성화) 로 매 프레임 wrapped row 수 계산해 follow-tail 시 scroll 을 bottom 에 pin. `j`/`k` 가 follow-tail 끄고 `Shift-G` 가 다시 켬. 12 신규 InputBuffer unit + 6 신규 Ask integration. spec: `tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md`. HOTFIXES 항목 `2026-05-04` 가 live cursor 모델 source of truth.
- **2026-05-03 P9 post-도그푸딩 (p9-fb-21)** — `i` 가 universal Normal→Insert toggle (모든 pane). 이전 mode_intercept 는 Library/Inspect/Jobs 만 `i` intercept 였고 Search/Ask 는 fall-through (자동 INSERT 가정). 사용자가 Esc 로 NORMAL 로 빠진 후 Insert 복귀 키 없어 dead-end → 도그푸딩에서 보고됨. mode_intercept 의 `(Char('i'), Normal, _)` arm 이 pane 무관 모두 INSERT flip. Search 의 chunk inspect 키 `i``o` rebind (vim "open") 으로 충돌 해소. footer hint 모든 (pane, mode, filter) 조합 첫 fragment = `F1 도움말` (cheatsheet binding discoverability). Search/Ask Normal hint 에 `i 입력모드` fragment 추가. cheatsheet popup Global/Search/Ask section 갱신. 6 신규 unit + 3 기존 갱신. spec: `tasks/p9/p9-fb-21-tui-insert-key-discoverability.md` (status `completed` 직접). HOTFIXES 항목이 Search `i``o` rebind 의 source of truth.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-10 follow-up)** — InputBuffer struct + 모든 text-input pane 마이그레이션 + cursor column 정렬. `kebab-tui::input::InputBuffer { content, cursor_col }` 신규 — `push_char` / `pop_char` / `clear` / `take` 가 wide-char 단위로 cursor_col 진행 (ASCII=1, Hangul/CJK=2, combining=0). `SearchState.input` / `AskState.input` / `FilterEdit.{tags_buf, lang_buf}` 가 InputBuffer 로 교체. render 단계에서 `f.set_cursor_position(...)``block.inner(area)` 기반 prompt 폭 + cursor_col 으로 caret 을 정확한 column 에 배치 (right-edge clamp). ratatui 0.28 의 cursor visibility 는 `cursor_position` Some/None 으로 자동 결정 — Search/Ask/Filter 가 `Some` 이라 caret 보임, Library/Inspect 는 `None` 이라 hidden. Korean lexical 검색은 `crates/kebab-app/tests/search_korean.rs` 에서 ingest → search → 결과 한 건 이상 + Korean 파일 stem 매칭 assert 로 회귀 핀. `lexical_query` test helper 가 `crates/kebab-app/tests/common/mod.rs` 로 promotion. spec status `in_progress``completed`. spec: `tasks/p9/p9-fb-10-tui-cjk-input.md`.
- **2026-05-07 P9 post-도그푸딩 (p9-fb-27)** — `kebab schema [--json]` introspection 명령 + `error.v1` wire 도입. 정적 (wire schemas / capabilities / models) + 동적 (stats) 한 번에. `--json` 모드에서 fatal error 가 stderr ndjson 으로 emit (비 `--json` 은 기존 stderr text 유지). exit code 0/1/2/3 unchanged — `error.v1.code` 가 fine-grained 분기. fb-30 MCP `initialize` capability matrix 의 prerequisite. spec: `tasks/p9/p9-fb-27-introspection-and-error-wire.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
- **2026-05-03 P9 도그푸딩 피드백 20/20 ✅** — `tasks/p9/p9-fb-01..20` 모든 spec status `completed`. 사용자가 `kebab` 직접 돌려서 수집한 UX 잡음 (ingest 진행 표시 부재, mode 혼란, CJK column drift, multi-turn 부재, citation 부재 등) 이 모두 코드 또는 spec-acknowledged-deferred 형태로 해소. 도그푸딩 사이클 한 바퀴 완성 — P9-5 desktop tauri 와 별개로 TUI/CLI 사용자 경험 측면은 한 단계 안정화. P9 phase row 는 P9-5 미진행이라 🟡 유지.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-13 follow-up)** — verb-form hint line 재구성. `pub fn footer_hints(focus: Pane, mode: Mode, filter_open: bool) -> &'static str` 신규 (run.rs). 한국어 동사구 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` 등) + mode-aware (NORMAL = navigation verbs, INSERT = typing + Esc reminder) + Library filter overlay 별 분기. 8 unit tests pin 모든 (pane, mode, filter) 조합 — exhaustive non-empty + Library Normal/filter, Search Normal/Insert, Ask Normal/Insert, Inspect Normal 별 verb fragment 존재 검증. spec status `in_progress``completed` — p9-fb-13 partial 의 deferred verb-form 항목이 닫힘.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-13)** — TUI cheatsheet popup. `kebab-tui::cheatsheet::render_cheatsheet(f, area, app)` 신규 — 70%/60% centered modal, sections (Global / Library / Search / Ask / Inspect) + global toggle table + 현재 focused pane footer. `App.cheatsheet_visible: bool` 필드 + `pub fn cheatsheet_visible()` getter. run loop `cheatsheet_intercept(app, key)` 가 mode_intercept 보다 먼저 dispatch — `F1` 토글 (open/close), `Esc` 가 visible 일 때 닫기 (mode_intercept 를 우회해서 cheatsheet 닫기 가 mode flip 도 발동시키지 않도록), 그 외 키는 fall-through (popup 열린 채 navigation 가능). modifier-bearing F1 (Ctrl-F1 등) 은 무시. **HOTFIXES 기록**: spec 의 `?` trigger 가 Library 의 quick-Ask binding 과 충돌해서 `F1` 으로 rebind. spec 의 verb-form hint line 재구성은 별 후속 PR (기존 footer 가 동일 역할). spec status `planned``in_progress` (verb hint deferral 으로 partial). spec: `tasks/p9/p9-fb-13-tui-cheatsheet.md`.
## 다음 task 후보

View File

@@ -74,10 +74,6 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
검색 결과를 근거로 LLM 답변을 생성하고 [#번호] 인용을 단다. 근거가 부족하면 답을 지어내지 않고 거절한다. compound 질문은 `--multi-hop` 으로 분해→synthesize. 답변의 groundedness 는 mDeBERTa XNLI 로 검증할 수 있다 (`[rag] nli_threshold`, default off).
### TUI
`kebab tui` 는 Ratatui 셸 — Library / Search / Ask / Inspect 패널을 vim-style 모드로 다룬다. 키 매핑은 앱 내 `F1` cheatsheet 가 권위 소스다.
## 명령
| 명령 | 동작 |
@@ -94,7 +90,6 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단 |
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats |
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크 |
| `kebab tui` | Ratatui 셸 (Library / Search / Ask / Inspect) |
| `kebab mcp` | MCP stdio server (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) |
| `kebab reset [--all \| --data-only \| --vector-only \| --config-only \| --orphans-only] [--yes]` | XDG 데이터 wipe (**irreversible**) |
@@ -173,7 +168,7 @@ nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedn
- **`[ingest.code]`** — code ingest 의 skip 정책 (`skip_generated_header`, `max_file_bytes`, `extra_skip_globs`). `.gitignore` 자동 honor, `.kebabignore` 는 추가 layer.
- **`[ingest.image.ocr]`** — 이미지 OCR (default off / opt-in). `engine` 으로 백엔드 선택: `"ollama-vision"` (default, 원격 vision LM) 또는 `"paddle-onnx"` (PP-OCRv5 ONNX 를 in-process 로 실행, Python 런타임 불필요, 큰 페이지 CPU <4초, 오프라인). `paddle-onnx` 는 워크스페이스에 번들된 모델을 쓰며 `det_model`/`rec_model`/`dict` 로 경로 override, `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) 로 검출 튜닝 가능 (`KEBAB_IMAGE_OCR_*` env 동일 지원 — env 이름은 v3 에서도 불변). engine 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.
- **`[ingest.pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). `engine``[ingest.image.ocr]` 과 동일하게 `"ollama-vision"`/`"paddle-onnx"` 선택. v3 에서 paddle 모델 경로 키(`det_model`/`rec_model`/`dict`/`score_thresh`/`unclip_ratio`/`max_boxes`)를 PDF 자체적으로 가질 수 있다(`KEBAB_PDF_OCR_*` env 동일). 활성화 후 옛 색인분은 `kebab ingest --force-reingest` 로 재처리.
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI · TUI 모두 honor).
- **`--config <path>`** — 임시 워크스페이스 / 격리 테스트용 (CLI honor).
- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다.
- **`KEBAB_*` env** — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN` 등).
- **XDG layout**: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
@@ -186,7 +181,6 @@ flowchart TB
subgraph UI["UI binary"]
cli["kebab CLI"]
tui["kebab TUI"]
end
subgraph App["Facade"]
@@ -213,9 +207,7 @@ flowchart TB
end
user --> cli
user --> tui
cli --> app
tui --> app
app --> parse
app --> chunker
@@ -239,7 +231,7 @@ flowchart TB
v0.21.0 기준 핵심 설계:
- **crate facade** — `kebab-app` 가 유일한 facade다. UI binary (`kebab-cli` / `kebab-tui`) 는 store / parse / search / llm / rag 를 직접 참조하지 않는다 (frozen 설계 §8). 각 user-facing 엔트리는 `*_with_config(cfg, …)` 동반 함수로 explicit config 를 thread 한다.
- **crate facade** — `kebab-app` 가 유일한 facade다. UI binary (`kebab-cli`) 는 store / parse / search / llm / rag 를 직접 참조하지 않는다 (frozen 설계 §8). 각 user-facing 엔트리는 `*_with_config(cfg, …)` 동반 함수로 explicit config 를 thread 한다.
- **chunk_id 는 위치 기반** — chunk 의 정체성은 문서 내 위치(ordinal + span)다. 반면 파생물 캐시 키는 **내용 해시**라, 내용이 같으면 위치·문서가 달라도 동일 캐시를 재사용한다.
- **wire schema v1** — 모든 `--json` 출력은 `schema_version` 을 담는 frozen contract다. 깨는 변경은 `*.v2` major bump을 요구한다.
- **versioning cascade** — `parser_version` / `chunker_version` / `embedding_version` / `prompt_template_version` / `index_version` 변경은 downstream record(청크·임베딩·캐시·eval)를 무효화한다.

View File

@@ -1,6 +1,6 @@
//! Typed signal re-exports + new signals introduced by fb-27.
//!
//! kebab-cli (and future kebab-tui / kebab-desktop) downcast on these to
//! kebab-cli (and future kebab-desktop) downcast on these to
//! build `error.v1` wire records. The existing signals
//! (`RefusalSignal`, `NoHitSignal`, `DoctorUnhealthy`) live in
//! `doctor_signal.rs` — leave those unchanged and re-export via this

View File

@@ -23,10 +23,6 @@ kebab-app = { path = "../kebab-app" }
# kb-cli → kb-eval directly; documented in
# `tasks/p5/p5-2-metrics-compare.md`.
kebab-eval = { path = "../kebab-eval" }
# P9-1: Ratatui shell. UI consumes `kebab-app` only — `kebab-tui`
# enforces the §8 boundary in its own Cargo.toml; kb-cli just
# launches it.
kebab-tui = { path = "../kebab-tui" }
# p9-fb-30: MCP stdio server. `Cmd::Mcp` delegates entirely to this crate.
kebab-mcp = { path = "../kebab-mcp" }
anyhow = { workspace = true }

View File

@@ -333,10 +333,6 @@ enum Cmd {
/// Print introspection report (wire schemas, capabilities, model versions, stats).
Schema,
/// Launch the Ratatui shell (P9-1 — Library pane only; search /
/// ask / inspect panes land with p9-2 / p9-3 / p9-4).
Tui,
/// Eval suite (placeholder; lands in P9).
Eval {
#[command(subcommand)]
@@ -1400,17 +1396,6 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
Ok(())
}
Cmd::Tui => {
// P9-1: Ratatui shell with Library pane. Search / Ask /
// Inspect panes land in p9-2 / p9-3 / p9-4.
let config = match cli.config.as_deref() {
Some(path) => kebab_config::Config::load(Some(path))?,
None => kebab_config::Config::load(None)?,
};
let mut app = kebab_tui::App::new(config)?;
app.run()
}
Cmd::Eval { what } => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
match what {

View File

@@ -1,41 +0,0 @@
[package]
name = "kebab-tui"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
description = "Ratatui shell + Library pane for kebab — UI consumes kebab-app facade only (P9-1)"
[dependencies]
kebab-core = { path = "../kebab-core" }
kebab-config = { path = "../kebab-config" }
# UI facade rule (design §8): UI crates may only touch `kebab-app`. The
# search / store / embed / llm / rag layers stay invisible behind it.
kebab-app = { path = "../kebab-app" }
# p9-fb-22: `unstable-rendered-line-info` exposes
# `Paragraph::line_count(width)` for the Ask follow-tail scroll
# math. Pinned ratatui 0.28.x means the unstable surface is fixed
# until we deliberately bump the dep.
ratatui = { version = "0.28", features = ["unstable-rendered-line-info"] }
crossterm = "0.28"
anyhow = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true }
serde_json = { workspace = true }
# Korean / wide-char column width — Ratatui's `Span` truncates by chars,
# not display width, so a list cell with `한` (width 2) followed by `a`
# (width 1) overflows by one column without explicit width accounting.
unicode-width = "0.2"
# p9-fb-11: parse markdown answer bodies into styled `Span`/`Line`s.
# Same parser the ingest pipeline uses (kebab-parse-md) — keeps the
# tokenizer behavior aligned with what the corpus is normalized as.
pulldown-cmark = { version = "0.13", default-features = false }
[dev-dependencies]
tempfile = { workspace = true }
kebab-app = { path = "../kebab-app" }
[lints]
workspace = true

View File

@@ -1,551 +0,0 @@
//! `App` — TUI shell state, owned by p9-1.
//!
//! The struct's full set of fields is owned here; the layout reserves
//! one `Option<*State>` slot per pane so p9-2 / p9-3 / p9-4 can plug
//! their state in WITHOUT modifying the struct definition. p9-1 is the
//! only crate that ever changes `App`.
use kebab_config::Config;
use crate::error_popup::ErrorOverlay;
use crate::library::LibraryStateInner;
/// TUI panes (design §1 UX scenes).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Pane {
Library,
Search,
Ask,
Inspect,
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 {
/// Stay on the current pane; re-render only.
Continue,
/// Quit the app (`q` / `Esc` from Library, or any pane's quit key).
Quit,
/// Switch focus to the named pane.
SwitchPane(Pane),
/// Re-run the pane's data fetch (e.g. Library after a filter edit).
Refresh,
}
/// Library pane state — fully owned by p9-1.
pub struct LibraryState {
pub(crate) inner: LibraryStateInner,
}
impl LibraryState {
pub fn new() -> Self {
Self {
inner: LibraryStateInner::default(),
}
}
}
impl Default for LibraryState {
fn default() -> Self {
Self::new()
}
}
/// Search pane state — owned by p9-2.
///
/// Field-set kept in `app.rs` (not in `search.rs`) so cross-module
/// access from `run.rs` (lazy-init, debounce tick) does not require
/// re-exporting field accessors. The pane behavior + render live in
/// `crate::search`.
pub struct SearchState {
/// p9-fb-10: `InputBuffer` tracks display-column cursor position
/// alongside content so wide chars (Hangul, CJK) place the
/// terminal cursor in the correct column.
pub input: crate::input::InputBuffer,
pub mode: kebab_core::SearchMode,
pub hits: Vec<kebab_core::SearchHit>,
pub selected_hit: usize,
/// When the input last changed; the run loop debounces searches
/// against this (200 ms after the last keystroke).
pub input_dirty_at: Option<time::OffsetDateTime>,
/// Snapshot of `(input, mode)` at the moment the last search
/// fired. The debounce skips re-searches when nothing changed.
pub last_query: Option<(String, kebab_core::SearchMode)>,
/// True while a search worker is in flight. The run loop uses
/// this to overlay a "searching…" hint and to dedupe rapid
/// keystroke spawns.
pub searching: bool,
/// Cached preview text for the currently-selected hit (lazily
/// fetched via `kebab-app::inspect_chunk_with_config`).
pub preview: Option<String>,
/// p9-fb-08: monotonic counter incremented every time
/// `fire_search` spawns a worker. Each worker carries its
/// generation back via the channel; if it doesn't match the
/// current value at receive time, the result is silently dropped
/// (the user kept typing and a newer query is already in
/// flight). Wraps at u64::MAX which is unreachable in practice.
pub generation: u64,
/// p9-fb-08: receiver for the in-flight worker's
/// `SearchWorkerMessage::Done`. Drained every tick by
/// `crate::search::poll_worker`. `None` between runs.
///
/// Workers are fire-and-forget (no join) — search is a pure
/// read with no cleanup obligation, and dropping the receiver
/// makes the worker's `tx.send` no-op (worker exits after).
/// We don't store the `JoinHandle` because nothing observes it
/// (cf. `AskState.thread` which is `take().join()`'d on
/// `Ctrl-L`); the previous draft kept one for "symmetry" but
/// it was dead code.
pub worker_rx: Option<std::sync::mpsc::Receiver<SearchWorkerMessage>>,
}
/// p9-fb-08: payload posted by the search worker on completion.
/// `generation` matches the value of `SearchState.generation` at the
/// moment the worker was spawned; the run loop drops the message if
/// `generation` no longer matches (a newer query is in flight).
pub enum SearchWorkerMessage {
Done {
generation: u64,
result: anyhow::Result<Vec<kebab_core::SearchHit>>,
},
}
impl Default for SearchState {
fn default() -> Self {
Self {
input: crate::input::InputBuffer::new(),
mode: kebab_core::SearchMode::Hybrid,
hits: Vec::new(),
selected_hit: 0,
input_dirty_at: None,
last_query: None,
searching: false,
preview: None,
generation: 0,
worker_rx: None,
}
}
}
/// Ask pane state — owned by p9-3, extended by p9-fb-16 for
/// multi-turn conversation transcript.
///
/// The worker thread (`thread`) owns the `mpsc::Sender<kebab_app::StreamEvent>`
/// that `kebab-app::ask` writes events into. The pane keeps the matching
/// `rx` and drains it once per render frame (no blocking). Only the
/// `Token { delta }` variant is consumed for the streaming transcript;
/// `RetrievalDone` and `Final` are ignored (citations render from
/// `last_answer` after the worker join).
///
/// p9-fb-16: completed turns accumulate in `turns` for in-pane
/// display. `Ctrl-L` clears `turns` to start a fresh conversation.
pub struct AskState {
/// p9-fb-10: `InputBuffer` tracks display-column cursor position
/// alongside content so wide chars (Hangul, CJK) place the
/// terminal cursor in the correct column.
pub input: crate::input::InputBuffer,
/// Toggled by the `e` key. Re-applied on the next `Enter`.
pub explain: bool,
/// True between `Enter` press and worker thread completion.
pub streaming: bool,
/// Tokens accumulated from the worker so far. Cleared on each
/// new submission. Mid-stream this is what the transcript shows
/// for the in-flight turn.
pub partial: String,
/// In-flight worker; `take()`n when it finishes.
pub thread: Option<std::thread::JoinHandle<anyhow::Result<kebab_core::Answer>>>,
/// Token receiver paired with the worker's `Sender`. Drained
/// every render frame.
pub rx: Option<std::sync::mpsc::Receiver<kebab_app::StreamEvent>>,
/// Vertical scroll offset for the transcript area when content
/// exceeds the viewport. Only consulted when `follow_tail` is
/// false; otherwise the renderer overrides this with the
/// computed bottom offset.
pub scroll: u16,
/// p9-fb-22: when true, the renderer pins the transcript to the
/// bottom on every frame (so streaming tokens and freshly-
/// completed turns are visible without manual scrolling). Set
/// to false the first time the user scrolls up (`k`); restored
/// to true by `G`, `Ctrl-L`, and a new submission.
pub follow_tail: bool,
/// Last error from the worker thread (rendered in popup if Some).
pub last_error: Option<String>,
/// p9-fb-16: completed turns of the current conversation. Each
/// turn = (question, full answer text, citations, ts). Streaming
/// turn (the one being generated right now) lives in
/// `current_question` + `partial` and only graduates into
/// `turns` on `poll_worker` completion.
pub turns: Vec<crate::ask::TuiTurn>,
/// p9-fb-16: question text for the in-flight turn. Cleared at
/// submission (input → current_question, input → empty),
/// finalized into the new Turn at completion.
pub current_question: Option<String>,
/// p9-fb-16: most-recent `Answer` for citation / status display
/// in the right panel. Same data also lives inside the last
/// `Turn`; this slot is just the easiest place for the panel
/// renderer to look.
pub last_answer: Option<kebab_core::Answer>,
/// p9-fb-41: toggle for the multi-hop pipeline. `F2` flips it
/// from the Ask pane; the next `Enter` snapshot picks the value
/// into `AskOpts.multi_hop` before spawning the worker. Default
/// `false` (single-pass). Conversation history (`turns`) survives
/// the toggle — flipping mid-conversation just changes the
/// pipeline used for the *next* turn.
pub multi_hop: bool,
}
impl Default for AskState {
fn default() -> Self {
Self {
input: crate::input::InputBuffer::default(),
explain: false,
streaming: false,
partial: String::new(),
thread: None,
rx: None,
scroll: 0,
// p9-fb-22: default to follow-tail so a freshly opened
// Ask pane auto-scrolls when the first answer streams in.
follow_tail: true,
last_error: None,
turns: Vec::new(),
current_question: None,
last_answer: None,
multi_hop: false,
}
}
}
/// What the Inspect pane is currently showing — owned by p9-4.
#[derive(Clone, Debug)]
pub enum InspectTarget {
Doc(kebab_core::DocumentId),
Chunk(kebab_core::ChunkId),
}
/// Inspect pane state — owned by p9-4.
///
/// Read-only view; data fetched on each target change via the
/// `kebab-app::inspect_*_with_config` facade (run-loop hook).
pub struct InspectState {
pub target: Option<InspectTarget>,
pub doc: Option<kebab_core::CanonicalDocument>,
pub chunk: Option<kebab_core::Chunk>,
/// Section names currently collapsed (e.g. "metadata", "provenance",
/// "blocks", "embeddings"). Toggled by `c`.
pub collapsed: std::collections::HashSet<&'static str>,
pub scroll: u16,
/// Pane the user came from — Library or Search. `Esc` returns
/// here.
pub return_to: Pane,
/// True when `target` differs from the last fetched result; the
/// run loop's idle tick services it.
pub needs_fetch: bool,
/// True while the inspect call is in flight (synchronous in v1).
pub loading: bool,
}
impl Default for InspectState {
fn default() -> Self {
Self {
target: None,
doc: None,
chunk: None,
collapsed: std::collections::HashSet::new(),
scroll: 0,
return_to: Pane::Library,
needs_fetch: false,
loading: false,
}
}
}
/// Background-ingest state — owned by p9-fb-03 + extended by
/// p9-fb-04 (cancel).
///
/// The TUI lets the user fire `kebab ingest` from inside the shell
/// without blocking the event loop. Pressing `r` on the Library pane
/// spawns a worker thread that calls
/// `kebab_app::ingest_with_config_cancellable(.., Some(tx), Some(cancel))`;
/// the run loop drains `rx` once per frame and updates the visible
/// status bar. When the worker thread joins (Sender dropped →
/// `recv()` Err), the final aggregate counts stay on screen for a
/// few seconds and then the slot clears.
///
/// `cancel` is the same `Arc<AtomicBool>` the worker polls at each
/// step boundary. The `Esc` / `Ctrl-C` key (only while ingest is
/// in flight) flips it via `cancel.store(true, Ordering::Relaxed)`
/// — the worker breaks at its next iteration check, emits
/// `IngestEvent::Aborted { counts: <partial> }`, and joins.
pub struct IngestState {
pub rx: std::sync::mpsc::Receiver<kebab_app::IngestEvent>,
pub counts: kebab_app::AggregateCounts,
pub current_path: Option<String>,
pub current_idx: u32,
pub started_at: std::time::Instant,
/// `Some(_)` once a `Completed` or `Aborted` event has arrived;
/// the run loop holds the final line on screen for
/// `TERMINAL_LINE_HOLD_SECS` seconds and then clears the slot.
pub terminal_at: Option<std::time::Instant>,
/// True when the terminal event was `Aborted` (vs `Completed`).
/// Used to colour the final line.
pub aborted: bool,
/// Worker thread handle. `take()`n at clear time so the join
/// happens after the user has had time to read the final line.
pub thread: Option<std::thread::JoinHandle<anyhow::Result<kebab_core::IngestReport>>>,
/// p9-fb-04: shared cancel token. `Esc` / `Ctrl-C` flip it; the
/// worker thread polls it at each asset-loop boundary.
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
/// Seconds the final ingest status line stays on screen after a run
/// completes / aborts. After this elapses the run loop clears
/// `App.ingest_state` so the footer returns to the standard hints.
pub const TERMINAL_LINE_HOLD_SECS: u64 = 3;
/// TUI application. The shell that p9-1 stands up; later p9-* tasks
/// add panes by populating their `Option<*State>` slot.
pub struct App {
pub config: Config,
/// p9-fb-14: resolved palette + role-style mapping. Built once
/// in `App::new` from `config.ui.theme` (`"dark"` / `"light"`,
/// fallback dark on unknown). Every pane reads its styles via
/// `app.theme.style(Role::X)` instead of inlining
/// `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>,
/// Populated by p9-3.
pub ask: Option<AskState>,
/// Populated by p9-4.
pub inspect: Option<InspectState>,
/// p9-fb-37: trace popup state, `Some` while open.
pub trace_popup: Option<crate::trace_popup::TracePopupState>,
/// Populated by p9-fb-03 when the user kicks off an in-shell
/// ingest (Library `r`). Cleared by the run loop a few seconds
/// after the run reaches a terminal event.
pub ingest_state: Option<IngestState>,
/// In-flight error overlay (popup); `Some` when the last facade
/// call returned `Err` and the user has not dismissed yet.
pub(crate) error_overlay: Option<ErrorOverlay>,
/// Set by `handle_key_library` when the user presses `q` / `Esc`
/// or by a future pane's quit key. The run loop drains this on
/// each tick.
pub(crate) should_quit: bool,
/// p9-fb-09: deferred external-program request. A pane's key
/// handler enqueues an `EditorRequest` here when the user wants
/// to spawn `$EDITOR` (e.g. Search `g` jumps to a citation in
/// vim) — the actual suspend / spawn / restore happens in the
/// run loop, where the `TuiTerminal` handle is in scope.
/// Drained every tick after the key dispatch.
///
/// `pub(crate)` because the enqueue/take invariant ("set by a
/// key handler, drained by the next run-loop tick") only holds
/// for in-crate callers; external mutation could leave a stale
/// request that never gets serviced.
pub(crate) pending_editor: Option<EditorRequest>,
/// p9-fb-09: when set, the next run-loop draw runs
/// `terminal.clear()` first so any leftover screen content from
/// a suspension (post-editor, future config-reload, …) is wiped
/// before Ratatui's diff renders the new frame. Reset back to
/// false after the clear. Independent of `pending_editor` —
/// any future code path that needs a forced redraw can flip
/// this flag.
pub(crate) force_redraw: bool,
/// p9-fb-13: cheatsheet popup visibility. Toggled by `F1` (set
/// via `cheatsheet_intercept` in the run loop). When true, the
/// renderer overlays a modal listing every keybinding for the
/// active pane plus the global mode toggles.
pub(crate) cheatsheet_visible: bool,
}
impl App {
/// p9-fb-13: read-only accessor for the cheatsheet visibility
/// flag — used by integration tests to assert the toggle
/// without exposing the field as `pub` (which would let
/// external code break the F1-only set/unset invariant).
pub fn cheatsheet_visible(&self) -> bool {
self.cheatsheet_visible
}
}
/// p9-fb-09: external-program spawn request. Posted by a pane's key
/// handler, serviced by the run loop on the next tick.
#[derive(Clone, Debug)]
pub struct EditorRequest {
pub citation: kebab_core::Citation,
pub editor_env: String,
pub workspace_root: std::path::PathBuf,
}
impl App {
/// Build an `App` against `config`. Does not load documents — the
/// run loop calls `library.refresh` on first frame so a slow
/// `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: initial_pane,
// p9-fb-12: starting pane = Library → Normal mode.
mode: Mode::auto_for(initial_pane),
library: LibraryState::new(),
search: None,
ask: None,
inspect: None,
trace_popup: None,
ingest_state: None,
error_overlay: None,
should_quit: false,
pending_editor: None,
force_redraw: false,
cheatsheet_visible: false,
})
}
/// Read-only accessor for the in-flight external-program request.
/// Tests and future external observers (e.g. integration smokes)
/// use this to assert that a key dispatch enqueued a spawn —
/// mutating the slot stays `pub(crate)` to preserve the
/// "set-then-drained-on-next-tick" invariant.
pub fn pending_editor(&self) -> Option<&EditorRequest> {
self.pending_editor.as_ref()
}
/// Blocking event loop. Returns when the user quits or a fatal
/// error escapes the loop (terminal raw-mode is restored either
/// way via the `Terminal` Drop guard).
pub fn run(&mut self) -> anyhow::Result<()> {
crate::run::run_loop(self)
}
/// Test-only: hand-populate the Library pane with docs without
/// going through `kebab-app::list_docs_with_config`. Snapshot /
/// key-handler tests use this to drive a deterministic view
/// instead of standing up a TempDir SQLite KB.
///
/// Marked `#[doc(hidden)]` because it is a test seam, not part
/// of the official UI API.
#[doc(hidden)]
pub fn populate_library_for_testing(&mut self, docs: Vec<kebab_core::DocSummary>) {
self.library.inner.docs = docs;
self.library.inner.needs_refresh = false;
let len = self.library.inner.docs.len();
if len == 0 {
self.library.inner.list_state.select(None);
} else {
self.library.inner.list_state.select(Some(0));
}
}
/// Test-only: read back the current Library doc filter so tests
/// can assert on what `FilterEdit::commit_into` produced after a
/// simulated Enter key. Never call this in the render path.
///
/// Marked `#[doc(hidden)]` because it is a test seam, not part
/// of the official UI API.
#[doc(hidden)]
pub fn library_filter_for_testing(&self) -> &kebab_core::DocFilter {
&self.library.inner.filter
}
}
#[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

@@ -1,672 +0,0 @@
//! Ask pane (P9-3).
//!
//! Streaming RAG answers in the TUI. Worker thread calls
//! `kebab-app::ask_with_config` with `AskOpts.stream_sink: Some(tx)`;
//! the pane keeps the matching `rx` and drains it once per render
//! frame so the answer area updates token-by-token without
//! blocking the event loop.
//!
//! Spec deviation (HOTFIXES `2026-05-02 P9-3`):
//! - `render_ask<B: Backend>` generic dropped (ratatui 0.28 Frame is
//! backend-agnostic — same as P9-1 / P9-2).
//!
//! Per design §1.1§1.4 (ask scenes), §2.3 (Answer wire), §3.8
//! (`Answer`).
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{RefusalReason, SearchMode};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use std::sync::mpsc;
use std::thread;
use crate::app::{App, AskState, KeyOutcome, Pane};
/// In-memory turn for the TUI conversation display. Not persisted —
/// session storage was removed in spine-phase0. Kept as a local type
/// so the Ask pane can render prior Q/A pairs without depending on
/// a now-deleted `kebab_core::Turn`.
#[derive(Clone, Debug)]
pub struct TuiTurn {
pub question: String,
pub answer: String,
pub citations: Vec<kebab_core::AnswerCitation>,
pub created_at: time::OffsetDateTime,
}
/// Render the Ask pane. Layout:
/// - top input bar
/// - middle answer area (scrollable when content overflows)
/// - bottom split: status (left) + citations / explain panel (right)
pub fn render_ask(f: &mut Frame, area: Rect, state: &App) {
let Some(s) = state.ask.as_ref() else {
f.render_widget(Block::default().title("Ask").borders(Borders::ALL), area);
return;
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(7),
])
.split(area);
render_input(f, layout[0], s, &state.theme);
render_answer(f, layout[1], s, &state.theme);
render_bottom(f, layout[2], s, &state.theme);
}
fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
const PROMPT: &str = "? ";
let mode_badge = if s.explain { " explain" } else { "" };
// p9-fb-41: visible badge for the multi-hop toggle so the user
// always knows which pipeline the next submission will use.
// Styled with `Success` (multi_hop=on) so it stands out from
// the `explain` warning-colored badge.
let multi_hop_badge = if s.multi_hop { " multi-hop" } else { "" };
// Distinguish three async states for the operator:
// - currently streaming (worker still emitting tokens)
// - prior worker detached (Esc-cancelled, no rx attached but
// thread has not finished yet — Enter is blocked until it ends)
// - idle
let busy = if s.streaming {
" streaming…"
} else if s.thread.is_some() {
" awaiting prior answer (Enter blocked)"
} else {
""
};
let line = Line::from(vec![
Span::styled(PROMPT, theme.style(crate::theme::Role::Heading)),
Span::raw(s.input.as_str()),
Span::styled(mode_badge, theme.style(crate::theme::Role::Warning)),
Span::styled(multi_hop_badge, theme.style(crate::theme::Role::Success)),
Span::styled(busy, theme.style(crate::theme::Role::Hint)),
]);
let block = Block::default()
.title("ask (Enter=submit e=explain F2=multi-hop Ctrl-L=new conversation Esc=back)")
.borders(Borders::ALL);
let inner = block.inner(area);
let paragraph = Paragraph::new(line).block(block);
f.render_widget(paragraph, area);
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
// cursor_position is Some (our case here). When a render fn
// omits set_cursor_position (Library/Inspect), ratatui calls
// hide_cursor instead. So this single call both positions and
// unhides the caret for the Ask input column.
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
// the right edge of the inner area.
let prompt_w = crate::input::display_width(PROMPT);
let cursor_x =
crate::input::place_cursor_x(inner.x, inner.width, prompt_w, s.input.cursor_col());
f.set_cursor_position((cursor_x, inner.y));
}
fn render_answer(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let title = if s.turns.is_empty() && !s.streaming {
"transcript".to_string()
} else {
let count = s.turns.len() + usize::from(s.streaming);
format!(
"transcript ({} turn{})",
count,
if count == 1 { "" } else { "s" }
)
};
let block = Block::default().title(title).borders(Borders::ALL);
// p9-fb-16: render the full conversation as Q/A pairs.
// Completed turns first (chronological), then the in-flight
// turn (if any) at the bottom. The most-recent completed
// turn's grounded flag (from `last_answer`) styles its A line
// via the theme's Warning role on refusal so the user keeps
// the P9-3 visual distinction even inside the transcript.
let last_turn_grounded = s.last_answer.as_ref().map(|a| a.grounded);
let last_turn_idx = s.turns.len().saturating_sub(1);
let mut lines: Vec<Line> = Vec::new();
for (idx, turn) in s.turns.iter().enumerate() {
let role_override = if idx == last_turn_idx {
last_turn_grounded.and_then(|g| {
if g {
None
} else {
Some(crate::theme::Role::Warning)
}
})
} else {
None
};
push_turn_lines(
&mut lines,
idx,
&turn.question,
&turn.answer,
false,
role_override,
theme,
);
lines.push(Line::raw(""));
}
if s.streaming {
let q = s.current_question.as_deref().unwrap_or("");
let mut a = s.partial.clone();
a.push('▍');
let idx = s.turns.len();
push_turn_lines(&mut lines, idx, q, &a, true, None, theme);
}
if lines.is_empty() {
let hint = Paragraph::new(Span::styled(
"(type a question and press Enter. follow-ups inherit history. Ctrl-L clears the conversation.)",
theme.style(crate::theme::Role::Hint),
))
.wrap(Wrap { trim: false });
f.render_widget(hint.block(block), area);
return;
}
// p9-fb-22: follow-tail render. Build the paragraph, ask it for
// the post-wrap line count at the viewport width (via ratatui's
// `unstable-rendered-line-info` feature, pinned to ratatui 0.28
// in our Cargo.toml), then pin the scroll offset to
// `line_count - inner_height` when `follow_tail` is set.
let inner = block.inner(area);
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
let scroll = if s.follow_tail {
let total_lines = para.line_count(inner.width);
u16::try_from(total_lines.saturating_sub(inner.height as usize)).unwrap_or(u16::MAX)
} else {
s.scroll
};
f.render_widget(para.scroll((scroll, 0)).block(block), area);
}
fn push_turn_lines(
out: &mut Vec<Line<'static>>,
idx: usize,
question: &str,
answer: &str,
streaming: bool,
answer_role_override: Option<crate::theme::Role>,
theme: &crate::theme::Theme,
) {
let q_label = format!("Q{}", idx + 1);
let a_label = format!("A{}", idx + 1);
out.push(Line::from(vec![
// `Role::Heading` already includes BOLD in both palettes, so
// no need to `add_modifier(BOLD)` here — the redundancy would
// imply Heading lacks BOLD elsewhere.
Span::styled(q_label, theme.style(crate::theme::Role::Heading)),
Span::raw(": "),
Span::raw(question.to_string()),
]));
// p9-fb-11: render markdown (bold/italic/code/list/heading) when
// the answer is in a normal/grounded state. For refusal (Warning
// override) and streaming (Hint), force plain styled rendering so
// the role color stays visible — markdown styling on top would
// mask the "this is a refusal" / "this is in flight" signal.
let a_label_span = Span::styled(
a_label,
theme
.style(crate::theme::Role::Success)
.add_modifier(Modifier::BOLD),
);
if let Some(role) = answer_role_override {
out.push(Line::from(vec![
a_label_span,
Span::raw(": "),
Span::styled(answer.to_string(), theme.style(role)),
]));
} else if streaming {
out.push(Line::from(vec![
a_label_span,
Span::raw(": "),
Span::styled(answer.to_string(), theme.style(crate::theme::Role::Hint)),
]));
} else {
// Grounded answer: split A label onto its own marker line, then
// append markdown-rendered body lines indented two spaces (so
// the transcript stays readable when the answer wraps).
out.push(Line::from(vec![a_label_span, Span::raw(":")]));
for body_line in crate::markdown::render(answer, theme) {
let mut spans: Vec<Span<'static>> = Vec::with_capacity(body_line.spans.len() + 1);
spans.push(Span::raw(" "));
spans.extend(body_line.spans);
out.push(Line::from(spans));
}
}
}
fn render_bottom(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
render_status(f, split[0], s, theme);
render_citations_or_explain(f, split[1], s, theme);
}
fn render_status(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
let block = Block::default().title("status").borders(Borders::ALL);
let lines: Vec<Line> = match &s.last_answer {
None => vec![Line::from(Span::styled(
"(no answer yet)",
theme.style(crate::theme::Role::Hint),
))],
Some(a) => {
let grounded = if a.grounded { "" } else { "" };
let mode = match a.retrieval.mode {
SearchMode::Lexical => "lexical",
SearchMode::Vector => "vector",
SearchMode::Hybrid => "hybrid",
};
let refusal = match a.refusal_reason {
Some(RefusalReason::ScoreGate) => " refusal=score_gate",
Some(RefusalReason::LlmSelfJudge) => " refusal=llm_self_judge",
Some(RefusalReason::NoIndex) => " refusal=no_index",
Some(RefusalReason::NoChunks) => " refusal=no_chunks",
Some(RefusalReason::LlmStreamAborted) => " refusal=llm_stream_aborted",
Some(RefusalReason::MultiHopDecomposeFailed) => {
" refusal=multi_hop_decompose_failed"
}
// p9-fb-41 PR-9c-1: NLI refusals don't yet appear on
// live answers (PR-9c-2 wires the gate), but the
// match must stay exhaustive so the new variants
// compile without `_ => unreachable!()`.
Some(RefusalReason::NliVerificationFailed) => " refusal=nli_verification_failed",
Some(RefusalReason::NliModelUnavailable) => " refusal=nli_model_unavailable",
None => "",
};
let mut lines = vec![
Line::from(format!("grounded {grounded} model {}", a.model.id)),
Line::from(format!(
"prompt {} mode {mode}",
a.prompt_template_version.0
)),
Line::from(format!(
"k={} used={}/{}{refusal}",
a.retrieval.k, a.retrieval.chunks_used, a.retrieval.chunks_returned
)),
];
// p9-fb-41: surface a brief multi-hop summary when the
// turn was routed through the multi-hop pipeline. The
// full per-hop trace lives in `Answer.hops`; this line
// is the at-a-glance "yes, this used N hops" signal.
// `forced_stop` count flags depth/pool-cap terminations
// — useful for tuning `multi_hop_max_depth` etc.
if let Some(hops) = a.hops.as_ref() {
let forced = hops.iter().filter(|h| h.forced_stop).count();
let forced_tag = if forced > 0 {
format!(" forced_stop={forced}")
} else {
String::new()
};
lines.push(Line::from(Span::styled(
format!("multi-hop: {} hops{forced_tag}", hops.len()),
theme.style(crate::theme::Role::Success),
)));
}
lines
}
};
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn render_citations_or_explain(
f: &mut Frame,
area: Rect,
s: &AskState,
theme: &crate::theme::Theme,
) {
let title = if s.explain {
"explain (per-claim)"
} else {
"citations"
};
let block = Block::default().title(title).borders(Borders::ALL);
let lines: Vec<Line> = match &s.last_answer {
None => vec![Line::from(Span::styled(
"(submit a question to see citations)",
theme.style(crate::theme::Role::Hint),
))],
Some(a) if a.citations.is_empty() => vec![Line::from(Span::styled(
if a.grounded {
"(no citations)"
} else {
"(가까운 후보 없음)"
},
theme.style(crate::theme::Role::Hint),
))],
Some(a) => a
.citations
.iter()
.map(|c| {
let marker = c.marker.as_deref().unwrap_or("?");
// p9-fb-32: when `c.stale`, prepend a Warning-styled
// `[STALE] ` Span between the citation marker and the
// path so the user sees the staleness signal as text
// (not just color — fb-14 accessibility).
let mut spans = vec![Span::styled(
format!("[{marker}] "),
theme.style(crate::theme::Role::CitationMarker),
)];
if c.stale {
spans.push(Span::styled(
"[STALE] ",
theme.style(crate::theme::Role::Warning),
));
}
spans.push(Span::raw(c.citation.to_uri()));
Line::from(spans)
})
.collect(),
};
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(para.block(block), area);
}
/// Ask pane key dispatch. Submission spawns a worker thread that
/// drives `kebab-app::ask_with_config` with `stream_sink: Some(tx)`.
pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
if state.error_overlay.is_some() {
state.error_overlay = None;
return KeyOutcome::Continue;
}
if state.ask.is_none() {
return KeyOutcome::SwitchPane(Pane::Library);
}
match (key.code, key.modifiers) {
// p9-fb-16: Ctrl-L clears the in-pane conversation (turns).
// Doesn't kill the in-flight worker — that turn still finishes
// and its result is silently discarded (joined into a new
// conversation that didn't exist when the worker was spawned).
// Behaviour mirrors `:new` slash command.
(KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => {
let s = state.ask.as_mut().unwrap();
s.turns.clear();
s.last_answer = None;
s.partial.clear();
s.current_question = None;
s.scroll = 0;
// p9-fb-22: re-engage follow-tail on Ctrl-L so the next
// submission's stream auto-scrolls.
s.follow_tail = true;
// p9-fb-16: detach the in-flight worker so its eventual
// result does NOT graduate into the new conversation as
// a stale Turn. JoinHandle Drop on `None` assignment is
// the same detach pattern P9-3 uses for Esc cancel —
// worker keeps running in the background, finishes its
// SQLite `answers` write (the failed-conv attempt is
// preserved on disk), TUI ignores the result.
s.thread = None;
s.rx = None;
s.streaming = false;
KeyOutcome::Continue
}
(KeyCode::Esc, _) => {
// Best-effort cancellation per spec — worker keeps running
// but its result is dropped. Detach by clearing rx /
// thread; the JoinHandle Drop on later replacement will
// not block (we never `join` from this path).
let s = state.ask.as_mut().unwrap();
s.rx = None;
s.thread = None;
s.streaming = false;
s.current_question = None;
KeyOutcome::SwitchPane(Pane::Library)
}
(KeyCode::Enter, _) => {
// Submission gates:
// - empty input → no-op
// - already streaming → no-op (same worker is in flight)
// - prior worker still attached (e.g. user pressed Esc
// then re-entered Ask before that thread finished) →
// no-op. Otherwise the new worker would race the
// detached one against the same Ollama endpoint and
// the stream output would interleave.
if state.ask.as_ref().is_none_or(|s| {
s.streaming || s.thread.is_some() || s.input.as_str().trim().is_empty()
}) {
return KeyOutcome::Continue;
}
spawn_ask_worker(state);
KeyOutcome::Continue
}
// 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();
s.explain = !s.explain;
KeyOutcome::Continue
}
(KeyCode::Char('j'), KeyModifiers::NONE) if state.mode == crate::app::Mode::Normal => {
// p9-fb-22: scrolling down via `j` opts out of follow-
// tail. The renderer uses `s.scroll` (not the computed
// bottom offset) until the user presses `G` to re-pin.
let s = state.ask.as_mut().unwrap();
s.follow_tail = false;
s.scroll = s.scroll.saturating_add(1);
KeyOutcome::Continue
}
(KeyCode::Char('k'), KeyModifiers::NONE) if state.mode == crate::app::Mode::Normal => {
// p9-fb-22: scrolling up via `k` opts out of follow-tail.
let s = state.ask.as_mut().unwrap();
s.follow_tail = false;
s.scroll = s.scroll.saturating_sub(1);
KeyOutcome::Continue
}
// p9-fb-22: `G` jumps the transcript to the bottom and
// re-engages follow-tail so subsequent streaming auto-
// scrolls. Only available in Normal mode (Insert mode
// types `G` into the input).
(KeyCode::Char('G'), KeyModifiers::SHIFT) if state.mode == crate::app::Mode::Normal => {
let s = state.ask.as_mut().unwrap();
s.follow_tail = true;
s.scroll = 0;
KeyOutcome::Continue
}
// p9-fb-41: F2 toggles multi-hop. Mode-agnostic (physical
// function key, no typing ambiguity). The toggle takes
// effect on the *next* Enter submission — the in-flight
// turn (if any) keeps the multi_hop value it was spawned
// with. Conversation history (`turns`) survives the flip;
// a follow-up turn just routes through the other pipeline
// (no silent invalidation per p9-fb-16's contract).
(KeyCode::F(2), _) => {
let s = state.ask.as_mut().unwrap();
s.multi_hop = !s.multi_hop;
KeyOutcome::Continue
}
(KeyCode::Backspace, _) => {
let s = state.ask.as_mut().unwrap();
s.input.pop_char();
KeyOutcome::Continue
}
// p9-fb-22: arrow keys + Home/End + Delete edit at the cursor.
// Available in both Normal and Insert mode (no shift to typing
// ambiguity — these are physical keys, not Char codes).
(KeyCode::Left, _) => {
let s = state.ask.as_mut().unwrap();
s.input.move_left();
KeyOutcome::Continue
}
(KeyCode::Right, _) => {
let s = state.ask.as_mut().unwrap();
s.input.move_right();
KeyOutcome::Continue
}
(KeyCode::Home, _) => {
let s = state.ask.as_mut().unwrap();
s.input.move_home();
KeyOutcome::Continue
}
(KeyCode::End, _) => {
let s = state.ask.as_mut().unwrap();
s.input.move_end();
KeyOutcome::Continue
}
(KeyCode::Delete, _) => {
let s = state.ask.as_mut().unwrap();
s.input.delete_after();
KeyOutcome::Continue
}
// p9-fb-24: PgUp / PgDn page-scroll the transcript by
// `pager::PAGE_STEP` rows. Mode-agnostic (physical keys, no
// typing ambiguity). Both flip `follow_tail` to false so the
// user pinning the view via paging doesn't get yanked back to
// the bottom on the next streamed token (same contract as
// `j` / `k` from p9-fb-22).
(KeyCode::PageDown, _) => {
let s = state.ask.as_mut().unwrap();
s.follow_tail = false;
s.scroll = s.scroll.saturating_add(crate::pager::PAGE_STEP);
KeyOutcome::Continue
}
(KeyCode::PageUp, _) => {
let s = state.ask.as_mut().unwrap();
s.follow_tail = false;
s.scroll = s.scroll.saturating_sub(crate::pager::PAGE_STEP);
KeyOutcome::Continue
}
// 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_char(c);
KeyOutcome::Continue
}
// Normal mode + un-handled Char → no-op (no typing in Normal).
_ => KeyOutcome::Continue,
}
}
fn spawn_ask_worker(state: &mut App) {
let (tx, rx) = mpsc::channel::<kebab_app::StreamEvent>();
let cfg = state.config.clone();
let s = state.ask.as_mut().unwrap();
// p9-fb-10: take() consumes the input in one step (no clone +
// clear). The buffer is left empty with cursor at 0.
let query = s.input.take();
let explain = s.explain;
// p9-fb-41: snapshot the toggle at spawn time. Later F2 flips
// do NOT affect the in-flight turn.
let multi_hop = s.multi_hop;
s.partial.clear();
s.last_answer = None;
s.streaming = true;
s.scroll = 0;
// p9-fb-22: every new submission re-engages follow-tail so the
// streaming answer auto-scrolls into view as tokens arrive.
s.follow_tail = true;
s.rx = Some(rx);
// Graduate the typed input into the in-flight turn.
s.current_question = Some(query.clone());
let opts = kebab_app::AskOpts {
k: 0, // facade clamps to config.search.default_k floor
explain,
mode: kebab_core::SearchMode::Hybrid,
temperature: None,
seed: None,
stream_sink: Some(tx),
multi_hop,
};
let handle = thread::spawn(move || kebab_app::ask_with_config(cfg, &query, opts));
s.thread = Some(handle);
}
/// Run-loop hook: drain the streaming channel into `partial`. Called
/// on every render frame so the answer area updates as tokens arrive.
pub(crate) fn drain_stream(state: &mut App) {
let Some(s) = state.ask.as_mut() else { return };
if let Some(rx) = &s.rx {
for ev in rx.try_iter() {
match ev {
kebab_app::StreamEvent::Token { delta, .. } => {
s.partial.push_str(&delta);
}
// p9-fb-33: TUI ignores RetrievalDone (citation
// panel renders after completion via `last_answer`)
// and Final (the worker thread's join already
// delivers the canonical Answer in poll_worker).
kebab_app::StreamEvent::RetrievalDone { .. }
| kebab_app::StreamEvent::Final { .. } => {}
}
}
}
}
/// Run-loop hook: poll the worker thread for completion. When the
/// thread finishes, populate `answer` and clear `streaming`.
pub(crate) fn poll_worker(state: &mut App) {
let Some(s) = state.ask.as_mut() else { return };
let finished = s
.thread
.as_ref()
.is_some_and(std::thread::JoinHandle::is_finished);
if !finished {
return;
}
let handle = s.thread.take().expect("just confirmed Some");
let result = handle.join();
s.streaming = false;
s.rx = None;
match result {
Ok(Ok(answer)) => {
// Graduate the in-flight (current_question + partial /
// answer) into a completed TuiTurn for display.
let question = s.current_question.take().unwrap_or_default();
s.partial.clear();
let turn = crate::ask::TuiTurn {
question,
answer: answer.answer.clone(),
citations: answer.citations.clone(),
created_at: answer.created_at,
};
s.turns.push(turn);
s.last_answer = Some(answer);
}
Ok(Err(e)) => {
s.last_error = Some(format!("{e:#}"));
state.error_overlay = Some(crate::error_popup::ErrorOverlay::from_anyhow(&e));
}
Err(panic_payload) => {
let msg = panic_payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic_payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "ask worker panicked".to_string());
s.last_error = Some(msg.clone());
state.error_overlay = Some(crate::error_popup::ErrorOverlay::from_message(
"ask worker panic",
msg,
));
}
}
}
/// Test-only helper. The pane's worker spawns a real `ask_with_config`
/// thread which would touch SQLite + LanceDB + Ollama. Tests bypass it
/// by hand-populating `AskState` and asserting render / key handler
/// behavior directly.
#[cfg(any(test, doc))]
#[allow(dead_code)]
pub(crate) fn debug_partial(state: &App) -> Option<&str> {
state.ask.as_ref().map(|s| s.partial.as_str())
}

View File

@@ -1,199 +0,0 @@
//! p9-fb-13: cheatsheet popup (`F1` toggle).
//!
//! Modal overlay listing every key binding the active pane responds
//! to, plus the global mode toggles (`i`/`Esc`). Triggered with
//! `F1` (universal help key — no collision with the existing Library
//! `?` binding, which already opens the Ask pane). `F1` or `Esc`
//! while the popup is visible closes it.
//!
//! Spec p9-fb-13 lists `?` as the trigger and a verb-form hint line
//! above the status bar. Both are deferred:
//!
//! * `?` would clobber Library's quick-Ask binding (`Char('?') →
//! SwitchPane(Ask)`). We swap to `F1` per HOTFIXES — common help
//! key, no rebinding needed.
//! * The verb hint line redesign sits in the existing `render_footer`
//! path; the per-pane string already serves the same role. A
//! future PR can split it into mode-aware verb fragments.
//!
//! **Maintenance**: the `push_section(...)` calls below hold every
//! key binding as a literal string — there is NO automated link
//! from `handle_key_*` to the cheatsheet entries. A future PR that
//! changes a binding (e.g. swap `r` → `R` for ingest) MUST update
//! the matching entry here. Drift would be silently invisible
//! (the cheatsheet still renders, but lies about the live key).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::app::{App, Pane};
use crate::theme::{Role, Theme};
/// Render the cheatsheet popup, centered on `area` with a 70% / 60%
/// box (matches the error overlay's footprint so the visual rhythm
/// is consistent). The body is one section per pane plus the global
/// toggles.
pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) {
// p9-fb-21: bumped from 60% → 75% height so the Inspect section
// (last in the list) still fits after Search + Ask each gained
// one row (`o` inspect + `i` Insert toggle).
let popup_area = centered_rect(area, 70, 75);
f.render_widget(Clear, popup_area);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
"kebab TUI — keymap (F1 / Esc to close)",
app.theme.style(Role::Heading).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
push_section(
&mut lines,
&app.theme,
"Global",
&[
("i", "Normal → Insert (every pane — p9-fb-21)"),
("Esc", "Insert → Normal (any pane)"),
("F1", "toggle this cheatsheet"),
("Tab / Shift-Tab", "(future) cycle pane"),
],
);
push_section(
&mut lines,
&app.theme,
"Library",
&[
("j / k", "move selection (Normal)"),
("gg / G", "top / bottom"),
("f", "filter overlay"),
("/", "switch to Search"),
("?", "switch to Ask"),
("Enter", "inspect selected doc"),
("r", "background ingest"),
("q", "quit"),
],
);
push_section(
&mut lines,
&app.theme,
"Search",
&[
("type", "query (Insert)"),
("Tab", "cycle search mode (lexical / vector / hybrid)"),
("Enter", "force search now (skip debounce)"),
("j / k", "move selection (Normal)"),
("← / →", "move cursor in query (p9-fb-22)"),
("Home / End", "cursor to start / end of query"),
("Delete", "remove char at cursor"),
("g", "open hit's citation in $EDITOR (Normal)"),
(
"o",
"inspect selected hit's chunk (Normal — was `i` pre-fb-21)",
),
("t", "open retrieval trace popup (Normal — p9-fb-37)"),
("i", "Normal → Insert (toggle back to typing)"),
("Esc", "back to Library"),
],
);
push_section(
&mut lines,
&app.theme,
"Ask",
&[
("type", "question (Insert)"),
("Enter", "submit"),
("e", "toggle explain mode (Normal)"),
(
"F2",
"toggle multi-hop pipeline (p9-fb-41 — affects next submission)",
),
("j / k", "scroll transcript (Normal — disengages auto-tail)"),
("Shift-G", "jump to bottom + re-engage auto-tail (p9-fb-22)"),
(
"PgUp / PgDn",
"page-scroll the transcript (p9-fb-24, disengages auto-tail)",
),
("← / →", "move cursor in input (p9-fb-22)"),
("Home / End", "cursor to start / end of input"),
("Delete", "remove char at cursor"),
("i", "Normal → Insert (toggle back to typing)"),
("Ctrl-L", "new conversation (clears turns)"),
("Esc", "back to Library (cancels in-flight worker)"),
],
);
push_section(
&mut lines,
&app.theme,
"Inspect",
&[
("j / k", "scroll lines"),
("PgUp / PgDn", "scroll pages"),
("c", "collapse / expand all sections"),
("Esc / q", "back to originating pane"),
],
);
// Pane footer: which pane is currently focused (helps the
// reader correlate \"the keys above\" with their current
// context).
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("(currently focused: {})", pane_label(app.focus)),
app.theme.style(Role::Hint),
)));
let block = Block::default()
.title("? cheatsheet")
.borders(Borders::ALL)
.border_style(app.theme.style(Role::Heading));
let para = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(para, popup_area);
}
fn push_section(
lines: &mut Vec<Line<'static>>,
theme: &Theme,
name: &'static str,
keys: &[(&'static str, &'static str)],
) {
lines.push(Line::from(Span::styled(
name,
theme.style(Role::Heading).add_modifier(Modifier::BOLD),
)));
for (key, desc) in keys {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{key:<18}"), theme.style(Role::CitationMarker)),
Span::raw(" "),
Span::raw(desc.to_string()),
]));
}
lines.push(Line::from(""));
}
fn pane_label(p: Pane) -> &'static str {
match p {
Pane::Library => "Library",
Pane::Search => "Search",
Pane::Ask => "Ask",
Pane::Inspect => "Inspect",
Pane::Jobs => "Jobs",
}
}
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let w = (area.width * percent_x / 100).max(40).min(area.width);
let h = (area.height * percent_y / 100).max(10).min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect::new(x, y, w, h)
}

View File

@@ -1,132 +0,0 @@
//! p9-fb-09: external-program suspend/restore helper.
//!
//! Spawning `$EDITOR` (or any other foreground child) from the TUI
//! requires a careful dance: leave the alternate screen, drop raw
//! mode, hand the terminal to the child, then on return re-enter the
//! alternate screen, re-enable raw mode, AND clear the framebuffer so
//! Ratatui's next draw doesn't paint on top of stale text from before
//! the suspension.
//!
//! Earlier `kebab-tui::search::jump_to_citation` did the suspend half
//! correctly via a RAII guard but skipped the post-resume `clear()` —
//! the frame from before the editor stayed visible underneath the new
//! draw, producing the "TUI 화면이 깨짐" report (도그푸딩 item 7).
//!
//! `with_external_program` centralizes the dance so any future call
//! site (citation jump, `$VISUAL` invocation, etc.) inherits the fix
//! automatically. Callers pass the `Command` (already configured) and
//! get back the child's `ExitStatus` if the spawn succeeded.
use std::process::{Command, ExitStatus};
use anyhow::{Context, Result};
use crossterm::cursor::{Hide, Show};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use crate::terminal::TuiTerminal;
/// Suspend the TUI (leave alt screen, drop raw mode, show cursor),
/// run `cmd` to completion in the host terminal, then restore the
/// TUI (re-enter alt screen, re-enable raw mode, hide cursor) and
/// `clear()` the framebuffer so the next `draw` repaints from a
/// blank canvas instead of layering on top of stale glyphs.
///
/// The restore happens via a RAII guard so a panic inside the child
/// spawn (or in this function before the explicit restore) still
/// puts the terminal back into raw + alternate-screen mode — the
/// shell would otherwise be left in a corrupt state.
///
/// On success, returns the child's `ExitStatus`. The caller decides
/// whether a non-zero exit is an error (editor was cancelled vs.
/// crashed) — this helper only fails if the spawn itself fails.
pub(crate) fn with_external_program(
terminal: &mut TuiTerminal,
mut cmd: Command,
) -> Result<ExitStatus> {
suspend_tui()?;
// RAII guard: regardless of how we leave (panic, error, normal
// return) the terminal goes back into raw + alt-screen mode and
// the framebuffer is cleared.
struct Restore<'a> {
terminal: &'a mut TuiTerminal,
}
impl Drop for Restore<'_> {
fn drop(&mut self) {
// Best-effort: errors here would clobber an in-flight
// panic if propagated. Match the conservative posture in
// `TuiTerminal::Drop` — log via `tracing` and continue.
if let Err(e) = resume_tui(self.terminal) {
tracing::error!(target: "kebab-tui", error = ?e, "TUI restore failed");
}
}
}
let restore = Restore { terminal };
let status = cmd
.status()
.with_context(|| format!("spawn child program: {:?}", cmd.get_program()))?;
drop(restore);
Ok(status)
}
/// Leave the alternate screen, disable raw mode, and show the cursor
/// so a child process inherits a "normal" terminal.
fn suspend_tui() -> Result<()> {
let mut out = std::io::stdout();
execute!(out, LeaveAlternateScreen, Show).context("crossterm: LeaveAlternateScreen + Show")?;
disable_raw_mode().context("crossterm: disable_raw_mode")?;
Ok(())
}
/// Re-enter the alternate screen, re-enable raw mode, hide the
/// cursor, and `terminal.clear()` so Ratatui draws a fresh frame
/// without inheriting whatever was on screen before the suspension.
fn resume_tui(terminal: &mut TuiTerminal) -> Result<()> {
enable_raw_mode().context("crossterm: enable_raw_mode")?;
let mut out = std::io::stdout();
execute!(out, EnterAlternateScreen, Hide).context("crossterm: EnterAlternateScreen + Hide")?;
terminal
.inner
.clear()
.context("ratatui: terminal.clear after editor return")?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::process::Command;
/// Sanity check on the OS layer that `with_external_program`
/// builds on top of: a missing program path makes `Command::
/// status()` fail with `ENOENT`, which the helper wraps with
/// `with_context(|| format!("spawn child program: {:?}", ...))`
/// so the error chain points at the program name.
///
/// We can't construct a `TuiTerminal` in a unit test (no real
/// terminal), so the helper end-to-end is verified by the
/// dogfooding loop in the spec rather than here. This test
/// only pins the OS behavior the helper assumes — if a future
/// libc / Rust update changes which `ErrorKind` is returned for
/// `ENOENT`, the helper's error message stays meaningful but
/// this test catches the platform regression first.
#[test]
fn command_status_returns_not_found_for_missing_program() {
let mut cmd = Command::new("/nonexistent/kebab-test-binary-xxx");
cmd.arg("dummy-arg");
let result = cmd.status();
assert!(result.is_err(), "expected ENOENT-like failure");
let err = result.unwrap_err();
assert!(
matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
),
"unexpected error kind: {err:?}",
);
}
}

View File

@@ -1,83 +0,0 @@
//! Error popup overlay — rendered on top of any pane when the last
//! facade call returned `Err`. Any key dismisses (handled by the
//! pane's key handler before its own dispatch).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::theme::{Role, Theme};
/// Captured snapshot of an `anyhow::Error` for rendering. We do NOT
/// store the `anyhow::Error` itself (it is `!Sync` in pre-1.0.99
/// versions on some toolchains and would force lifetime gymnastics
/// on `App`); we render the formatted chain at capture time.
#[derive(Clone, Debug)]
pub struct ErrorOverlay {
pub title: String,
/// Each chain link as a separate line, root-cause last.
pub chain: Vec<String>,
}
impl ErrorOverlay {
pub fn from_anyhow(err: &anyhow::Error) -> Self {
let chain: Vec<String> = err.chain().map(std::string::ToString::to_string).collect();
Self {
title: "error".to_string(),
chain,
}
}
pub fn from_message(title: impl Into<String>, msg: impl Into<String>) -> Self {
Self {
title: title.into(),
chain: vec![msg.into()],
}
}
}
/// Render the popup centred in `area`. Caller is responsible for
/// clearing the underlying region (`Clear` widget); we do that here.
/// `theme` is threaded so the overlay's red borders / dim hint use
/// the same role-style mapping as every other pane (p9-fb-14).
pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay, theme: &Theme) {
let popup_area = centered_rect(area, 60, 50);
f.render_widget(Clear, popup_area);
let mut lines: Vec<Line> = Vec::with_capacity(overlay.chain.len() + 2);
lines.push(Line::from(Span::styled(
format!(
"{}: {}",
overlay.title,
overlay.chain.first().map_or("(unknown)", String::as_str)
),
theme.style(Role::Error).add_modifier(Modifier::BOLD),
)));
for cause in overlay.chain.iter().skip(1) {
lines.push(Line::from(format!(" caused by: {cause}")));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"press any key to dismiss",
theme.style(Role::Hint),
)));
let block = Block::default()
.title("error")
.borders(Borders::ALL)
.border_style(theme.style(Role::Error));
let para = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(para, popup_area);
}
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let w = (area.width * percent_x / 100).max(20).min(area.width);
let h = (area.height * percent_y / 100).max(5).min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect::new(x, y, w, h)
}

View File

@@ -1,481 +0,0 @@
//! TUI background-ingest worker + status-bar reducer (p9-fb-03).
//!
//! The Library pane's `r` key fires `start_ingest`, which spawns a
//! worker thread calling
//! `kebab_app::ingest_with_config_progress(.., Some(tx))`. The run
//! loop drains the matching `rx` once per frame via
//! `drain_progress` and re-renders the status bar from the
//! accumulated counts. When the worker emits a terminal event
//! (`Completed` / `Aborted`) the status line freezes for a few
//! seconds (`TERMINAL_LINE_HOLD_SECS`) and then `tick_clear` returns
//! true so the run loop can drop the slot.
//!
//! Cancel (p9-fb-04) is wired by sharing an `Arc<AtomicBool>`
//! between the worker thread (polled at each asset-loop boundary
//! inside `kebab_app::ingest_with_config_cancellable`) and the TUI
//! key handler (`Esc` / `Ctrl-C` flips it via `cancel_running_ingest`).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use kebab_app::IngestEvent;
use kebab_core::SourceScope;
use crate::app::{App, IngestState, TERMINAL_LINE_HOLD_SECS};
/// Already-running guard. Returns `Err` if `app.ingest_state` is
/// already populated — pressing `r` twice in a row should not spawn
/// two parallel workers (SQLite is mutexed but Lance writes can race
/// each other).
pub fn start_ingest(app: &mut App) -> anyhow::Result<()> {
if app.ingest_state.is_some() {
anyhow::bail!("ingest already running");
}
let cfg = app.config.clone();
// [[workspace.sources]]: leave `scope.root` empty so the app iterates
// every configured source (`config.resolved_sources()`), mirroring the
// CLI `kebab ingest` path. Each source carries its own merged exclude.
let scope = SourceScope::default();
let (tx, rx) = mpsc::channel::<IngestEvent>();
let cancel = Arc::new(AtomicBool::new(false));
let cancel_for_worker = cancel.clone();
let cfg_for_thread = cfg;
let thread = thread::spawn(move || {
kebab_app::ingest_with_config_cancellable(
cfg_for_thread,
scope,
true,
Some(tx),
Some(cancel_for_worker),
)
});
app.ingest_state = Some(IngestState {
rx,
counts: kebab_app::AggregateCounts::default(),
current_path: None,
current_idx: 0,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: Some(thread),
cancel,
});
Ok(())
}
/// Flip the cancel token of an in-flight ingest. Returns `true` if a
/// run was actually in flight (and thus the signal will reach the
/// worker), `false` if there was nothing to cancel — the caller
/// (key handler) can decide whether to swallow the keypress or let
/// the original pane action run.
pub fn cancel_running_ingest(app: &App) -> bool {
match app.ingest_state.as_ref() {
Some(state) if state.terminal_at.is_none() => {
state.cancel.store(true, Ordering::Relaxed);
true
}
_ => false,
}
}
/// Drain whatever progress events have arrived since the last tick.
/// Non-blocking. Caller (the run loop) calls this once per frame.
///
/// On a terminal event (`Completed` / `Aborted`) the function records
/// `terminal_at = Instant::now()` so subsequent ticks can decide when
/// to clear the slot.
pub fn drain_progress(app: &mut App) {
let Some(state) = app.ingest_state.as_mut() else {
return;
};
while let Ok(event) = state.rx.try_recv() {
apply_event(state, event);
}
}
fn apply_event(state: &mut IngestState, event: IngestEvent) {
match event {
IngestEvent::ScanStarted { .. } => {
// No counter to update; `started_at` already set by
// `start_ingest`. The status line shows "scanning…" while
// counts.scanned is zero.
}
IngestEvent::ScanCompleted { total } => {
state.counts.scanned = total;
}
IngestEvent::AssetStarted { idx, path, .. } => {
state.current_idx = idx;
state.current_path = Some(path);
}
IngestEvent::AssetFinished { result, chunks, .. } => {
// Per-asset counter increments mirror the way
// `kebab-app::ingest_with_config_progress` aggregates the
// final report — kept in sync so the status bar's running
// totals match the eventual `Completed { counts }`.
match result {
kebab_core::IngestItemKind::New => {
state.counts.new = state.counts.new.saturating_add(1);
state.counts.chunks_indexed =
state.counts.chunks_indexed.saturating_add(chunks);
}
kebab_core::IngestItemKind::Updated => {
state.counts.updated = state.counts.updated.saturating_add(1);
state.counts.chunks_indexed =
state.counts.chunks_indexed.saturating_add(chunks);
}
kebab_core::IngestItemKind::Skipped => {
state.counts.skipped = state.counts.skipped.saturating_add(1);
}
kebab_core::IngestItemKind::Unchanged => {
state.counts.unchanged = state.counts.unchanged.saturating_add(1);
}
kebab_core::IngestItemKind::Error => {
state.counts.errors = state.counts.errors.saturating_add(1);
}
}
}
IngestEvent::Completed { counts } => {
// Trust the facade's authoritative aggregate — replaces
// any tiny drift between our running totals and the
// final report.
state.counts = counts;
state.current_path = None;
state.terminal_at = Some(std::time::Instant::now());
state.aborted = false;
}
IngestEvent::Aborted { counts } => {
state.counts = counts;
state.current_path = None;
state.terminal_at = Some(std::time::Instant::now());
state.aborted = true;
}
// v0.20.0 sub-item 1: per-page PDF OCR events — TUI does not
// surface per-page OCR progress in v1; no counter to update.
IngestEvent::PdfOcrStarted { .. }
| IngestEvent::PdfOcrFinished { .. }
// v0.24.0 asset-internal phase events: the status-bar reducer tracks
// per-asset counters, not sub-asset phase progress, so these are
// no-ops here (the CLI / --json surfaces render them).
| IngestEvent::AssetChunked { .. }
| IngestEvent::AssetTimings { .. }
// v0.26.1 slow-phase hint (ocr / caption / embed): the CLI bar uses
// it for a live phase message; the TUI status-bar reducer tracks only
// per-asset counters, so it's a no-op here.
| IngestEvent::AssetPhase { .. } => {}
}
}
/// Should the run loop drop `app.ingest_state` now? True when the
/// terminal event arrived ≥ `TERMINAL_LINE_HOLD_SECS` ago.
pub fn ready_to_clear(state: &IngestState) -> bool {
match state.terminal_at {
Some(t) => t.elapsed().as_secs() >= TERMINAL_LINE_HOLD_SECS,
None => false,
}
}
/// Render the status-bar text for the current `IngestState`. Pure —
/// the run loop wraps this in a Paragraph widget. Returns the
/// human-friendly line per spec §p9-fb-03 ("`ingest: 142/1024 (14%)
/// parsing notes/foo.md [0:42]`").
pub fn status_line(state: &IngestState) -> String {
if state.terminal_at.is_some() {
let elapsed = state.started_at.elapsed();
let secs = elapsed.as_secs();
if state.aborted {
let skipped_breakdown = kebab_app::ingest_progress::render_skipped_breakdown(
&state.counts.skipped_by_extension,
);
return format!(
"✗ ingest aborted at {}/{} after {}s (new={} updated={} unchanged={} skipped={}{} errors={})",
state.counts.scanned.saturating_sub(state.counts.errors),
state.counts.scanned,
secs,
state.counts.new,
state.counts.updated,
state.counts.unchanged,
state.counts.skipped,
skipped_breakdown,
state.counts.errors,
);
}
let skipped_breakdown = kebab_app::ingest_progress::render_skipped_breakdown(
&state.counts.skipped_by_extension,
);
return format!(
"✓ ingest: {} docs ({} new, {} updated, {} unchanged, {} skipped{}), {} chunks indexed in {}s",
state.counts.scanned,
state.counts.new,
state.counts.updated,
state.counts.unchanged,
state.counts.skipped,
skipped_breakdown,
state.counts.chunks_indexed,
secs,
);
}
if state.counts.scanned == 0 {
let secs = state.started_at.elapsed().as_secs();
return format!("ingest: scanning… [{secs}s]");
}
let pct =
u64::from(state.current_idx).saturating_mul(100) / u64::from(state.counts.scanned.max(1));
let elapsed = state.started_at.elapsed();
let mm = elapsed.as_secs() / 60;
let ss = elapsed.as_secs() % 60;
let path = state.current_path.as_deref().unwrap_or("");
format!(
"ingest: {}/{} ({}%) {} [{}:{:02}]",
state.current_idx, state.counts.scanned, pct, path, mm, ss,
)
}
#[cfg(test)]
mod tests {
use super::*;
use kebab_app::AggregateCounts;
use kebab_core::IngestItemKind;
use std::sync::mpsc;
fn fresh_state() -> IngestState {
let (_tx, rx) = mpsc::channel::<IngestEvent>();
IngestState {
rx,
counts: AggregateCounts::default(),
current_path: None,
current_idx: 0,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: None,
cancel: Arc::new(AtomicBool::new(false)),
}
}
#[test]
fn apply_scan_completed_sets_total() {
let mut s = fresh_state();
apply_event(&mut s, IngestEvent::ScanCompleted { total: 42 });
assert_eq!(s.counts.scanned, 42);
}
#[test]
fn apply_asset_finished_accumulates_per_kind_counters() {
let mut s = fresh_state();
apply_event(
&mut s,
IngestEvent::AssetFinished {
idx: 1,
total: 3,
result: IngestItemKind::New,
chunks: 5,
},
);
apply_event(
&mut s,
IngestEvent::AssetFinished {
idx: 2,
total: 3,
result: IngestItemKind::Updated,
chunks: 2,
},
);
apply_event(
&mut s,
IngestEvent::AssetFinished {
idx: 3,
total: 3,
result: IngestItemKind::Skipped,
chunks: 0,
},
);
assert_eq!(s.counts.new, 1);
assert_eq!(s.counts.updated, 1);
assert_eq!(s.counts.skipped, 1);
assert_eq!(s.counts.chunks_indexed, 7);
}
#[test]
fn apply_completed_replaces_counts_and_marks_terminal() {
let mut s = fresh_state();
let final_counts = AggregateCounts {
scanned: 10,
new: 5,
updated: 5,
chunks_indexed: 50,
..Default::default()
};
apply_event(
&mut s,
IngestEvent::Completed {
counts: final_counts.clone(),
},
);
assert_eq!(s.counts, final_counts);
assert!(s.terminal_at.is_some());
assert!(!s.aborted);
}
#[test]
fn apply_aborted_marks_aborted_flag() {
let mut s = fresh_state();
apply_event(
&mut s,
IngestEvent::Aborted {
counts: AggregateCounts::default(),
},
);
assert!(s.terminal_at.is_some());
assert!(s.aborted);
}
#[test]
fn status_line_scanning_shows_dots() {
let s = fresh_state();
let line = status_line(&s);
assert!(line.starts_with("ingest: scanning…"), "got: {line}");
}
#[test]
fn status_line_in_progress_shows_count_path_pct() {
let mut s = fresh_state();
apply_event(&mut s, IngestEvent::ScanCompleted { total: 100 });
apply_event(
&mut s,
IngestEvent::AssetStarted {
idx: 14,
total: 100,
path: "notes/foo.md".into(),
media: "markdown".into(),
},
);
let line = status_line(&s);
assert!(line.contains("14/100"), "got: {line}");
assert!(line.contains("(14%)"), "got: {line}");
assert!(line.contains("notes/foo.md"), "got: {line}");
}
#[test]
fn status_line_terminal_completed_shows_check_mark_and_totals() {
let mut s = fresh_state();
apply_event(
&mut s,
IngestEvent::Completed {
counts: AggregateCounts {
scanned: 10,
new: 8,
updated: 1,
skipped: 1,
chunks_indexed: 50,
..Default::default()
},
},
);
let line = status_line(&s);
assert!(line.starts_with("✓ ingest:"), "got: {line}");
assert!(line.contains("10 docs"), "got: {line}");
assert!(line.contains("50 chunks"), "got: {line}");
}
#[test]
fn status_line_terminal_aborted_shows_cross() {
let mut s = fresh_state();
s.current_idx = 7;
apply_event(
&mut s,
IngestEvent::Aborted {
counts: AggregateCounts {
scanned: 100,
errors: 0,
..Default::default()
},
},
);
let line = status_line(&s);
assert!(line.starts_with("✗ ingest aborted"), "got: {line}");
assert!(line.contains("100/100"), "got: {line}");
}
#[test]
fn ready_to_clear_false_until_hold_elapses() {
let mut s = fresh_state();
s.terminal_at = Some(std::time::Instant::now());
assert!(!ready_to_clear(&s));
}
#[test]
fn ready_to_clear_true_in_absence_of_terminal_is_false() {
let s = fresh_state();
assert!(!ready_to_clear(&s));
}
#[test]
fn cancel_running_ingest_returns_false_when_no_state() {
let cfg = kebab_config::Config::defaults();
let app = App::new(cfg).unwrap();
assert!(!cancel_running_ingest(&app));
}
#[test]
fn cancel_running_ingest_flips_token_when_in_flight() {
let cfg = kebab_config::Config::defaults();
let mut app = App::new(cfg).unwrap();
app.ingest_state = Some(fresh_state());
let token = app.ingest_state.as_ref().unwrap().cancel.clone();
assert!(!token.load(Ordering::Relaxed));
assert!(cancel_running_ingest(&app));
assert!(token.load(Ordering::Relaxed));
}
#[test]
fn cancel_running_ingest_returns_false_when_terminal_already_seen() {
let cfg = kebab_config::Config::defaults();
let mut app = App::new(cfg).unwrap();
let mut s = fresh_state();
s.terminal_at = Some(std::time::Instant::now());
app.ingest_state = Some(s);
// No worker to cancel — already terminated.
assert!(!cancel_running_ingest(&app));
}
#[test]
fn status_line_terminal_includes_skipped_breakdown() {
let mut s = fresh_state();
let skipped_by_extension = std::collections::BTreeMap::from([
("docx".to_string(), 2u32),
("txt".to_string(), 1u32),
]);
let counts = AggregateCounts {
scanned: 10,
skipped: 3,
skipped_by_extension,
..Default::default()
};
apply_event(&mut s, IngestEvent::Completed { counts });
let line = status_line(&s);
assert!(
line.contains("3 skipped: 2 docx, 1 txt"),
"breakdown must appear in: {line}"
);
}
#[test]
fn status_line_aborted_includes_skipped_breakdown() {
let mut s = fresh_state();
let skipped_by_extension = std::collections::BTreeMap::from([("pdf".to_string(), 2u32)]);
let counts = AggregateCounts {
scanned: 5,
skipped: 2,
skipped_by_extension,
..Default::default()
};
apply_event(&mut s, IngestEvent::Aborted { counts });
let line = status_line(&s);
assert!(
line.contains("skipped=2: 2 pdf"),
"breakdown must appear in: {line}"
);
}
}

View File

@@ -1,552 +0,0 @@
//! p9-fb-10: CJK / wide-char width helpers.
//!
//! TUI rendering needs **column width**, not char count. ASCII = 1
//! column, Hangul / CJK / fullwidth Latin = 2 columns, combining
//! diacriticals = 0. Naive `s.chars().count()` overflows boxes when
//! the user types `한글` (5 chars × 2 cols = 10 columns — twice
//! what a 5-char ASCII string would be).
//!
//! These helpers wrap `unicode-width` (already a workspace dep used
//! by `library.rs` for the doc-list title column). Centralizing
//! avoids drift between panes that all need the same calculation.
//!
//! ## What this crate does NOT do
//!
//! * **IME composing**: crossterm doesn't surface IME composition
//! events on any platform (raw `KeyCode::Char(c)` per finalized
//! jamo). Users on macOS / Windows IME stacks see one char per
//! commit; on Linux ibus / fcitx similar. The TUI sees the
//! already-composed character — no preedit handling needed.
//! * **Grapheme clusters** beyond what `unicode-width` covers (e.g.
//! emoji + skin-tone modifier rendering as 1 visual but 2 chars).
//! The dominant CJK use case is single-char-per-glyph; emoji
//! fallback is best-effort via `unicode_width::UnicodeWidthStr`.
//!
//! ## Backspace + boundary safety
//!
//! `String::pop()` is char-aware (returns `Option<char>`, removes
//! one Unicode scalar value, never splits a UTF-8 sequence
//! mid-byte). Every existing pane's `Backspace` handler uses
//! `pop()`, so byte-slicing bugs are out of scope. The helpers
//! below are purely for **rendering width**.
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Compute the cursor column for a text-input pane: prompt width +
/// content cursor, summed in `usize` to avoid `u16` overflow, then
/// clamped to fit within `inner_width` columns from `inner_x`.
///
/// Use as:
/// ```ignore
/// f.set_cursor_position((place_cursor_x(inner.x, inner.width, prompt_w, buf.cursor_col()), inner.y));
/// ```
///
/// If a fourth input pane is added, use this helper rather than
/// open-coding the arithmetic — one place to fix if the clamping
/// policy ever changes.
pub fn place_cursor_x(inner_x: u16, inner_width: u16, prompt_w: usize, cursor_col: usize) -> u16 {
let raw = (inner_x as usize)
.saturating_add(prompt_w)
.saturating_add(cursor_col);
let max = (inner_x as usize).saturating_add(inner_width.saturating_sub(1) as usize);
raw.min(max).try_into().unwrap_or(u16::MAX)
}
/// Display width of `s` in terminal columns. CJK / fullwidth = 2
/// per char, ASCII = 1, combining marks = 0. Sums every char's
/// `unicode-width` reading — same calculation Ratatui uses
/// internally, exposed here so callers can pre-compute layout.
pub fn display_width(s: &str) -> usize {
s.width()
}
/// Truncate `s` to fit within `max_cols` terminal columns,
/// appending `…` when truncated. The `…` itself counts as 1
/// column. Returns `s` unchanged when it already fits.
///
/// Boundary contract: never splits a multi-byte UTF-8 sequence
/// (`for ch in s.chars()` walks code points). Wide chars are
/// either kept whole or fully omitted — never half-rendered.
pub fn truncate_to_display_width(s: &str, max_cols: usize) -> String {
if s.width() <= max_cols {
return s.to_string();
}
if max_cols == 0 {
return String::new();
}
let cap = max_cols.saturating_sub(1);
let mut out = String::new();
let mut cols = 0usize;
for ch in s.chars() {
let w = ch.width().unwrap_or(0);
if cols + w > cap {
out.push('…');
return out;
}
cols += w;
out.push(ch);
}
// Loop ended without exceeding cap — but we know s.width() >
// max_cols (early-return covered the easy case), so the only
// way to land here is zero-width tail (combining marks). Add
// the ellipsis and stop.
out.push('…');
out
}
/// Text input buffer with mid-string cursor editing. The cursor
/// position is stored as a byte index into `content` (UTF-8 char
/// boundary), and the display column is derived on demand by
/// summing `unicode-width` over the prefix.
///
/// Wide chars (Hangul / Kanji / fullwidth) count 2 columns; ASCII
/// counts 1; combining marks 0. The cursor lives **between** chars,
/// not on them — `cursor_byte == 0` is "before the first char",
/// `cursor_byte == content.len()` is "after the last char".
///
/// `push_char` / `pop_char` operate **at the cursor**, not at the
/// end. When the cursor is at the end (the freshly-typed state),
/// behavior matches the pre-fb-22 append-only buffer. When the
/// cursor is mid-string (after a Left arrow), `push_char` inserts
/// at that position and `pop_char` deletes the char immediately
/// before the cursor (Backspace semantics).
#[derive(Debug, Default, Clone)]
pub struct InputBuffer {
content: String,
cursor_byte: usize,
}
impl InputBuffer {
/// Create an empty buffer.
pub fn new() -> Self {
Self::default()
}
/// Insert a single char at the cursor and advance the cursor
/// past it. Zero-width chars (combining marks) leave the
/// display column unchanged but still extend `content`.
pub fn push_char(&mut self, ch: char) {
self.content.insert(self.cursor_byte, ch);
self.cursor_byte += ch.len_utf8();
}
/// Insert a `&str` char-by-char at the cursor. Same width
/// semantics as `push_char` per element.
pub fn push_str(&mut self, s: &str) {
for ch in s.chars() {
self.push_char(ch);
}
}
/// Delete the char immediately before the cursor (Backspace)
/// and rewind the cursor onto its byte position. No-op on
/// empty input or when the cursor is already at the start.
pub fn pop_char(&mut self) -> Option<char> {
if self.cursor_byte == 0 {
return None;
}
let prev = self.content[..self.cursor_byte]
.chars()
.next_back()
.expect("cursor_byte > 0 implies at least one prior char");
let new_byte = self.cursor_byte - prev.len_utf8();
self.content.remove(new_byte);
self.cursor_byte = new_byte;
Some(prev)
}
/// Delete the char at the cursor (Delete key). Cursor stays
/// in place. No-op when the cursor is at the end.
pub fn delete_after(&mut self) -> Option<char> {
if self.cursor_byte >= self.content.len() {
return None;
}
Some(self.content.remove(self.cursor_byte))
}
/// Move the cursor one char to the left (toward index 0).
/// Returns true when the cursor moved.
pub fn move_left(&mut self) -> bool {
if self.cursor_byte == 0 {
return false;
}
let prev = self.content[..self.cursor_byte]
.chars()
.next_back()
.expect("cursor_byte > 0 implies at least one prior char");
self.cursor_byte -= prev.len_utf8();
true
}
/// Move the cursor one char to the right (toward end of content).
/// Returns true when the cursor moved.
pub fn move_right(&mut self) -> bool {
if self.cursor_byte >= self.content.len() {
return false;
}
let next = self.content[self.cursor_byte..]
.chars()
.next()
.expect("cursor_byte < len implies at least one trailing char");
self.cursor_byte += next.len_utf8();
true
}
/// Move the cursor to the start of the buffer.
pub fn move_home(&mut self) {
self.cursor_byte = 0;
}
/// Move the cursor to the end of the buffer.
pub fn move_end(&mut self) {
self.cursor_byte = self.content.len();
}
/// Reset to empty.
pub fn clear(&mut self) {
self.content.clear();
self.cursor_byte = 0;
}
/// Move the typed string out, leaving the buffer empty (cursor 0).
/// Convenience for "submit" flows that consume the input.
pub fn take(&mut self) -> String {
self.cursor_byte = 0;
std::mem::take(&mut self.content)
}
/// Borrow the typed text.
pub fn as_str(&self) -> &str {
&self.content
}
/// Cursor column in display-width units — sum of every char's
/// `unicode-width` reading from the start of the buffer up to
/// (but not including) the cursor.
pub fn cursor_col(&self) -> usize {
self.content[..self.cursor_byte].width()
}
/// True when no chars have been typed.
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// p9-fb-10: ASCII = 1 col per char.
#[test]
fn ascii_width_is_one_per_char() {
assert_eq!(display_width(""), 0);
assert_eq!(display_width("hello"), 5);
assert_eq!(display_width("kebab"), 5);
}
/// p9-fb-10: Hangul = 2 cols per char (single composed syllable).
#[test]
fn hangul_width_is_two_per_char() {
assert_eq!(display_width(""), 2);
assert_eq!(display_width("한글"), 4);
assert_eq!(display_width("러스트"), 6);
}
/// p9-fb-10: mixed ASCII + Hangul sums correctly.
#[test]
fn mixed_ascii_hangul_width() {
// "kb-한글" = k(1) + b(1) + -(1) + 한(2) + 글(2) = 7
assert_eq!(display_width("kb-한글"), 7);
// "Hello, 세계" = "Hello"(5) + ","(1) + " "(1) + "세"(2) + "계"(2) = 11
assert_eq!(display_width("Hello, 세계"), 11);
}
/// p9-fb-10: Japanese kana / kanji also wide.
#[test]
fn japanese_width_is_two_per_char() {
assert_eq!(display_width("こんにちは"), 10);
assert_eq!(display_width("漢字"), 4);
}
/// p9-fb-10: truncate fits when possible, no allocation.
#[test]
fn truncate_returns_same_when_already_fits() {
assert_eq!(truncate_to_display_width("hello", 5), "hello");
assert_eq!(truncate_to_display_width("hello", 100), "hello");
assert_eq!(truncate_to_display_width("한글", 4), "한글");
}
/// p9-fb-10: truncate emits ellipsis when overflow.
#[test]
fn truncate_emits_ellipsis_on_overflow() {
assert_eq!(truncate_to_display_width("hello", 4), "hel…");
assert_eq!(truncate_to_display_width("hello world", 8), "hello w…");
}
/// p9-fb-10: truncate respects wide-char boundary — never splits
/// a Hangul syllable to fit one column.
#[test]
fn truncate_does_not_split_wide_char() {
// "한글테스트" = 10 cols. max_cols=5 → fits "한글" (4) + "…" (1).
// Cannot include "테" because that would push to 4+2 > 4 (cap).
let out = truncate_to_display_width("한글테스트", 5);
assert_eq!(out, "한글…");
assert_eq!(display_width(&out), 5);
}
/// p9-fb-10: max_cols=0 returns empty (degenerate; no room
/// even for the ellipsis).
#[test]
fn truncate_zero_cols_is_empty() {
assert_eq!(truncate_to_display_width("hello", 0), "");
assert_eq!(truncate_to_display_width("한글", 0), "");
}
/// p9-fb-10: backspace via String::pop is char-aware (sanity
/// pin — exercises the contract these helpers depend on).
#[test]
fn string_pop_handles_hangul_boundary_safely() {
let mut s = String::from("러스트");
let popped = s.pop();
assert_eq!(popped, Some('트'));
assert_eq!(s, "러스");
assert_eq!(display_width(&s), 4);
// Pop again — still char-aware.
s.pop();
assert_eq!(s, "");
assert_eq!(display_width(&s), 2);
}
/// p9-fb-10: ASCII typing advances cursor by 1 per char.
#[test]
fn input_buffer_ascii_cursor_advances_by_one() {
let mut b = InputBuffer::new();
for ch in "hello".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), 5);
assert_eq!(b.as_str(), "hello");
}
/// p9-fb-10: Hangul typing advances cursor by 2 per char.
#[test]
fn input_buffer_hangul_cursor_advances_by_two() {
let mut b = InputBuffer::new();
for ch in "한글".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), 4);
assert_eq!(b.as_str(), "한글");
}
/// p9-fb-10: Backspace rewinds cursor by the popped char's
/// width — Hangul rewinds by 2, ASCII by 1.
#[test]
fn input_buffer_pop_char_rewinds_cursor_by_width() {
let mut b = InputBuffer::new();
b.push_str("러스트");
assert_eq!(b.cursor_col(), 6);
let popped = b.pop_char();
assert_eq!(popped, Some('트'));
assert_eq!(b.cursor_col(), 4);
assert_eq!(b.as_str(), "러스");
// Invariant must still hold after pop, not just after push.
assert_eq!(b.cursor_col(), display_width(b.as_str()));
b.push_char('a');
assert_eq!(b.cursor_col(), 5);
assert_eq!(b.as_str(), "러스a");
}
/// p9-fb-10: cursor invariant — cursor_col always equals
/// display_width(content).
#[test]
fn input_buffer_cursor_matches_display_width() {
let mut b = InputBuffer::new();
for ch in "Hello, 세계 mixed".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), display_width(b.as_str()));
}
/// p9-fb-10: clear resets both content and cursor.
#[test]
fn input_buffer_clear_resets_state() {
let mut b = InputBuffer::new();
b.push_str("한글");
b.clear();
assert_eq!(b.cursor_col(), 0);
assert!(b.is_empty());
}
/// p9-fb-10: pop_char on empty input returns None and leaves
/// cursor at 0 (no underflow).
#[test]
fn input_buffer_pop_on_empty_is_noop() {
let mut b = InputBuffer::new();
assert!(b.pop_char().is_none());
assert_eq!(b.cursor_col(), 0);
}
/// p9-fb-10: take() returns the content and resets state.
#[test]
fn input_buffer_take_returns_content_and_resets() {
let mut b = InputBuffer::new();
b.push_str("러스트");
let s = b.take();
assert_eq!(s, "러스트");
assert!(b.is_empty());
assert_eq!(b.cursor_col(), 0);
}
/// p9-fb-10: place_cursor_x clamps within the inner area.
#[test]
fn place_cursor_x_clamps_to_inner_right_edge() {
// inner.x=10, width=20, so the rightmost column is 10+20-1 = 29.
// prompt_w=2, cursor_col=100 (overflow) → clamped to 29.
assert_eq!(place_cursor_x(10, 20, 2, 100), 29);
}
/// p9-fb-10: place_cursor_x preserves position when within bounds.
#[test]
fn place_cursor_x_keeps_position_when_within_bounds() {
assert_eq!(place_cursor_x(10, 20, 2, 5), 17); // 10 + 2 + 5
}
/// p9-fb-22: Left arrow moves cursor back by one char (ASCII).
#[test]
fn input_buffer_move_left_ascii() {
let mut b = InputBuffer::new();
b.push_str("abc");
assert_eq!(b.cursor_col(), 3);
assert!(b.move_left());
assert_eq!(b.cursor_col(), 2);
assert!(b.move_left());
assert_eq!(b.cursor_col(), 1);
assert!(b.move_left());
assert_eq!(b.cursor_col(), 0);
assert!(!b.move_left());
assert_eq!(b.cursor_col(), 0);
}
/// p9-fb-22: Left arrow rewinds by full Hangul width (2 cols, 3 bytes).
#[test]
fn input_buffer_move_left_hangul() {
let mut b = InputBuffer::new();
b.push_str("러스트");
assert_eq!(b.cursor_col(), 6);
assert!(b.move_left());
assert_eq!(b.cursor_col(), 4);
assert_eq!(b.as_str(), "러스트");
}
/// p9-fb-22: Right arrow advances by one char until the end.
#[test]
fn input_buffer_move_right_until_end() {
let mut b = InputBuffer::new();
b.push_str("ab");
b.move_home();
assert_eq!(b.cursor_col(), 0);
assert!(b.move_right());
assert_eq!(b.cursor_col(), 1);
assert!(b.move_right());
assert_eq!(b.cursor_col(), 2);
assert!(!b.move_right());
assert_eq!(b.cursor_col(), 2);
}
/// p9-fb-22: Home / End cursor jumps.
#[test]
fn input_buffer_move_home_end() {
let mut b = InputBuffer::new();
b.push_str("hello");
b.move_home();
assert_eq!(b.cursor_col(), 0);
b.move_end();
assert_eq!(b.cursor_col(), 5);
}
/// p9-fb-22: typing mid-string inserts at cursor (not append).
#[test]
fn input_buffer_insert_at_cursor_mid_string() {
let mut b = InputBuffer::new();
b.push_str("abc");
b.move_left(); // cursor between b and c
b.move_left(); // cursor between a and b
b.push_char('X'); // insert X between a and b
assert_eq!(b.as_str(), "aXbc");
assert_eq!(b.cursor_col(), 2);
}
/// p9-fb-22: Backspace mid-string removes the char before the cursor.
#[test]
fn input_buffer_backspace_at_cursor() {
let mut b = InputBuffer::new();
b.push_str("abcde");
b.move_left(); // cursor between d and e
b.move_left(); // cursor between c and d
b.pop_char(); // delete c
assert_eq!(b.as_str(), "abde");
assert_eq!(b.cursor_col(), 2);
}
/// p9-fb-22: Backspace at start of buffer is a no-op.
#[test]
fn input_buffer_backspace_at_home_is_noop() {
let mut b = InputBuffer::new();
b.push_str("abc");
b.move_home();
assert!(b.pop_char().is_none());
assert_eq!(b.as_str(), "abc");
assert_eq!(b.cursor_col(), 0);
}
/// p9-fb-22: Delete key removes the char AT the cursor; cursor stays.
#[test]
fn input_buffer_delete_after_at_cursor() {
let mut b = InputBuffer::new();
b.push_str("abc");
b.move_home();
assert_eq!(b.delete_after(), Some('a'));
assert_eq!(b.as_str(), "bc");
assert_eq!(b.cursor_col(), 0);
}
/// p9-fb-22: Delete on empty buffer / at end → no-op.
#[test]
fn input_buffer_delete_after_at_end_is_noop() {
let mut b = InputBuffer::new();
b.push_str("ab");
// cursor at end
assert!(b.delete_after().is_none());
assert_eq!(b.as_str(), "ab");
}
/// p9-fb-22: cursor_col stays consistent after mixed mid-string edits
/// with wide chars.
#[test]
fn input_buffer_cursor_col_after_mixed_hangul_edits() {
let mut b = InputBuffer::new();
b.push_str("a한b"); // cursor at end, col = 1 + 2 + 1 = 4
assert_eq!(b.cursor_col(), 4);
b.move_left(); // before 'b': col = 3
assert_eq!(b.cursor_col(), 3);
b.move_left(); // before '한': col = 1
assert_eq!(b.cursor_col(), 1);
b.push_char('글'); // insert 글 → "a글한b", cursor between 글 and 한, col = 1 + 2 = 3
assert_eq!(b.as_str(), "a글한b");
assert_eq!(b.cursor_col(), 3);
}
/// p9-fb-22: take() resets cursor even when it was mid-string.
#[test]
fn input_buffer_take_resets_mid_string_cursor() {
let mut b = InputBuffer::new();
b.push_str("abc");
b.move_left();
let s = b.take();
assert_eq!(s, "abc");
assert!(b.is_empty());
assert_eq!(b.cursor_col(), 0);
}
}

View File

@@ -1,585 +0,0 @@
//! Inspect pane (P9-4).
//!
//! Read-only view of a `CanonicalDocument` (entered from Library
//! `Enter`) or a `Chunk` (entered from Search `i`). Sections
//! (metadata / provenance / blocks / embeddings) are collapsible
//! via `c`. `Esc` returns to the originating pane.
//!
//! Spec deviation (HOTFIXES `2026-05-02 P9-4`):
//! - `render_inspect<B: Backend>` generic dropped (ratatui 0.28 Frame
//! is backend-agnostic — same as P9-1 / P9-2 / P9-3).
//! - Search pane now exposes `i` to enter chunk inspect (spec says
//! "from Search pressing `i`"); previously Search had no `i` —
//! added in p9-2's handler module since this PR can edit it.
//!
//! Per design §1 inspect output, §3.5 Chunk, §2.5 DocSummary,
//! §2.6 ChunkInspection.
use crossterm::event::{KeyCode, KeyEvent};
use kebab_core::{Block, CanonicalDocument, Chunk};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block as RBlock, Borders, Paragraph, Wrap};
use crate::app::{App, InspectState, InspectTarget, KeyOutcome, Pane};
const SECTION_METADATA: &str = "metadata";
const SECTION_PROVENANCE: &str = "provenance";
const SECTION_BLOCKS: &str = "blocks";
const SECTION_EMBEDDINGS: &str = "embeddings";
const SECTION_TEXT: &str = "text";
const SECTION_SPANS: &str = "spans";
/// Render the Inspect pane. Doc target → `render_doc`, chunk target →
/// `render_chunk`. No target → empty hint.
pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) {
let Some(s) = state.inspect.as_ref() else {
f.render_widget(
RBlock::default().title("Inspect").borders(Borders::ALL),
area,
);
return;
};
if s.loading {
let block = RBlock::default()
.title("Inspect — loading…")
.borders(Borders::ALL);
f.render_widget(block, area);
return;
}
// p9-fb-32: compute staleness against the configured threshold so
// the inspect header can carry a `[STALE]` badge alongside the
// doc_path. Threshold = 0 short-circuits in `compute_stale`.
let threshold_days = state.config.search.stale_threshold_days;
match (&s.target, &s.doc, &s.chunk) {
(Some(InspectTarget::Doc(_)), Some(doc), _) => {
render_doc(f, area, s, doc, &state.theme, threshold_days);
}
(Some(InspectTarget::Chunk(_)), _, Some(chunk)) => {
render_chunk(f, area, s, chunk, &state.theme);
}
_ => {
let block = RBlock::default().title("Inspect").borders(Borders::ALL);
let hint = Paragraph::new(Span::styled(
"(no target — return to Library and press Enter on a doc, \
or to Search and press `i` on a hit)",
state.theme.style(crate::theme::Role::Hint),
))
.wrap(Wrap { trim: false });
f.render_widget(hint.block(block), area);
}
}
}
fn render_doc(
f: &mut Frame,
area: Rect,
s: &InspectState,
doc: &CanonicalDocument,
theme: &crate::theme::Theme,
threshold_days: u32,
) {
let lines = build_doc_lines(s, doc, theme, threshold_days);
let block = RBlock::default()
.title(format!("Inspect Doc — {}", short_id(&doc.doc_id.0)))
.borders(Borders::ALL);
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((s.scroll, 0));
f.render_widget(para.block(block), area);
}
fn render_chunk(
f: &mut Frame,
area: Rect,
s: &InspectState,
chunk: &Chunk,
theme: &crate::theme::Theme,
) {
let lines = build_chunk_lines(s, chunk, theme);
let block = RBlock::default()
.title(format!("Inspect Chunk — {}", short_id(&chunk.chunk_id.0)))
.borders(Borders::ALL);
let para = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((s.scroll, 0));
f.render_widget(para.block(block), area);
}
/// Build the wrapped Lines for a doc inspect view. Pure function so
/// snapshot tests can compare a stable prefix of lines.
///
/// p9-fb-32: when `now - doc.metadata.updated_at > threshold_days`,
/// the `doc_path` header line is preceded by a Warning-styled
/// `[STALE] ` Span. Threshold 0 short-circuits to never-stale.
pub(crate) fn build_doc_lines<'a>(
s: &InspectState,
doc: &'a CanonicalDocument,
theme: &crate::theme::Theme,
threshold_days: u32,
) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
// Header
let now = time::OffsetDateTime::now_utc();
// `doc.metadata.updated_at` is the same source as `SearchHit.indexed_at`
// (both come from `documents.updated_at`); we compute here because Inspect
// doesn't go through the SearchHit post-process pipeline.
let stale = kebab_app::compute_stale(doc.metadata.updated_at, now, threshold_days);
lines.push(header_kv("title", &doc.title, theme));
lines.push(header_kv_with_stale(
"doc_path",
&doc.workspace_path.0,
stale,
theme,
));
lines.push(header_kv("doc_id", &doc.doc_id.0, theme));
lines.push(header_kv("lang", &doc.lang.0, theme));
lines.push(header_kv(
"source_type",
&format!("{:?}", doc.metadata.source_type).to_lowercase(),
theme,
));
lines.push(header_kv(
"trust_level",
&format!("{:?}", doc.metadata.trust_level).to_lowercase(),
theme,
));
lines.push(header_kv("parser_version", &doc.parser_version.0, theme));
lines.push(blank());
// metadata
push_section_header(&mut lines, SECTION_METADATA, s, theme);
if !s.collapsed.contains(SECTION_METADATA) {
lines.push(kv("aliases", &format!("{:?}", doc.metadata.aliases), theme));
lines.push(kv("tags", &format!("{:?}", doc.metadata.tags), theme));
lines.push(kv("created_at", &fmt_dt(&doc.metadata.created_at), theme));
lines.push(kv("updated_at", &fmt_dt(&doc.metadata.updated_at), theme));
// user metadata pretty-printed JSON
if let Ok(pretty) =
serde_json::to_string_pretty(&serde_json::Value::Object(doc.metadata.user.clone()))
{
for line in pretty.lines() {
lines.push(Line::from(format!(" {line}")));
}
}
lines.push(blank());
}
// provenance
push_section_header(&mut lines, SECTION_PROVENANCE, s, theme);
if !s.collapsed.contains(SECTION_PROVENANCE) {
if doc.provenance.events.is_empty() {
lines.push(Line::from(Span::styled(
" (no events)",
theme.style(crate::theme::Role::Hint),
)));
} else {
for ev in &doc.provenance.events {
let kind = format!("{:?}", ev.kind).to_lowercase();
let note = ev.note.as_deref().unwrap_or("");
lines.push(Line::from(format!(
" [{}] {}{}{}{}",
fmt_dt(&ev.at),
ev.agent,
kind,
if note.is_empty() { "" } else { ": " },
note,
)));
}
}
lines.push(blank());
}
// blocks — section header carries the count inline so a
// collapsed view still reports "how many" without leaking
// body lines (R1 review: count must collapse with the rest).
push_section_header_with_count(&mut lines, SECTION_BLOCKS, s, Some(doc.blocks.len()), theme);
if !s.collapsed.contains(SECTION_BLOCKS) {
let preview_n = 16.min(doc.blocks.len());
for (i, b) in doc.blocks.iter().take(preview_n).enumerate() {
lines.push(Line::from(format!(" [{i}] {}", describe_block(b))));
}
if doc.blocks.len() > preview_n {
lines.push(Line::from(Span::styled(
format!(" … +{} more", doc.blocks.len() - preview_n),
theme.style(crate::theme::Role::Hint),
)));
}
}
lines
}
pub(crate) fn build_chunk_lines<'a>(
s: &InspectState,
chunk: &'a Chunk,
theme: &crate::theme::Theme,
) -> Vec<Line<'a>> {
let mut lines: Vec<Line> = Vec::new();
// Header
lines.push(header_kv("chunk_id", &chunk.chunk_id.0, theme));
lines.push(header_kv("doc_id", &chunk.doc_id.0, theme));
lines.push(header_kv(
"heading_path",
&if chunk.heading_path.is_empty() {
"-".to_string()
} else {
chunk.heading_path.join(" / ")
},
theme,
));
lines.push(header_kv(
"chunker_version",
&chunk.chunker_version.0,
theme,
));
lines.push(header_kv("policy_hash", &chunk.policy_hash, theme));
lines.push(header_kv(
"token_estimate",
&chunk.token_estimate.to_string(),
theme,
));
lines.push(blank());
// source spans
push_section_header(&mut lines, SECTION_SPANS, s, theme);
if !s.collapsed.contains(SECTION_SPANS) {
if chunk.source_spans.is_empty() {
lines.push(Line::from(Span::styled(
" (no spans)",
theme.style(crate::theme::Role::Hint),
)));
} else {
for span in &chunk.source_spans {
lines.push(Line::from(format!(" {}", describe_span(span))));
}
}
lines.push(blank());
}
// text
push_section_header(&mut lines, SECTION_TEXT, s, theme);
if !s.collapsed.contains(SECTION_TEXT) {
for line in chunk.text.lines() {
lines.push(Line::from(format!(" {line}")));
}
if chunk.text.is_empty() {
lines.push(Line::from(Span::styled(
" (empty)",
theme.style(crate::theme::Role::Hint),
)));
}
lines.push(blank());
}
// embeddings — section header carries the block_id count inline
// (spec § Out of scope: full embedding records lookup is P+).
push_section_header_with_count(
&mut lines,
SECTION_EMBEDDINGS,
s,
Some(chunk.block_ids.len()),
theme,
);
if !s.collapsed.contains(SECTION_EMBEDDINGS) {
lines.push(Line::from(Span::styled(
" (embedding records not loaded — out of v1 scope)",
theme.style(crate::theme::Role::Hint),
)));
for bid in &chunk.block_ids {
lines.push(Line::from(format!(" {}", bid.0)));
}
}
lines
}
fn header_kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{k:>16}: "),
theme.style(crate::theme::Role::Heading),
),
Span::raw(v.to_string()),
])
}
/// p9-fb-32: same as `header_kv` but prepends `[STALE] ` (Warning-
/// styled) before the value when `stale == true`. The `[STALE]` text
/// is plain ASCII so monochrome readers still get the signal (fb-14
/// accessibility note).
fn header_kv_with_stale(
k: &str,
v: &str,
stale: bool,
theme: &crate::theme::Theme,
) -> Line<'static> {
let mut spans = vec![Span::styled(
format!("{k:>16}: "),
theme.style(crate::theme::Role::Heading),
)];
if stale {
spans.push(Span::styled(
"[STALE] ",
theme.style(crate::theme::Role::Warning),
));
}
spans.push(Span::raw(v.to_string()));
Line::from(spans)
}
fn kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {k}: "), theme.style(crate::theme::Role::Hint)),
Span::raw(v.to_string()),
])
}
fn blank() -> Line<'static> {
Line::from("")
}
fn push_section_header(
lines: &mut Vec<Line<'static>>,
name: &'static str,
s: &InspectState,
theme: &crate::theme::Theme,
) {
push_section_header_with_count(lines, name, s, None, theme);
}
/// Section header + optional inline count. Inline-count form is used
/// where a collapsed section should still report \"how many\" — see
/// blocks / embeddings.
fn push_section_header_with_count(
lines: &mut Vec<Line<'static>>,
name: &'static str,
s: &InspectState,
count: Option<usize>,
theme: &crate::theme::Theme,
) {
let collapsed = s.collapsed.contains(name);
let marker = if collapsed { "" } else { "" };
let title = match count {
Some(n) => format!("{marker} {name} ({n})"),
None => format!("{marker} {name}"),
};
lines.push(Line::from(Span::styled(
title,
theme
.style(crate::theme::Role::Warning)
.add_modifier(Modifier::BOLD),
)));
}
fn fmt_dt(dt: &time::OffsetDateTime) -> String {
dt.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "?".into())
}
fn short_id(id: &str) -> String {
if id.len() > 12 {
format!("{}", &id[..12])
} else {
id.to_string()
}
}
fn describe_block(b: &Block) -> String {
match b {
Block::Heading(h) => format!("Heading L{}: {:?}", h.level, h.text),
Block::Paragraph(p) => {
let snippet = p.text.lines().next().unwrap_or("");
let trimmed = if snippet.chars().count() > 60 {
format!("{}", snippet.chars().take(60).collect::<String>())
} else {
snippet.to_string()
};
format!("Paragraph: {trimmed}")
}
Block::Quote(q) => format!("Quote: {} chars", q.text.len()),
Block::List(l) => format!(
"List {} ({} items)",
if l.ordered { "ordered" } else { "unordered" },
l.items.len()
),
Block::Code(c) => format!(
"Code [{}]: {} bytes",
c.lang.as_deref().unwrap_or("?"),
c.code.len()
),
Block::Table(t) => format!("Table: {} cols × {} rows", t.headers.len(), t.rows.len()),
Block::ImageRef(i) => format!(
"ImageRef: src={} alt={:?} ocr={}",
i.src,
i.alt,
if i.ocr.is_some() { "Y" } else { "N" }
),
Block::AudioRef(a) => format!(
"AudioRef: asset_id={} duration_ms={}",
a.asset_id.0, a.duration_ms
),
}
}
fn describe_span(span: &kebab_core::SourceSpan) -> String {
use kebab_core::SourceSpan;
match span {
SourceSpan::Line { start, end } => format!("Line {start}-{end}"),
SourceSpan::Byte { start, end } => format!("Byte {start}-{end}"),
SourceSpan::Page {
page,
char_start,
char_end,
} => match (char_start, char_end) {
(Some(s), Some(e)) => format!("Page {page} (chars {s}-{e})"),
_ => format!("Page {page}"),
},
SourceSpan::Region { x, y, w, h } => {
format!("Region xywh={x},{y},{w},{h}")
}
SourceSpan::Time { start_ms, end_ms } => {
format!("Time {start_ms}-{end_ms} ms")
}
SourceSpan::Code {
line_start,
line_end,
symbol,
..
} => match symbol {
Some(sym) => format!("Code {line_start}-{line_end} ({sym})"),
None => format!("Code {line_start}-{line_end}"),
},
}
}
/// Inspect pane key dispatch.
pub fn handle_key_inspect(state: &mut App, key: KeyEvent) -> KeyOutcome {
if state.error_overlay.is_some() {
state.error_overlay = None;
return KeyOutcome::Continue;
}
let Some(s) = state.inspect.as_mut() else {
return KeyOutcome::SwitchPane(Pane::Library);
};
match (key.code, key.modifiers) {
(KeyCode::Esc | KeyCode::Char('q'), _) => KeyOutcome::SwitchPane(s.return_to),
(KeyCode::Char('j') | KeyCode::Down, _) => {
s.scroll = s.scroll.saturating_add(1);
KeyOutcome::Continue
}
(KeyCode::Char('k') | KeyCode::Up, _) => {
s.scroll = s.scroll.saturating_sub(1);
KeyOutcome::Continue
}
(KeyCode::PageDown, _) => {
s.scroll = s.scroll.saturating_add(crate::pager::PAGE_STEP);
KeyOutcome::Continue
}
(KeyCode::PageUp, _) => {
s.scroll = s.scroll.saturating_sub(crate::pager::PAGE_STEP);
KeyOutcome::Continue
}
(KeyCode::Char('c'), _) => {
// Toggle all sections at once. v1 simplification per spec
// ("focus is implicit by current scroll position; v1 may
// simplify by toggling all sections").
toggle_all_sections(s);
KeyOutcome::Continue
}
_ => KeyOutcome::Continue,
}
}
fn toggle_all_sections(s: &mut InspectState) {
let candidates: &[&'static str] = &[
SECTION_METADATA,
SECTION_PROVENANCE,
SECTION_BLOCKS,
SECTION_EMBEDDINGS,
SECTION_TEXT,
SECTION_SPANS,
];
let any_collapsed = candidates.iter().any(|n| s.collapsed.contains(*n));
if any_collapsed {
// Some collapsed → expand all.
s.collapsed.clear();
} else {
// None collapsed → collapse all.
for &name in candidates {
s.collapsed.insert(name);
}
}
}
/// Run-loop hook: fetch doc / chunk for the current target if
/// `needs_fetch`. Synchronous (v1).
pub(crate) fn refresh_inspect(state: &mut App) -> anyhow::Result<()> {
let cfg = state.config.clone();
let target = {
let s = state.inspect.as_ref().expect("inspect slot must exist");
if !s.needs_fetch {
return Ok(());
}
s.target.clone()
};
let Some(target) = target else {
let s = state.inspect.as_mut().unwrap();
s.needs_fetch = false;
return Ok(());
};
{
let s = state.inspect.as_mut().unwrap();
s.loading = true;
}
match target {
InspectTarget::Doc(doc_id) => {
let result = kebab_app::inspect_doc_with_config(cfg, &doc_id);
let s = state.inspect.as_mut().unwrap();
s.loading = false;
s.needs_fetch = false;
match result {
Ok(doc) => {
s.doc = Some(doc);
s.chunk = None;
s.scroll = 0;
}
Err(e) => return Err(e),
}
}
InspectTarget::Chunk(chunk_id) => {
let result = kebab_app::inspect_chunk_with_config(cfg, &chunk_id);
let s = state.inspect.as_mut().unwrap();
s.loading = false;
s.needs_fetch = false;
match result {
Ok(chunk) => {
s.chunk = Some(chunk);
s.doc = None;
s.scroll = 0;
}
Err(e) => return Err(e),
}
}
}
Ok(())
}
/// Helper used by Library / Search panes to enter Inspect with a
/// specific target. Sets `needs_fetch` so the run-loop tick
/// services the `kebab-app::inspect_*` call.
pub fn enter_inspect(state: &mut App, target: InspectTarget, return_to: Pane) {
if state.inspect.is_none() {
state.inspect = Some(InspectState::default());
}
let s = state.inspect.as_mut().unwrap();
s.target = Some(target);
s.return_to = return_to;
s.needs_fetch = true;
s.doc = None;
s.chunk = None;
s.scroll = 0;
s.collapsed.clear();
}

View File

@@ -1,71 +0,0 @@
//! `kebab-tui` — Ratatui shell + Library pane (P9-1).
//!
//! Per design §8 module boundary: UI crates may only touch the
//! `kebab-app` facade. The store / search / embed / llm / rag layers
//! stay invisible behind it. P9-1 establishes the shell (App loop,
//! key dispatch, error popup, raw-mode panic guard) plus the Library
//! pane. P9-2/3/4 plug into the same `App` struct via the
//! `Option<*State>` slot pattern (parallel-safety: their sub-state
//! types start as `pub struct *State;` opaque forward declarations
//! and only their authoring crate fills the body).
//!
//! Per report §16.2 (TUI epic), design §1 (UX scenes), design §3.7
//! (`SearchHit` / `DocSummary`).
mod app;
mod ask;
mod cheatsheet;
mod editor;
mod error_popup;
mod ingest_progress;
mod input;
mod inspect;
mod library;
mod markdown;
mod pager;
mod run;
mod search;
mod terminal;
mod theme;
pub mod trace_popup;
pub use app::{
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};
pub use ingest_progress::{
cancel_running_ingest, drain_progress, ready_to_clear, start_ingest, status_line,
};
pub use input::{InputBuffer, display_width, place_cursor_x, truncate_to_display_width};
pub use inspect::{enter_inspect, handle_key_inspect, render_inspect};
pub use library::{handle_key_library, render_library};
pub use theme::{Palette, Role, Theme};
// `editor::with_external_program` and `search::jump_to_citation`
// stay `pub(crate)` — they take the internal `TuiTerminal` handle,
// which is intentionally module-private (its `Drop` lifecycle is the
// only safe constructor path for raw mode + alt-screen). External
// callers stage editor spawns via `App.pending_editor` instead.
pub use search::{build_jump_command, handle_key_search, render_search};
// p9-fb-08: expose `poll_worker` + `debounce_due` so integration
// tests can drive the stale-result drop / fresh-result apply paths
// without spawning the real thread (they inject a
// `SearchWorkerMessage` directly via a channel they construct in
// the test) and can pin the in-flight-skip invariant of debounce.
pub use search::debounce_due as search_debounce_due;
pub use search::poll_worker as poll_search_worker;
// 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;
// p9-fb-13: expose the cheatsheet-toggle intercept + render fn
// for integration tests + future TUI consumers.
pub use cheatsheet::render_cheatsheet;
pub use run::cheatsheet_intercept;
// p9-fb-24: expose the status bar render fn so integration tests can
// pin its content without standing up the full run loop.
pub use run::render_status_bar;
// p9-fb-13 follow-up: expose footer_hints so integration tests can
// pin the verb-form per (pane, mode) without standing up the run loop.
pub use run::footer_hints;

View File

@@ -1,593 +0,0 @@
//! Library pane — list + filter + key dispatch.
//!
//! State / render / key handler are kept in one module so the slot
//! pattern (p9-2/3/4 in their own modules) has a clear template to
//! follow. The renderer is `Frame`-typed — ratatui 0.28 dropped the
//! `B: Backend` generic from `Frame` (it's bound at `Terminal` init),
//! so the spec's `render_library<B: Backend>` literal is collapsed
//! here. Logged in HOTFIXES.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{DocFilter, DocSummary, Lang};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use crate::app::{App, KeyOutcome, Pane};
use crate::input::{display_width, truncate_to_display_width};
/// Width (in display columns) of the `tags` column in the doc-list
/// row. Used twice — truncate input + pad calculation — so a const
/// keeps them in sync.
const TAGS_COL_W: usize = 12;
/// Internal state owned by `LibraryState`. Public-by-crate so
/// `handle_key_library` can mutate it without crossing the
/// `pub`-visibility boundary `LibraryState` exposes.
pub(crate) struct LibraryStateInner {
pub docs: Vec<DocSummary>,
pub list_state: ListState,
pub filter: DocFilter,
/// Edit overlay for the filter (toggled by `f`). `Some` while
/// the user is editing tags / lang fields.
pub filter_edit: Option<FilterEdit>,
/// True after `App::new` and again after every filter refresh,
/// flipped to false once the run loop services the refresh.
pub needs_refresh: bool,
/// True while the run loop is awaiting `kebab-app::list_docs_with_config`
/// — drives the "loading…" header span. Synchronous in v1
/// (acceptable hang per spec).
pub loading: bool,
/// `g` waiting for the second `g` (vim-style `gg` → top).
pub pending_g: bool,
}
impl Default for LibraryStateInner {
fn default() -> Self {
let mut list_state = ListState::default();
list_state.select(None);
Self {
docs: Vec::new(),
list_state,
filter: DocFilter::default(),
filter_edit: None,
needs_refresh: true,
loading: false,
pending_g: false,
}
}
}
/// Filter edit overlay state. `f` toggles in/out of edit mode;
/// while editing, `tab` cycles between fields and `Enter` commits.
pub(crate) struct FilterEdit {
pub field: FilterField,
pub tags_buf: crate::input::InputBuffer,
pub lang_buf: crate::input::InputBuffer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FilterField {
Tags,
Lang,
}
impl FilterEdit {
/// Borrow the buffer for the currently-focused field. Centralizes
/// the `match edit.field` pick so the key-handler arms (Backspace
/// / arrows / Delete / typed Char) don't each re-spell the same
/// 2-arm dispatch.
fn active_buf_mut(&mut self) -> &mut crate::input::InputBuffer {
match self.field {
FilterField::Tags => &mut self.tags_buf,
FilterField::Lang => &mut self.lang_buf,
}
}
pub fn from_filter(filter: &DocFilter) -> Self {
let mut tags_buf = crate::input::InputBuffer::new();
tags_buf.push_str(&filter.tags_any.join(","));
let mut lang_buf = crate::input::InputBuffer::new();
if let Some(lang) = filter.lang.as_ref() {
lang_buf.push_str(&lang.0);
}
Self {
field: FilterField::Tags,
tags_buf,
lang_buf,
}
}
pub fn commit_into(&self, filter: &mut DocFilter) {
filter.tags_any = self
.tags_buf
.as_str()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let trimmed = self.lang_buf.as_str().trim();
filter.lang = if trimmed.is_empty() {
None
} else {
Some(Lang(trimmed.to_string()))
};
}
}
/// Render the Library pane. `area` is the full body region;
/// header / footer are owned by the run loop.
pub fn render_library(f: &mut Frame, area: Rect, state: &App) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(filter_overlay_height(state)),
Constraint::Min(1),
])
.split(area);
if let Some(edit) = &state.library.inner.filter_edit {
render_filter_overlay(f, layout[0], edit, &state.theme);
}
render_doc_list(f, layout[1], state);
}
fn filter_overlay_height(state: &App) -> u16 {
if state.library.inner.filter_edit.is_some() {
4
} else {
0
}
}
/// Single source of truth for the filter overlay row labels — used
/// both by `line_with_focus` (display) and the cursor-placement
/// `display_width(...)` math below. Editing one without the other
/// would silently miscolumn the caret.
const LABEL_TAGS: &str = "tags_any (csv): ";
const LABEL_LANG: &str = "lang: ";
fn render_filter_overlay(
f: &mut Frame,
area: Rect,
edit: &FilterEdit,
theme: &crate::theme::Theme,
) {
let block = Block::default()
.title("Filter (Tab=cycle field, Enter=apply, Esc=cancel)")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let lines = vec![
line_with_focus(
LABEL_TAGS,
edit.tags_buf.as_str(),
edit.field == FilterField::Tags,
theme,
),
line_with_focus(
LABEL_LANG,
edit.lang_buf.as_str(),
edit.field == FilterField::Lang,
theme,
),
];
let para = Paragraph::new(lines);
f.render_widget(para, inner);
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
// cursor_position is Some (our case here). When a render fn
// omits set_cursor_position (Library/Inspect main view), ratatui
// calls hide_cursor instead. So this single call positions the
// caret on the focused field of the filter overlay.
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
// the right edge of the inner area.
let (label, focused_buf, row_offset) = match edit.field {
FilterField::Tags => (LABEL_TAGS, &edit.tags_buf, 0u16),
FilterField::Lang => (LABEL_LANG, &edit.lang_buf, 1u16),
};
let label_w = display_width(label);
let cursor_x =
crate::input::place_cursor_x(inner.x, inner.width, label_w, focused_buf.cursor_col());
f.set_cursor_position((cursor_x, inner.y + row_offset));
}
fn line_with_focus<'a>(
label: &'a str,
value: &'a str,
focused: bool,
theme: &crate::theme::Theme,
) -> Line<'a> {
let style = if focused {
theme.style(crate::theme::Role::Selected)
} else {
theme.style(crate::theme::Role::Body)
};
Line::from(vec![Span::raw(label), Span::styled(value, style)])
}
fn render_doc_list(f: &mut Frame, area: Rect, state: &App) {
let inner = &state.library.inner;
let header_text = if inner.loading {
"Library — loading…"
} else if inner.docs.is_empty() {
"Library — no docs (run `kebab ingest` first, then press F5 or re-open)"
} else {
"Library"
};
let block = Block::default().title(header_text).borders(Borders::ALL);
let block_inner = block.inner(area);
f.render_widget(block, area);
if inner.docs.is_empty() {
return;
}
// p9-fb-24: split the inner area into a 1-row column header on top
// and the doc list below. Header reuses the same width math as
// `format_doc_row` so labels line up with their data columns.
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(block_inner);
let header_area = layout[0];
let list_area = layout[1];
let title_w = (list_area.width as usize).saturating_sub(40).max(20);
let header_para = Paragraph::new(format_doc_header(title_w))
.style(state.theme.style(crate::theme::Role::Heading));
f.render_widget(header_para, header_area);
let items: Vec<ListItem> = inner
.docs
.iter()
.map(|d| ListItem::new(format_doc_row(d, title_w)))
.collect();
let list = List::new(items)
.highlight_style(state.theme.style(crate::theme::Role::Selected))
.highlight_symbol("> ");
let mut list_state = inner.list_state.clone();
f.render_stateful_widget(list, list_area, &mut list_state);
}
/// p9-fb-24: render the column-label row that sits directly above
/// the doc list. Uses the same width math as `format_doc_row` so
/// the labels line up with their data columns regardless of Hangul
/// / CJK width drift.
///
/// Layout: `TITLE<title_pad> TAGS<tags_pad> UPDATED CHUNKS`.
/// The title column width matches `area.width.saturating_sub(40).max(20)`
/// — the same calculation `render_doc_list` uses for `title_w`.
pub(crate) fn format_doc_header(title_w: usize) -> Line<'static> {
let title_label = "TITLE";
let tags_label = "TAGS";
let title_pad = title_w.saturating_sub(display_width(title_label));
let tags_pad = TAGS_COL_W.saturating_sub(display_width(tags_label));
let text = format!(
"{title_label}{:title_pad$} {tags_label}{:tags_pad$} {updated:<10} {chunks}",
"",
"",
title_label = title_label,
tags_label = tags_label,
updated = "UPDATED",
chunks = "CHUNKS",
title_pad = title_pad,
tags_pad = tags_pad,
);
Line::from(text)
}
/// Format a `DocSummary` row using display-width-aware truncation
/// and padding. Korean / wide chars contribute 2 columns each.
pub(crate) fn format_doc_row(d: &DocSummary, title_w: usize) -> String {
let title = truncate_to_display_width(&d.title, title_w);
let tags = if d.tags.is_empty() {
"-".to_string()
} else {
d.tags.join(",")
};
let tags = truncate_to_display_width(&tags, TAGS_COL_W);
let updated = d
.updated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "?".to_string());
let updated_short = updated.split('T').next().unwrap_or("?");
// std::fmt's `<width$>` form pads by **char count**, which
// overshoots when the value contains wide chars (each Hangul
// adds 2 cols but counts as 1 char → padding is half-short and
// downstream columns drift). Compute the pad spaces ourselves
// from `display_width`, then concatenate — the truncate above
// already guarantees `display_width(title) <= title_w`.
let title_pad = title_w.saturating_sub(display_width(&title));
let tags_pad = TAGS_COL_W.saturating_sub(display_width(&tags));
format!(
"{title}{:title_pad$} {tags}{:tags_pad$} {updated_short:<10} {chunk_count}",
"",
"",
title = title,
tags = tags,
updated_short = updated_short,
chunk_count = d.chunk_count,
title_pad = title_pad,
tags_pad = tags_pad,
)
}
/// Library pane key dispatch. Mutates `App.library.inner`; never
/// touches another pane's state (parallel-safety contract).
pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome {
if state.error_overlay.is_some() {
// Any key dismisses the popup.
state.error_overlay = None;
return KeyOutcome::Continue;
}
if state.library.inner.filter_edit.is_some() {
return handle_filter_edit_key(state, key);
}
// p9-fb-04: Esc / Ctrl-C while ingest is in flight flips the
// worker's cancel token (instead of triggering the quit path).
// Done BEFORE the `inner` borrow so we can re-borrow `state`.
let is_cancel_chord = match (key.code, key.modifiers) {
(KeyCode::Esc, _) => true,
(KeyCode::Char('c'), m) => m.contains(KeyModifiers::CONTROL),
_ => false,
};
if is_cancel_chord && crate::ingest_progress::cancel_running_ingest(state) {
return KeyOutcome::Continue;
}
let inner = &mut state.library.inner;
let pending_g = std::mem::take(&mut inner.pending_g);
match (key.code, key.modifiers) {
(KeyCode::Char('q') | KeyCode::Esc, _) => {
state.should_quit = true;
KeyOutcome::Quit
}
(KeyCode::Char('j') | KeyCode::Down, _) => {
move_selection(inner, 1);
KeyOutcome::Continue
}
(KeyCode::Char('k') | KeyCode::Up, _) => {
move_selection(inner, -1);
KeyOutcome::Continue
}
(KeyCode::Char('g'), m) if !m.contains(KeyModifiers::SHIFT) => {
if pending_g {
set_selection(inner, 0);
KeyOutcome::Continue
} else {
inner.pending_g = true;
KeyOutcome::Continue
}
}
(KeyCode::Char('G'), _) => {
let last = inner.docs.len().saturating_sub(1);
set_selection(inner, last);
KeyOutcome::Continue
}
(KeyCode::Char('f'), _) => {
inner.filter_edit = Some(FilterEdit::from_filter(&inner.filter));
KeyOutcome::Continue
}
(KeyCode::Char('r'), _) => {
// p9-fb-03: trigger background ingest. The `inner` mutable
// borrow above is not used in this arm, so NLL releases it
// before we re-borrow `state` for `start_ingest`. Errors
// (e.g. "ingest already running") surface via the error
// overlay.
if let Err(e) = crate::ingest_progress::start_ingest(state) {
state.error_overlay = Some(crate::ErrorOverlay::from_anyhow(&e));
}
KeyOutcome::Continue
}
(KeyCode::Char('/'), _) => KeyOutcome::SwitchPane(Pane::Search),
(KeyCode::Char('?'), _) => KeyOutcome::SwitchPane(Pane::Ask),
(KeyCode::Enter, _) => {
if inner.docs.is_empty() {
KeyOutcome::Continue
} else {
let idx = inner.list_state.selected().unwrap_or(0);
// Capture doc_id and exit the `inner` borrow scope
// before re-borrowing `state` for `enter_inspect`.
let doc_id = inner.docs[idx].doc_id.clone();
// NLL releases the `inner` borrow at last use above;
// we can re-borrow `state` mutably for the inspect-side
// mutation below.
let target = crate::app::InspectTarget::Doc(doc_id);
crate::inspect::enter_inspect(state, target, Pane::Library);
KeyOutcome::SwitchPane(Pane::Inspect)
}
}
_ => KeyOutcome::Continue,
}
}
fn handle_filter_edit_key(state: &mut App, key: KeyEvent) -> KeyOutcome {
let Some(edit) = state.library.inner.filter_edit.as_mut() else {
return KeyOutcome::Continue;
};
match key.code {
KeyCode::Esc => {
state.library.inner.filter_edit = None;
KeyOutcome::Continue
}
KeyCode::Tab => {
edit.field = match edit.field {
FilterField::Tags => FilterField::Lang,
FilterField::Lang => FilterField::Tags,
};
KeyOutcome::Continue
}
KeyCode::Enter => {
let edit = state.library.inner.filter_edit.take().unwrap();
edit.commit_into(&mut state.library.inner.filter);
state.library.inner.needs_refresh = true;
KeyOutcome::Refresh
}
KeyCode::Backspace => {
edit.active_buf_mut().pop_char();
KeyOutcome::Continue
}
// p9-fb-22: cursor navigation + Delete inside the active filter
// field. Tab still cycles between Tags / Lang fields; arrows
// only move within the focused buffer.
KeyCode::Left => {
edit.active_buf_mut().move_left();
KeyOutcome::Continue
}
KeyCode::Right => {
edit.active_buf_mut().move_right();
KeyOutcome::Continue
}
KeyCode::Home => {
edit.active_buf_mut().move_home();
KeyOutcome::Continue
}
KeyCode::End => {
edit.active_buf_mut().move_end();
KeyOutcome::Continue
}
KeyCode::Delete => {
edit.active_buf_mut().delete_after();
KeyOutcome::Continue
}
KeyCode::Char(c) => {
edit.active_buf_mut().push_char(c);
KeyOutcome::Continue
}
_ => KeyOutcome::Continue,
}
}
fn move_selection(inner: &mut LibraryStateInner, delta: i32) {
if inner.docs.is_empty() {
return;
}
let current = inner.list_state.selected().unwrap_or(0) as i32;
let last = (inner.docs.len() as i32) - 1;
let next = (current + delta).clamp(0, last);
inner.list_state.select(Some(next as usize));
}
fn set_selection(inner: &mut LibraryStateInner, idx: usize) {
if inner.docs.is_empty() {
inner.list_state.select(None);
} else {
let clamped = idx.min(inner.docs.len() - 1);
inner.list_state.select(Some(clamped));
}
}
/// Run-loop hook: refresh `docs` from the facade. Public-by-crate
/// because the run loop owns the call site.
pub(crate) fn refresh_docs(state: &mut App) -> anyhow::Result<()> {
state.library.inner.loading = true;
let result =
kebab_app::list_docs_with_config(state.config.clone(), state.library.inner.filter.clone());
state.library.inner.loading = false;
match result {
Ok(docs) => {
let prior = state.library.inner.list_state.selected();
state.library.inner.docs = docs;
// Clamp selection.
let len = state.library.inner.docs.len();
if len == 0 {
state.library.inner.list_state.select(None);
} else {
let next = prior.map_or(0, |p| p.min(len - 1));
state.library.inner.list_state.select(Some(next));
}
state.library.inner.needs_refresh = false;
Ok(())
}
Err(e) => {
state.library.inner.needs_refresh = false;
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use kebab_core::{
ChunkerVersion, DocSummary, DocumentId, Lang, ParserVersion, SourceType, TrustLevel,
WorkspacePath,
};
use time::OffsetDateTime;
fn doc(title: &str, tags: &[&str]) -> DocSummary {
DocSummary {
doc_id: DocumentId("a".repeat(32)),
doc_path: WorkspacePath::new("x.md".into()).unwrap(),
title: title.into(),
lang: Lang("en".into()),
tags: tags.iter().map(|s| (*s).into()).collect(),
trust_level: TrustLevel::Primary,
source_type: SourceType::Note,
byte_len: 1,
chunk_count: 1,
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
parser_version: ParserVersion("p".into()),
chunker_version: ChunkerVersion("c".into()),
}
}
/// p9-fb-10: format_doc_row pads by display width (not char
/// count) so wide-char titles don't shift downstream columns.
/// Regression pin — `<title_w$>` (std::fmt char-count form)
/// would fail this for any Hangul title.
#[test]
fn format_doc_row_pads_by_display_width_for_hangul_title() {
let row = format_doc_row(&doc("러스트로 만드는 KB", &["rust"]), 30);
// Expected layout (display cols):
// title 30 + " "(2) + tags 12 + " "(2) + date 10 + " "(2) + chunk
// chunk = "1" → 1 col. Total = 30+2+12+2+10+2+1 = 59.
assert_eq!(
display_width(&row),
59,
"row must align to display columns, not char count: {row:?}"
);
}
/// p9-fb-10: Hangul tag also pads by display width.
#[test]
fn format_doc_row_pads_by_display_width_for_hangul_tag() {
let row = format_doc_row(&doc("ascii", &["한글"]), 20);
// title 20 + " " + tags 12 + " " + date 10 + " " + "1" = 49
assert_eq!(display_width(&row), 49, "row: {row:?}");
}
/// p9-fb-24: column header row uses the same width math as
/// `format_doc_row` so labels line up with their data columns.
/// The TITLE label sits in the title column, TAGS sits in the
/// 12-col TAGS column, UPDATED in the 10-col date column, and
/// CHUNKS at the trailing position.
#[test]
fn format_doc_header_aligns_with_format_doc_row() {
let title_w = 30;
let header = format_doc_header(title_w);
let header_text: String = header.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(header_text.contains("TITLE"), "header has TITLE label");
assert!(header_text.contains("TAGS"), "header has TAGS label");
assert!(header_text.contains("UPDATED"), "header has UPDATED label");
assert!(header_text.contains("CHUNKS"), "header has CHUNKS label");
let row = format_doc_row(&doc("ascii-title", &["rust"]), title_w);
let tags_start_in_row = row.find("rust").expect("row has tags");
let tags_start_in_header = header_text.find("TAGS").expect("header has TAGS");
assert!(
tags_start_in_header <= tags_start_in_row,
"TAGS header drifted past row tags: header={tags_start_in_header} row={tags_start_in_row}"
);
}
}

View File

@@ -1,618 +0,0 @@
//! p9-fb-11: render a markdown string to ratatui `Line`s with the
//! current `Theme`.
//!
//! Scope (per spec p9-fb-11):
//! - inline `**bold**`, `*italic*`, `` `code` `` → `Modifier::*`
//! - inline `[text](url)` → underline + `Role::CitationMarker` color
//! - block heading `#`-`######` → bold + role-graded color
//! - block list (`-` / `*` / `1.`) → indent + bullet glyph
//! - block code fence ```` ``` ```` → indented monospace lines
//! - block table `| col |` → row text, `|` separators preserved
//! - block blockquote `>` → left bar `▎` + dim
//!
//! Streaming: the caller re-renders the full answer text every
//! frame. The Ratatui-side cost is a few µs per kilobyte (pulldown
//! is tokenizer-fast), so re-parse is fine. Incomplete inline spans
//! (e.g. unterminated `**`) emit their literal characters as raw
//! text — `pulldown-cmark` treats them as Text events when no
//! closing marker shows up.
//!
//! Out of scope (per spec): images (terminal can't render them),
//! link click/follow (P+).
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::theme::{Role, Theme};
/// Render a markdown answer into styled `Line`s. Always returns at
/// least one line — a fully-empty input emits a single empty line so
/// callers can scroll / measure without an `is_empty()` guard.
pub fn render(text: &str, theme: &Theme) -> Vec<Line<'static>> {
if text.is_empty() {
return vec![Line::from("")];
}
let mut out: Vec<Line<'static>> = Vec::new();
// Pending Spans for the line currently under construction.
// Flushed into `out` on every Hard/SoftBreak or when a block
// boundary closes the current line.
let mut current: Vec<Span<'static>> = Vec::new();
// Stack of inline modifiers active right now (Strong / Emph
// nest, plus inline Code as a one-off bg/style flag).
let mut style_stack: Vec<Modifier> = Vec::new();
// Heading level overrides the per-Text style with a Role-based
// color until End(Heading) pops it.
let mut heading_role: Option<Role> = None;
// Link target: when set, every Text event inside the link gets
// an underline + CitationMarker color.
let mut in_link: bool = false;
// List depth: 0 = no list, 1+ = nested. Indent grows with depth.
let mut list_depth: usize = 0;
// Ordered-list counter stack (one entry per ordered list level
// we've entered). Always pushed/popped in lockstep with list_depth
// — bullet-list entries push None (rendered as `-`).
let mut list_counters: Vec<Option<u64>> = Vec::new();
// Code-fence body — collected so it can be flushed as a block
// with each line indented + dim-styled.
let mut in_code_block: bool = false;
let mut code_block_buf: String = String::new();
// Blockquote depth: render lines inside with a `▎` prefix.
let mut quote_depth: usize = 0;
let parser = Parser::new_ext(text, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH);
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Heading { level, .. } => {
flush_current(&mut current, &mut out);
heading_role = Some(heading_role_for(level));
}
Tag::Strong => style_stack.push(Modifier::BOLD),
Tag::Emphasis => style_stack.push(Modifier::ITALIC),
Tag::Strikethrough => style_stack.push(Modifier::CROSSED_OUT),
Tag::Link { .. } => in_link = true,
Tag::List(start) => {
flush_current(&mut current, &mut out);
list_depth += 1;
list_counters.push(start);
}
Tag::Item => {
flush_current(&mut current, &mut out);
let indent = " ".repeat(list_depth.saturating_sub(1));
let bullet = match list_counters.last_mut() {
Some(Some(n)) => {
let s = format!("{n}. ");
*n += 1;
s
}
_ => "- ".to_string(),
};
current.push(Span::raw(indent));
current.push(Span::styled(bullet, theme.style(Role::Bullet)));
}
Tag::CodeBlock(kind) => {
flush_current(&mut current, &mut out);
in_code_block = true;
code_block_buf.clear();
// pulldown emits `Tag::CodeBlock` once per fence; the
// language tag (rust, python, …) is informational —
// we don't syntax-highlight in v1, but log it for
// future reference.
if let CodeBlockKind::Fenced(lang) = kind {
if !lang.is_empty() {
tracing::trace!(target: "kebab-tui", %lang, "markdown code fence lang");
}
}
}
Tag::BlockQuote(_) => {
flush_current(&mut current, &mut out);
quote_depth += 1;
}
Tag::Paragraph => {
// No-op on Start: the next Text event will start
// populating `current`. End(Paragraph) flushes.
}
Tag::Table(_) | Tag::TableHead | Tag::TableRow => {
flush_current(&mut current, &mut out);
}
Tag::TableCell => {
// Cell separator. Push `| ` prefix so a row
// renders as `| col1 | col2 |` (markdown-style).
if current.is_empty() {
current.push(Span::styled("| ", theme.style(Role::Bullet)));
} else {
current.push(Span::styled(" | ", theme.style(Role::Bullet)));
}
}
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Heading(_) => {
heading_role = None;
flush_current(&mut current, &mut out);
}
TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough => {
style_stack.pop();
}
TagEnd::Link => in_link = false,
TagEnd::List(_) => {
flush_current(&mut current, &mut out);
list_depth = list_depth.saturating_sub(1);
list_counters.pop();
}
TagEnd::Item => {
flush_current(&mut current, &mut out);
}
TagEnd::CodeBlock => {
flush_code_block(&mut code_block_buf, &mut out, theme);
in_code_block = false;
}
TagEnd::BlockQuote(_) => {
flush_current(&mut current, &mut out);
quote_depth = quote_depth.saturating_sub(1);
}
TagEnd::Paragraph => {
flush_current(&mut current, &mut out);
// Blank line between paragraphs for readability.
out.push(Line::from(""));
}
TagEnd::TableRow | TagEnd::TableHead => {
// Close the row with a trailing `|`.
current.push(Span::styled(" |", theme.style(Role::Bullet)));
flush_current(&mut current, &mut out);
}
TagEnd::Table => {
flush_current(&mut current, &mut out);
out.push(Line::from(""));
}
_ => {}
},
Event::Text(t) => {
if in_code_block {
code_block_buf.push_str(&t);
} else {
let style = compose_style(theme, heading_role, &style_stack, in_link, false);
push_text_with_quote_prefix(
&mut current,
&mut out,
&t,
style,
quote_depth,
theme,
);
}
}
Event::Code(c) => {
let style = compose_style(theme, heading_role, &style_stack, in_link, true);
current.push(Span::styled(c.into_string(), style));
}
Event::SoftBreak | Event::HardBreak => {
flush_current(&mut current, &mut out);
}
Event::Rule => {
flush_current(&mut current, &mut out);
out.push(Line::from(Span::styled(
"".repeat(40),
theme.style(Role::Bullet),
)));
}
Event::Html(h) | Event::InlineHtml(h) => {
// Render raw HTML as text — terminal can't display
// tags. Use Hint role so it visually distinguishes
// from user-written prose.
current.push(Span::styled(h.into_string(), theme.style(Role::Hint)));
}
Event::InlineMath(s) | Event::DisplayMath(s) => {
// No LaTeX rendering in a terminal v1, but preserve
// the source so the answer's math still reaches the
// user as readable text instead of vanishing.
current.push(Span::styled(s.into_string(), theme.style(Role::Hint)));
}
Event::FootnoteReference(label) => {
// Render as `[^label]` so the footnote anchor is
// visible in the answer body.
current.push(Span::styled(
format!("[^{label}]"),
theme.style(Role::CitationMarker),
));
}
Event::TaskListMarker(checked) => {
// GFM task lists — surface as `[x] ` / `[ ] ` so
// checklists stay legible in the answer.
let marker = if checked { "[x] " } else { "[ ] " };
current.push(Span::styled(marker, theme.style(Role::Bullet)));
}
}
}
// Flush any trailing line (e.g. a paragraph not yet closed —
// happens when input ends mid-line during streaming).
flush_current(&mut current, &mut out);
if out.is_empty() {
out.push(Line::from(""));
}
out
}
/// Map an MD heading level to a Role. H1 / H2 use `Heading` (Cyan +
/// BOLD in dark); H3+ degrade to `Title` (White + BOLD) so the
/// hierarchy stays visible without inventing new roles.
fn heading_role_for(level: HeadingLevel) -> Role {
match level {
HeadingLevel::H1 | HeadingLevel::H2 => Role::Heading,
_ => Role::Title,
}
}
/// Compose the active inline style from the heading override (if any),
/// the modifier stack (Strong/Emph/Strikethrough), and the link /
/// inline-code flags.
///
/// Layering rule: the **base color** comes from the most-specific
/// container — heading first, then link, then inline code, then body.
/// **Modifiers** from `style_stack` AND from link/inline-code overlay
/// on top regardless. So `# Section [docs](url) `code``:
/// - `docs` keeps the heading color (Cyan + BOLD) but also gains
/// `UNDERLINED` from the link, signalling "clickable text" without
/// losing the heading's hierarchy color.
/// - `code` keeps the heading color and adds `DIM` from inline-code.
fn compose_style(
theme: &Theme,
heading_role: Option<Role>,
style_stack: &[Modifier],
in_link: bool,
inline_code: bool,
) -> Style {
let base = if let Some(role) = heading_role {
theme.style(role)
} else if in_link {
theme.style(Role::CitationMarker)
} else if inline_code {
// Inline code — represent with Hint (DIM) since Terminal
// doesn't reliably do bg colors without 256-color, and italic
// is taken by Emphasis. Conservative-but-visible cue.
theme.style(Role::Hint)
} else {
theme.style(Role::Body)
};
let mut acc = Modifier::empty();
for m in style_stack {
acc.insert(*m);
}
if in_link {
acc.insert(Modifier::UNDERLINED);
}
if inline_code && heading_role.is_some() {
// Inside a heading, inline code keeps heading color but takes
// the DIM marker so it still reads as code.
acc.insert(Modifier::DIM);
}
base.add_modifier(acc)
}
/// Push a text run into the current line, splitting on any embedded
/// `\n` (pulldown emits these inside paragraphs occasionally). Each
/// new line inherits the blockquote prefix.
fn push_text_with_quote_prefix(
current: &mut Vec<Span<'static>>,
out: &mut Vec<Line<'static>>,
text: &str,
style: Style,
quote_depth: usize,
theme: &Theme,
) {
if quote_depth > 0 && current.is_empty() {
current.push(quote_prefix(quote_depth, theme));
}
let mut first = true;
for chunk in text.split('\n') {
if !first {
flush_current(current, out);
if quote_depth > 0 {
current.push(quote_prefix(quote_depth, theme));
}
}
if !chunk.is_empty() {
current.push(Span::styled(chunk.to_string(), style));
}
first = false;
}
}
/// `▎` glyph repeated for nested quotes, dim-styled.
fn quote_prefix(depth: usize, theme: &Theme) -> Span<'static> {
Span::styled("".repeat(depth) + " ", theme.style(Role::Hint))
}
/// Move `current` into a new `Line` and clear it. No-op when empty.
fn flush_current(current: &mut Vec<Span<'static>>, out: &mut Vec<Line<'static>>) {
if current.is_empty() {
return;
}
let line: Vec<Span<'static>> = std::mem::take(current);
out.push(Line::from(line));
}
/// Flush a captured code-fence body. Each source line becomes one
/// output `Line`, indented ` ` and `Hint`-styled (DIM) so it visually
/// stands apart from prose. A blank line follows the block.
fn flush_code_block(buf: &mut String, out: &mut Vec<Line<'static>>, theme: &Theme) {
if buf.is_empty() {
return;
}
for line in buf.lines() {
out.push(Line::from(Span::styled(
format!(" {line}"),
theme.style(Role::Hint),
)));
}
out.push(Line::from(""));
buf.clear();
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> Theme {
Theme::dark()
}
/// Empty input still produces a single empty line so callers can
/// scroll / measure without an `is_empty()` guard.
#[test]
fn empty_input_returns_one_empty_line() {
let lines = render("", &theme());
assert_eq!(lines.len(), 1);
assert_eq!(line_text(&lines[0]), "");
}
/// Plain text emits one Line with one Span (no styling beyond
/// `Role::Body`'s default).
#[test]
fn plain_text_one_paragraph_one_line() {
let lines = render("hello world", &theme());
// Paragraph end emits a blank line, so 2 lines total: text + blank.
assert_eq!(lines.len(), 2);
assert_eq!(line_text(&lines[0]), "hello world");
assert_eq!(line_text(&lines[1]), "");
}
/// `**bold**` produces a Span with BOLD modifier.
#[test]
fn bold_emits_bold_modifier() {
let lines = render("**hi**", &theme());
let bold_spans: Vec<&Span> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| s.style.add_modifier.contains(Modifier::BOLD))
.collect();
assert!(!bold_spans.is_empty(), "expected at least one BOLD span");
let combined: String = bold_spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(combined, "hi");
}
/// `*italic*` produces a Span with ITALIC modifier.
#[test]
fn italic_emits_italic_modifier() {
let lines = render("*hi*", &theme());
let italic_spans: Vec<&Span> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| s.style.add_modifier.contains(Modifier::ITALIC))
.collect();
assert!(
!italic_spans.is_empty(),
"expected at least one ITALIC span"
);
let combined: String = italic_spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(combined, "hi");
}
/// Inline `` `code` `` emits a span with the inline-code style
/// (DIM in our v1 mapping). Content matches the literal.
#[test]
fn inline_code_emits_styled_span() {
let lines = render("call `frob()` here", &theme());
let code_spans: Vec<&Span> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| s.content.as_ref() == "frob()")
.collect();
assert_eq!(code_spans.len(), 1);
assert!(
code_spans[0].style.add_modifier.contains(Modifier::DIM)
|| code_spans[0].style.fg.is_some()
|| code_spans[0].style.bg.is_some(),
"inline code span carries no style: {:?}",
code_spans[0].style
);
}
/// p9-fb-11 R1: link inside a heading layers — heading color
/// stays (Cyan + BOLD) AND link's UNDERLINE marker is added.
#[test]
fn link_inside_heading_layers_underline_on_heading_color() {
let lines = render("# Section [docs](https://x)", &theme());
let docs = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "docs")
.expect("link text span");
assert!(
docs.style.add_modifier.contains(Modifier::UNDERLINED),
"link inside heading should still get UNDERLINED: {:?}",
docs.style
);
assert!(
docs.style.add_modifier.contains(Modifier::BOLD),
"link inside heading should keep heading BOLD: {:?}",
docs.style
);
}
/// p9-fb-11 R1: math expressions render as text (they used to be
/// silently dropped, losing answer content).
#[test]
fn inline_and_display_math_render_as_text() {
let inline = render("see $E = mc^2$ here", &theme());
let combined: String = inline.iter().map(line_text).collect::<String>();
assert!(
combined.contains("E = mc^2"),
"inline math content dropped: {combined:?}"
);
let display = render("$$\\sum_i x_i$$", &theme());
let combined: String = display.iter().map(line_text).collect::<String>();
assert!(
combined.contains("\\sum_i x_i") || combined.contains("sum_i x_i"),
"display math content dropped: {combined:?}"
);
}
/// p9-fb-11 R1: GFM task lists render as `[ ] ` / `[x] `.
#[test]
fn task_list_renders_checkbox_glyphs() {
let md = "- [ ] todo\n- [x] done";
let lines = render(md, &theme());
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(
texts.iter().any(|t| t.contains("[ ] todo")),
"unchecked task missing: {texts:?}"
);
assert!(
texts.iter().any(|t| t.contains("[x] done")),
"checked task missing: {texts:?}"
);
}
/// `[text](https://x)` underlines `text`.
#[test]
fn link_underlines_text() {
let lines = render("see [docs](https://example.com)", &theme());
let link_span = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "docs")
.expect("link text span present");
assert!(
link_span.style.add_modifier.contains(Modifier::UNDERLINED),
"link span missing UNDERLINE: {:?}",
link_span.style
);
}
/// Heading `# Title` styles the title with the H1 Role::Heading
/// (Cyan + BOLD in dark).
#[test]
fn heading_h1_styles_title() {
let lines = render("# Title here", &theme());
let title_span = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref().contains("Title here"))
.expect("heading text span");
assert!(title_span.style.add_modifier.contains(Modifier::BOLD));
}
/// `- item` emits a bullet glyph + indented item text.
#[test]
fn bullet_list_renders_dash_prefix() {
let lines = render("- first\n- second", &theme());
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(texts.iter().any(|t| t.starts_with("- first")));
assert!(texts.iter().any(|t| t.starts_with("- second")));
}
/// `1.` / `2.` numbered list prefixes with the actual numbers.
#[test]
fn ordered_list_renders_numbered_prefix() {
let lines = render("1. alpha\n2. beta", &theme());
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(texts.iter().any(|t| t.starts_with("1. alpha")));
assert!(texts.iter().any(|t| t.starts_with("2. beta")));
}
/// Code fence body is preserved verbatim per line, indented two
/// spaces.
#[test]
fn code_fence_preserves_body_lines() {
let md = "```rust\nlet x = 1;\nlet y = 2;\n```";
let lines = render(md, &theme());
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(texts.iter().any(|t| t == " let x = 1;"));
assert!(texts.iter().any(|t| t == " let y = 2;"));
}
/// Blockquote `> hi` prefixes the line with `▎`.
#[test]
fn blockquote_renders_left_bar() {
let lines = render("> quoted text", &theme());
let texts: Vec<String> = lines.iter().map(line_text).collect();
assert!(
texts.iter().any(|t| t.starts_with("")),
"no `▎` prefix in: {texts:?}"
);
}
/// 2x2 table renders as `| col | col |` rows. We don't promote to
/// `Table` widget since the answer area uses Paragraph-flow.
#[test]
fn table_renders_pipe_separated_rows() {
let md = "| a | b |\n| - | - |\n| 1 | 2 |";
let lines = render(md, &theme());
let texts: Vec<String> = lines.iter().map(line_text).collect();
// header row + body row, both with `|` separators
assert!(
texts.iter().any(|t| t.contains("| a") && t.contains("b |")),
"header row missing pipes: {texts:?}"
);
assert!(
texts.iter().any(|t| t.contains("| 1") && t.contains("2 |")),
"body row missing pipes: {texts:?}"
);
}
/// Streaming partial: an unterminated `**` MUST NOT drop the
/// content text. pulldown-cmark 0.13 emits the suffix as a Text
/// event (with or without preserving the `**` literal — both are
/// acceptable as long as `still typing` reaches the output).
/// Splitting the assertion: content presence is a hard constraint
/// (regression catches `pulldown` upgrades that lose characters);
/// the literal `**` is cosmetic and not pinned.
#[test]
fn unterminated_bold_does_not_drop_content() {
let lines = render("**still typing", &theme());
let combined: String = lines.iter().map(line_text).collect::<String>();
assert!(
combined.contains("still typing"),
"stream-mid output dropped content text: {combined:?}"
);
}
/// Composite snapshot — heading + paragraph + list + code render
/// in document order without swallowing content.
#[test]
fn composite_snapshot_preserves_document_order() {
let md = "# Goal\n\nDescription **here**.\n\n- alpha\n- beta\n\n```\nlet x = 1;\n```";
let lines = render(md, &theme());
let texts: Vec<String> = lines.iter().map(line_text).collect();
let heading_idx = texts.iter().position(|t| t.contains("Goal")).unwrap();
let para_idx = texts
.iter()
.position(|t| t.contains("Description"))
.unwrap();
let alpha_idx = texts.iter().position(|t| t.contains("alpha")).unwrap();
let code_idx = texts.iter().position(|t| t.contains("let x = 1;")).unwrap();
assert!(heading_idx < para_idx);
assert!(para_idx < alpha_idx);
assert!(alpha_idx < code_idx);
}
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
}

View File

@@ -1,11 +0,0 @@
//! p9-fb-24: page-step constant shared by Ask + Inspect PgUp/PgDn.
//!
//! Fixed `10` rows per page (independent of viewport height). The
//! design doc considered viewport-aware paging but deliberately
//! deferred it — Inspect already shipped with `+/-10`, so unifying
//! on the same constant is the smallest path that closes the
//! "Ask has no PgUp/PgDn" feedback. A future viewport-aware upgrade
//! lives behind this single edit point.
/// Rows scrolled per `PgUp` / `PgDn` keystroke.
pub(crate) const PAGE_STEP: u16 = 10;

View File

@@ -1,718 +0,0 @@
//! Run loop — owns the event poll + render cycle. Pane-specific
//! key handlers are dispatched on focus.
use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use std::time::Duration;
use crate::app::{App, AskState, InspectState, KeyOutcome, Pane, SearchState};
use crate::ask::{drain_stream, handle_key_ask, poll_worker, render_ask};
use crate::error_popup::{ErrorOverlay, render_error_overlay};
use crate::inspect::{handle_key_inspect, refresh_inspect, render_inspect};
use crate::library::{handle_key_library, refresh_docs, render_library};
use crate::search::{debounce_due, fire_search, handle_key_search, refresh_preview, render_search};
use crate::terminal::TuiTerminal;
/// Poll interval for crossterm's `event::poll`. Short enough that a
/// pending data refresh shows up promptly, long enough that an idle
/// app doesn't spin the CPU.
const POLL_INTERVAL: Duration = Duration::from_millis(150);
pub(crate) fn run_loop(app: &mut App) -> Result<()> {
let mut terminal = TuiTerminal::enter()?;
while !app.should_quit {
// p9-fb-03: ingest progress is pane-independent. Drain
// freshly-arrived events every tick + clear the slot a few
// seconds after the run terminated so the user has time to
// read the final line.
crate::ingest_progress::drain_progress(app);
let clear_now = app
.ingest_state
.as_ref()
.is_some_and(crate::ingest_progress::ready_to_clear);
if clear_now {
if let Some(mut state) = app.ingest_state.take() {
// Reap the worker thread now that the user has seen
// the final status line; ignore the join result —
// `IngestReport` was already mirrored into the status
// bar via `Completed { counts }`.
if let Some(handle) = state.thread.take() {
let _ = handle.join();
}
}
// Library may show stale doc list; queue a refresh so the
// next idle tick picks up the just-ingested rows.
app.library.inner.needs_refresh = true;
}
// Per-pane idle work BEFORE rendering so the frame reflects
// freshly-loaded state.
if app.error_overlay.is_none() {
match app.focus {
Pane::Library => {
if app.library.inner.needs_refresh {
if let Err(e) = refresh_docs(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
Pane::Search => {
// p9-fb-08: drain the async search worker first.
// Stale generations are silently dropped; the
// current generation's result populates `hits`
// / clears `searching` here.
crate::search::poll_worker(app);
let due = app.search.as_ref().is_some_and(debounce_due);
if due {
if let Err(e) = fire_search(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
// Lazy preview fetch when selection lacks one.
let needs_preview = app
.search
.as_ref()
.is_some_and(|s| s.preview.is_none() && !s.hits.is_empty());
if needs_preview {
if let Err(e) = refresh_preview(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
Pane::Ask => {
// Token stream + worker completion polled every
// tick so the answer area updates without
// blocking the event loop.
drain_stream(app);
poll_worker(app);
}
Pane::Inspect => {
let due = app.inspect.as_ref().is_some_and(|s| s.needs_fetch);
if due {
if let Err(e) = refresh_inspect(app) {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
_ => {}
}
}
// p9-fb-09: any code path (editor return, future reset
// helper, …) that toggled `force_redraw` gets a fresh
// framebuffer for this draw — without it, residual content
// from before the suspension would layer through Ratatui's
// diff and produce a corrupted-looking screen.
if app.force_redraw {
terminal.inner.clear()?;
app.force_redraw = false;
}
terminal.inner.draw(|f| render_root(f, app))?;
if event::poll(POLL_INTERVAL)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
// p9-fb-37: trace popup eats keys while open.
// Sits ahead of cheatsheet + mode + pane dispatch
// so Esc / j / k / arrows route to the popup
// instead of leaking through to the search pane.
if app.trace_popup.is_some() {
let close = if let Some(popup) = app.trace_popup.as_mut() {
crate::trace_popup::handle_key_trace_popup(popup, key)
} else {
false
};
if close {
app.trace_popup = None;
}
continue;
}
// p9-fb-13: cheatsheet popup toggle takes
// precedence over both mode + pane dispatch.
// F1 toggles open/close. While visible, Esc
// also closes — and the rest of the dispatch
// is skipped so `Esc` doesn't double as
// "Insert→Normal" while the user is reading
// the cheatsheet.
if cheatsheet_intercept(app, key) {
continue;
}
// 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),
Pane::Ask => handle_key_ask(app, key),
Pane::Inspect => handle_key_inspect(app, key),
// p9-5 (Jobs) plugs its handler here when it
// lands. Until then, accepts only `q` / `Esc`.
Pane::Jobs => handle_key_unimplemented_pane(app, key),
};
match outcome {
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());
}
if p == Pane::Ask && app.ask.is_none() {
app.ask = Some(AskState::default());
}
if p == Pane::Inspect && app.inspect.is_none() {
app.inspect = Some(InspectState::default());
}
}
KeyOutcome::Refresh => {
// Library uses needs_refresh; Search uses
// input_dirty_at — pane-specific. The next
// loop iteration's idle pass services it.
}
KeyOutcome::Continue => {}
}
}
_ => {}
}
}
// p9-fb-09: drain any pending external-program request that
// a key handler enqueued. The actual suspend / spawn /
// restore needs the `TuiTerminal` handle, which is only in
// scope here. After return, `force_redraw` is set so the
// next iteration's draw paints from a clean canvas.
if let Some(req) = app.pending_editor.take() {
let result = crate::search::jump_to_citation(
&mut terminal,
&req.citation,
&req.editor_env,
&req.workspace_root,
);
app.force_redraw = true;
if let Err(e) = result {
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
}
}
}
Ok(())
}
/// Stub key handler for panes whose authoring task has not landed
/// yet. `q` / `Esc` returns to Library; everything else is a no-op.
fn handle_key_unimplemented_pane(app: &mut App, key: crossterm::event::KeyEvent) -> KeyOutcome {
use crossterm::event::KeyCode;
if app.error_overlay.is_some() {
app.error_overlay = None;
return KeyOutcome::Continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => KeyOutcome::SwitchPane(Pane::Library),
_ => KeyOutcome::Continue,
}
}
fn render_root(f: &mut Frame, app: &App) {
// p9-fb-24: bottom is always 2 rows — status bar + key hints.
// The pre-fb-24 conditional ingest-status row is gone; the
// ingest progress text now appears in the status bar's dynamic
// slot (see `dynamic_status` priority cascade).
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // top header
Constraint::Min(1), // pane content
Constraint::Length(1), // status bar
Constraint::Length(1), // key hint bar
])
.split(f.area());
render_header(f, outer[0], app);
match app.focus {
Pane::Library => render_library(f, outer[1], app),
Pane::Search => render_search(f, outer[1], app),
Pane::Ask => render_ask(f, outer[1], app),
Pane::Inspect => render_inspect(f, outer[1], app),
Pane::Jobs => render_library(f, outer[1], app),
}
render_status_bar(f, outer[2], app);
render_key_hints(f, outer[3], app);
// p9-fb-37: trace popup overlays on top of pane content but
// below the error overlay (errors are higher-priority modal).
if let Some(popup) = &app.trace_popup {
let popup_area = centered_rect(80, 80, f.area());
crate::trace_popup::render_trace_popup(f, popup_area, popup);
}
if let Some(err) = &app.error_overlay {
render_error_overlay(f, f.area(), err, &app.theme);
}
if app.cheatsheet_visible {
crate::cheatsheet::render_cheatsheet(f, f.area(), app);
}
}
/// p9-fb-37: centered sub-rect helper for the trace popup. Returns
/// a rect of `percent_x` × `percent_y` percent of `r`, centered.
fn centered_rect(
percent_x: u16,
percent_y: u16,
r: ratatui::layout::Rect,
) -> ratatui::layout::Rect {
use ratatui::layout::{Constraint, Direction, Layout};
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let pane_label = match app.focus {
Pane::Library => "Library",
Pane::Search => "Search",
Pane::Ask => "Ask",
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);
}
/// p9-fb-24: separator between status bar fragments. Two spaces +
/// box-drawings light vertical (U+2502) + two spaces. Single source
/// — the docstring of `render_status_bar` references the rendered
/// shape, so any change here MUST update that docstring too.
const STATUS_SEPARATOR: &str = "";
/// p9-fb-24: always-visible status bar. Layout (left → right):
///
/// ```text
/// kebab v0.1.0 │ <pane> │ <docs> docs │ [conv_<8hex>… │ ]<state>
/// ```
///
/// `<state>` is one of `streaming…` / `searching…` / `indexing N/M (P%)` / `idle`,
/// chosen via the priority cascade:
/// 1. Ask streaming → `streaming…`
/// 2. Search worker active → `searching…`
/// 3. Ingest worker active (or terminal-line still on hold) → ingest `status_line`
/// 4. fallback → `idle`
///
/// `<conv_…>` only appears when `app.focus == Ask` AND the pane has
/// either an in-flight question or at least one completed turn — the
/// signal that "this Ask session has context".
pub fn render_status_bar(f: &mut Frame, area: Rect, app: &App) {
let pane_label = match app.focus {
Pane::Library => "Library",
Pane::Search => "Search",
Pane::Ask => "Ask",
Pane::Inspect => "Inspect",
Pane::Jobs => "Jobs",
};
let doc_count = app.library.inner.docs.len();
let dynamic = dynamic_status(app);
let sep = STATUS_SEPARATOR;
let mut line_text = format!(
"kebab v{}{sep}{}{sep}{} docs{sep}",
env!("CARGO_PKG_VERSION"),
pane_label,
doc_count,
);
if let Some(conv) = ask_conv_id_short(app) {
line_text.push_str(&conv);
line_text.push_str(sep);
}
line_text.push_str(&dynamic);
let line = Line::from(Span::styled(
line_text,
app.theme.style(crate::theme::Role::Hint),
));
f.render_widget(Paragraph::new(line), area);
}
/// Priority-cascade dynamic state for the status bar. See
/// `render_status_bar` for the priority order.
fn dynamic_status(app: &App) -> String {
if app.ask.as_ref().is_some_and(|s| s.streaming) {
return "streaming…".to_string();
}
if app.search.as_ref().is_some_and(|s| s.searching) {
return "searching…".to_string();
}
if let Some(state) = app.ingest_state.as_ref() {
return crate::ingest_progress::status_line(state);
}
"idle".to_string()
}
/// Short status for the Ask pane: turn count when there is context.
/// Returns `None` when not in Ask or when no turns / in-flight question.
fn ask_conv_id_short(app: &App) -> Option<String> {
if app.focus != Pane::Ask {
return None;
}
let s = app.ask.as_ref()?;
let has_context = s.current_question.is_some() || !s.turns.is_empty();
if !has_context {
return None;
}
let count = s.turns.len() + usize::from(s.current_question.is_some());
Some(format!("{count} turn(s)"))
}
fn render_key_hints(f: &mut Frame, area: Rect, app: &App) {
let hints = footer_hints(app.focus, app.mode, app.library.inner.filter_edit.is_some());
let line = Line::from(Span::styled(
hints,
app.theme.style(crate::theme::Role::Hint),
));
f.render_widget(
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),
area,
);
}
/// p9-fb-13 follow-up: produce the footer hint text for a given
/// `(focus, mode, filter_open)` tuple. Pure function — extracted so
/// integration tests can pin the verb-form fragments per pane×mode
/// without standing up the full render loop.
///
/// Style contract:
/// - **Verb-form Korean fragments** (e.g. `"위로"` not `"=move"`).
/// The original `key=action` form was English-only and read like
/// a dev cheat-sheet, not user help.
/// - **Mode-aware**: NORMAL shows navigation verbs;
/// INSERT shows typing verbs + `Esc 로 NORMAL 모드` reminder.
/// - **Filter overlay** overrides Library hints — short list of the
/// 3 keys that work inside the overlay.
/// - **Order**: most-frequent verb first; last fragment is always
/// the way back out (`Esc`/`q`).
pub fn footer_hints(focus: Pane, mode: crate::app::Mode, filter_open: bool) -> &'static str {
use crate::app::Mode::{Insert, Normal};
// p9-fb-21: every hint starts with `F1 도움말` so the cheatsheet
// is always one keystroke away — dogfooding feedback was that
// the F1 binding itself was undiscoverable.
match (focus, mode, filter_open) {
// Library filter overlay — same on both modes (overlay
// captures every key, mode label irrelevant).
(Pane::Library, _, true) => "F1 도움말 Tab 필드전환 Enter 적용 Esc 취소",
// Library Normal: full navigation surface.
(Pane::Library, Normal, false) => {
"F1 도움말 ↑/k 위로 ↓/j 아래로 gg 맨위 G 맨아래 f 필터 / 검색 ? 질문 Enter 자세히 r 인덱싱 q 종료"
}
// Library Insert: degenerate — nothing types in Library.
(Pane::Library, Insert, false) => "F1 도움말 Esc 로 NORMAL 모드",
// Search Insert: typing the query is the dominant action.
// `i` becomes a typed char here (intercept only fires in
// Normal mode); `o` is the chunk-inspect command exposed
// via Esc → o (was `i` pre-fb-21).
(Pane::Search, Insert, _) => {
"F1 도움말 타이핑 검색어 Tab 모드전환 Enter 검색 Esc 로 NORMAL 모드 (j/k 이동 o 인스펙트 g 에디터 i 입력모드)"
}
// Search Normal: navigation + commands.
(Pane::Search, Normal, _) => {
"F1 도움말 ↑/k 위로 ↓/j 아래로 Tab 모드전환 Enter 검색 o 인스펙트 g 에디터 i 입력모드 Esc 뒤로"
}
// Ask Insert: typing the question.
(Pane::Ask, Insert, _) => {
"F1 도움말 타이핑 질문 Enter 전송 Esc 로 NORMAL 모드 (e 상세 j/k 스크롤 i 입력모드)"
}
// Ask Normal: scroll + toggle.
(Pane::Ask, Normal, _) => {
"F1 도움말 e 상세설명 ↑/k 위로 ↓/j 아래로 Enter 전송 Ctrl-L 새대화 i 입력모드 Esc 뒤로"
}
// Inspect Normal (default): scroll + collapse.
(Pane::Inspect, Normal, _) => {
"F1 도움말 ↑/k 위로 ↓/j 아래로 PgUp/PgDn 페이지 c 섹션접기 Esc/q 뒤로"
}
// Inspect Insert: degenerate.
(Pane::Inspect, Insert, _) => "F1 도움말 Esc 로 NORMAL 모드",
// Jobs pane: placeholder.
(Pane::Jobs, _, _) => "F1 도움말 Jobs pane 미구현 — q 로 복귀",
}
}
/// 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.
/// - Library/Inspect/Jobs: `i` has no pre-fb-12 meaning, so the
/// intercept is unambiguous.
/// - Search/Ask (p9-fb-21): once the user has pressed `Esc` to
/// leave the auto-Insert state, they need a way back. `i`
/// intercepts here too — the dogfooding feedback was that the
/// Insert→Normal→? loop dead-ended. Search's pre-fb-21 `i` =
/// chunk inspect was rebound to `o` (vim "open") to free `i`
/// for the universal toggle.
/// - 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 crate::app::Mode;
use crossterm::event::{KeyCode, KeyModifiers};
// 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
}
// p9-fb-21: `i` intercepts on every pane in Normal mode.
// Pre-fb-21 this was Library/Inspect/Jobs only; Search/Ask
// had no Normal→Insert key, so once the user pressed Esc
// they were stuck. Search's `i` (chunk inspect) was
// rebound to `o` to free this slot.
(KeyCode::Char('i'), Mode::Normal, _) => {
app.mode = Mode::Insert;
true
}
_ => false,
}
}
/// p9-fb-13: cheatsheet popup interception. Returns `true` when
/// consumed. Rules:
/// - **`F1`** → toggle visibility (open if closed, close if open).
/// Modifier-bearing variants (Ctrl-F1 etc.) are NOT the trigger.
/// - **`Esc` while visible** → close. Returning `true` here means
/// the global `mode_intercept` does NOT also see the Esc, so the
/// user's "close cheatsheet" action stays a single keystroke
/// instead of also flipping mode. **Trade-off**: a user in
/// Insert mode with the cheatsheet open needs a SECOND `Esc` to
/// flip to Normal. Single-effect-per-keystroke wins over
/// compound actions.
/// - Any other key while visible → fall through (so the key reaches
/// the active pane normally — useful if the user wants to keep
/// the popup open and still navigate). The popup auto-closes
/// only via F1 / Esc.
///
/// `pub` so integration tests can drive without standing up the
/// full run loop.
pub fn cheatsheet_intercept(app: &mut crate::app::App, key: crossterm::event::KeyEvent) -> bool {
use crossterm::event::{KeyCode, KeyModifiers};
let plain_or_shift = key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT;
if !plain_or_shift {
return false;
}
match key.code {
KeyCode::F(1) => {
app.cheatsheet_visible = !app.cheatsheet_visible;
true
}
KeyCode::Esc if app.cheatsheet_visible => {
app.cheatsheet_visible = false;
true
}
_ => false,
}
}
#[cfg(test)]
mod footer_hints_tests {
use super::*;
use crate::app::Mode;
/// p9-fb-13 follow-up: Library Normal hint includes nav verbs in
/// Korean and ends with the quit shortcut.
#[test]
fn library_normal_hint_uses_korean_verb_fragments() {
let h = footer_hints(Pane::Library, Mode::Normal, false);
assert!(h.contains("위로"), "expected 위로 verb: {h}");
assert!(h.contains("아래로"), "expected 아래로 verb: {h}");
assert!(h.contains("필터"), "expected 필터 verb: {h}");
assert!(h.ends_with("q 종료"), "expected q 종료 last: {h}");
}
/// p9-fb-13 follow-up: Library filter overlay overrides the
/// usual hint with the 3 keys that actually work in the overlay.
#[test]
fn library_filter_overlay_hint_lists_overlay_keys_only() {
let h = footer_hints(Pane::Library, Mode::Normal, true);
assert_eq!(h, "F1 도움말 Tab 필드전환 Enter 적용 Esc 취소");
}
/// p9-fb-13 follow-up: Insert mode reminds user how to leave —
/// this is the most common confusion point per the dogfooding
/// feedback.
#[test]
fn insert_mode_hint_mentions_esc_to_normal() {
for pane in [Pane::Library, Pane::Search, Pane::Ask, Pane::Inspect] {
let h = footer_hints(pane, Mode::Insert, false);
assert!(
h.contains("Esc") && h.contains("NORMAL"),
"{pane:?} insert hint must mention Esc + NORMAL: {h}"
);
}
}
/// p9-fb-13 follow-up: Search Insert hint leads with the typing
/// verb (the dominant action) and lists the NORMAL-only commands
/// in parentheses so the user knows they're gated.
#[test]
fn search_insert_hint_leads_with_typing_verb() {
let h = footer_hints(Pane::Search, Mode::Insert, false);
// p9-fb-21: every hint now leads with `F1 도움말`. The
// "typing verb" (`타이핑 검색어`) follows immediately so it's
// still the dominant action visually.
assert!(
h.starts_with("F1 도움말"),
"should lead with F1 도움말: {h}"
);
assert!(h.contains("타이핑 검색어"), "expected 타이핑 검색어: {h}");
assert!(h.contains("Tab 모드전환"), "expected Tab 모드전환: {h}");
assert!(h.contains("Enter 검색"), "expected Enter 검색: {h}");
}
/// p9-fb-13 follow-up: Ask Insert hint leads with typing.
#[test]
fn ask_insert_hint_leads_with_typing_verb() {
let h = footer_hints(Pane::Ask, Mode::Insert, false);
// p9-fb-21: F1 prefix now leads; typing verb is second.
assert!(
h.starts_with("F1 도움말"),
"should lead with F1 도움말: {h}"
);
assert!(h.contains("타이핑 질문"), "expected 타이핑 질문: {h}");
assert!(h.contains("Enter 전송"), "expected Enter 전송: {h}");
}
/// p9-fb-13 follow-up: Inspect Normal hint covers scroll +
/// collapse + back-out.
#[test]
fn inspect_normal_hint_covers_scroll_collapse_back() {
let h = footer_hints(Pane::Inspect, Mode::Normal, false);
assert!(h.contains("위로"), "expected 위로 verb: {h}");
assert!(h.contains("페이지"), "expected 페이지 verb: {h}");
assert!(h.contains("섹션접기"), "expected 섹션접기 verb: {h}");
assert!(h.contains("뒤로"), "expected 뒤로 verb: {h}");
}
/// p9-fb-21: Search Normal hint enables o/g as commands (i is
/// now the universal Insert toggle, not chunk-inspect).
#[test]
fn search_normal_hint_lists_commands_directly() {
let h = footer_hints(Pane::Search, Mode::Normal, false);
assert!(h.contains("위로"), "expected 위로 verb: {h}");
assert!(h.contains("Tab 모드전환"), "expected Tab 모드전환: {h}");
assert!(h.contains("o 인스펙트"), "expected o 인스펙트: {h}");
assert!(h.contains("g 에디터"), "expected g 에디터: {h}");
assert!(h.contains("i 입력모드"), "expected i 입력모드: {h}");
}
/// p9-fb-21: every footer hint starts with `F1 도움말` so the
/// cheatsheet binding is always discoverable. Pre-fb-21 it was
/// invisible until the user already knew about it.
#[test]
fn every_hint_starts_with_f1_help_prefix() {
for pane in [
Pane::Library,
Pane::Search,
Pane::Ask,
Pane::Inspect,
Pane::Jobs,
] {
for mode in [Mode::Normal, Mode::Insert] {
for filter_open in [false, true] {
let h = footer_hints(pane, mode, filter_open);
assert!(
h.starts_with("F1 도움말"),
"{pane:?}/{mode:?}/filter={filter_open} missing F1 prefix: {h}"
);
}
}
}
}
/// p9-fb-21: Search/Ask Normal hints advertise `i` as the
/// Insert toggle. Pre-fb-21 these panes had no Normal→Insert
/// key documented and the user was dead-ended.
#[test]
fn search_ask_normal_hint_advertises_i_insert_toggle() {
for pane in [Pane::Search, Pane::Ask] {
let h = footer_hints(pane, Mode::Normal, false);
assert!(
h.contains("i 입력모드"),
"{pane:?} Normal hint missing i 입력모드: {h}"
);
}
}
/// p9-fb-13 follow-up: every (pane, mode, filter_open) tuple
/// returns a non-empty hint — exhaustive sanity that the match
/// covers every arm.
#[test]
fn every_pane_mode_combo_returns_non_empty_hint() {
for pane in [
Pane::Library,
Pane::Search,
Pane::Ask,
Pane::Inspect,
Pane::Jobs,
] {
for mode in [Mode::Normal, Mode::Insert] {
for filter_open in [false, true] {
let h = footer_hints(pane, mode, filter_open);
assert!(
!h.is_empty(),
"{pane:?}/{mode:?}/filter={filter_open} empty"
);
}
}
}
}
}

View File

@@ -1,728 +0,0 @@
//! Search pane (P9-2).
//!
//! `App.search` slot is filled lazily by the run loop on first
//! `Pane::Search` switch. `handle_key_search` mutates only
//! `app.search` (parallel-safety contract from p9-1) — never touches
//! Library / Ask / Inspect state.
//!
//! Spec deviation (HOTFIXES `2026-05-02 P9-2`):
//! - `render_search<B: Backend>` generic dropped (ratatui 0.28 Frame
//! is backend-agnostic, same as P9-1).
//! - `jump_to_citation` gained a `workspace_root: &Path` argument
//! missing from spec literal — citations carry workspace-relative
//! paths and the editor needs an absolute path to open.
//!
//! Per design §1.5 / §1.6 (search output dense format), §3.7
//! (`SearchHit`), §0 Q3 (citation URI fragments).
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{Citation, SearchHit, SearchMode, SearchQuery};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::app::{App, KeyOutcome, Pane, SearchState};
/// Debounce window after the last keystroke before re-searching.
/// Matches the spec's 200 ms.
pub const SEARCH_DEBOUNCE: Duration = Duration::from_millis(200);
/// Maximum hits to fetch per query — matches `config.search.default_k`
/// in production but the trait does not expose `Config`, so we cap
/// here. Users running deep recall should `kebab search --json` for
/// large `k`.
const SEARCH_K: usize = 10;
/// Render the Search pane: input bar (top), result list (middle),
/// preview (bottom). Each result row uses §1.5's 4-line dense format.
pub fn render_search(f: &mut Frame, area: Rect, state: &App) {
let Some(s) = state.search.as_ref() else {
// Pane has no state yet — should not happen because the run
// loop lazy-inits before render. Defensive empty block.
f.render_widget(Block::default().title("Search").borders(Borders::ALL), area);
return;
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(7),
])
.split(area);
render_input_bar(f, layout[0], s, &state.theme);
render_result_list(f, layout[1], s, &state.theme);
render_preview(f, layout[2], s, &state.theme);
}
fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) {
let mode_label = mode_label(s.mode);
let mode_role = match s.mode {
SearchMode::Lexical => crate::theme::Role::ModeLexical,
SearchMode::Vector => crate::theme::Role::ModeVector,
SearchMode::Hybrid => crate::theme::Role::ModeHybrid,
};
let searching_hint = if s.searching { " searching…" } else { "" };
// p9-fb-10: compute prompt display width before moving the String
// into the Span so we can place the cursor without a second alloc.
let prompt = format!("[{mode_label}] ");
let prompt_w = crate::input::display_width(&prompt);
let line = Line::from(vec![
Span::styled(prompt, theme.style(mode_role)),
Span::raw(s.input.as_str()),
Span::styled(searching_hint, theme.style(crate::theme::Role::Hint)),
]);
let block = Block::default()
.title("query (Tab=mode Enter=search Esc=back)")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(Paragraph::new(line).block(block), area);
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
// cursor_position is Some (our case here). When a render fn
// omits set_cursor_position (Library/Inspect), ratatui calls
// hide_cursor instead. So this single call both positions and
// unhides the caret for the Search input column.
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
// the right edge of the inner area.
let cursor_x =
crate::input::place_cursor_x(inner.x, inner.width, prompt_w, s.input.cursor_col());
f.set_cursor_position((cursor_x, inner.y));
}
fn mode_label(m: SearchMode) -> &'static str {
match m {
SearchMode::Lexical => "lexical",
SearchMode::Vector => "vector",
SearchMode::Hybrid => "hybrid",
}
}
fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) {
let block = Block::default()
.title(format!("results ({})", s.hits.len()))
.borders(Borders::ALL);
if s.hits.is_empty() {
f.render_widget(block, area);
return;
}
let items: Vec<ListItem> = s
.hits
.iter()
.map(|h| ListItem::new(format_hit_lines(h, theme)))
.collect();
let list = List::new(items)
.block(block)
.highlight_style(theme.style(crate::theme::Role::Selected))
.highlight_symbol("> ");
let mut list_state = ListState::default();
list_state.select(Some(s.selected_hit.min(s.hits.len().saturating_sub(1))));
f.render_stateful_widget(list, area, &mut list_state);
}
/// §1.5 dense format — 4 lines per hit:
/// 1. `<rank>. <fusion_score> [STALE]?<path#frag>`
/// 2. `<heading_path joined by " / "> | section_label?`
/// 3. snippet line 1
/// 4. snippet line 2 (or trailing blank for layout symmetry)
///
/// p9-fb-32: when `h.stale == true` the rank/score header line is
/// preceded by a Warning-styled `[STALE] ` Span — text + color so a
/// monochrome reader still gets the signal (fb-14 accessibility note).
fn format_hit_lines(h: &SearchHit, theme: &crate::theme::Theme) -> Vec<Line<'static>> {
let header = format!(
"{}. {:.4} {}",
h.rank,
h.retrieval.fusion_score,
h.citation.to_uri(),
);
let path_line = {
let hp = if h.heading_path.is_empty() {
String::from("-")
} else {
h.heading_path.join(" / ")
};
match h.section_label.as_deref() {
Some(s) if !s.is_empty() => format!(" {hp} | {s}"),
_ => format!(" {hp}"),
}
};
let mut snippet_lines = h.snippet.lines();
let s1 = snippet_lines.next().unwrap_or("").to_string();
let s2 = snippet_lines.next().unwrap_or("").to_string();
let header_line = if h.stale {
Line::from(vec![
Span::styled("[STALE] ", theme.style(crate::theme::Role::Warning)),
Span::styled(header, theme.style(crate::theme::Role::Title)),
])
} else {
Line::from(Span::styled(header, theme.style(crate::theme::Role::Title)))
};
vec![
header_line,
Line::from(Span::styled(
path_line,
theme.style(crate::theme::Role::Path),
)),
Line::from(format!(" {s1}")),
Line::from(format!(" {s2}")),
]
}
fn render_preview(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::theme::Theme) {
let block = Block::default()
.title("preview (g=open in $EDITOR)")
.borders(Borders::ALL);
let body = match (&s.preview, s.hits.is_empty()) {
(_, true) => Paragraph::new(""),
(Some(text), _) => Paragraph::new(text.as_str()).wrap(Wrap { trim: false }),
(None, _) => Paragraph::new(Span::styled(
"(loading preview… select a hit to fetch its chunk text)",
theme.style(crate::theme::Role::Hint),
)),
};
f.render_widget(body.block(block), area);
}
/// Search pane key dispatch. Returns `KeyOutcome::Refresh` when the
/// run loop should re-fire `kebab-app::search`. Pure mutation on
/// `app.search` — never touches another pane's state.
pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
if state.error_overlay.is_some() {
state.error_overlay = None;
return KeyOutcome::Continue;
}
if state.search.is_none() {
// No search state — bail back to Library.
return KeyOutcome::SwitchPane(Pane::Library);
}
// 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;
// p9-fb-37: `t` opens the trace popup. Re-runs the last submitted
// query with SearchOpts.trace = true. Bypasses cache by going
// through `search_with_opts_with_config` (Task 5 wires opts.trace
// to skip the LRU cache).
if is_normal
&& matches!(
(key.code, key.modifiers),
(KeyCode::Char('t'), KeyModifiers::NONE)
)
{
let (last_query, has_results) = {
let s = state.search.as_ref().unwrap();
(s.last_query.clone(), !s.hits.is_empty())
};
if !has_results {
return KeyOutcome::Continue;
}
if let Some((q_text, q_mode)) = last_query {
// TODO: thread filters when TUI gains a filter UI (currently
// mirrors fire_search which also passes default filters).
let q = kebab_core::SearchQuery {
text: q_text,
mode: q_mode,
k: state.config.search.default_k,
filters: kebab_core::SearchFilters::default(),
};
let opts = kebab_core::SearchOpts {
trace: true,
..Default::default()
};
if let Ok(resp) = kebab_app::search_with_opts_with_config(state.config.clone(), q, opts)
{
if let Some(t) = resp.trace {
state.trace_popup = Some(crate::trace_popup::TracePopupState::new(t));
}
} else {
// Silent failure — trace is debug-only; user
// can still see search hits without it.
}
}
return KeyOutcome::Continue;
}
// p9-fb-21: chunk-inspect rebound from `i` to `o` (vim "open").
// The `i` key is now the universal Normal→Insert toggle (handled
// in `mode_intercept`), so it cannot also mean "inspect chunk"
// here. `o` is unused elsewhere on this pane and matches the vim
// mnemonic "open" — we're opening the selected chunk in Inspect.
if is_normal
&& matches!(
(key.code, key.modifiers),
(KeyCode::Char('o'), KeyModifiers::NONE)
)
{
let chunk_id = {
let s = state.search.as_ref().unwrap();
if s.hits.is_empty() {
None
} else {
Some(s.hits[s.selected_hit].chunk_id.clone())
}
};
if let Some(chunk_id) = chunk_id {
crate::inspect::enter_inspect(
state,
crate::app::InspectTarget::Chunk(chunk_id),
Pane::Search,
);
return KeyOutcome::SwitchPane(Pane::Inspect);
}
return KeyOutcome::Continue;
}
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() {
(None, false)
} else {
(Some(s.hits[s.selected_hit].citation.clone()), true)
}
};
if has_hits {
// p9-fb-09: enqueue the spawn for the run loop. Calling
// `jump_to_citation` directly here would not have access
// to the TuiTerminal handle, so the post-resume
// `terminal.clear()` couldn't happen — leaving the
// previous frame leaking through the new draw.
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
// [[workspace.sources]]: resolve the primary workspace root
// (first source / legacy `root`). `resolve_workspace_root` applies
// the same `~` / `${XDG_…}` / relative-to-config expansion as the
// markdown / image / PDF ingest paths (HOTFIXES 2026-05-02 P9-4).
let workspace_root = state.config.resolve_workspace_root();
state.pending_editor = Some(crate::app::EditorRequest {
citation: citation.unwrap(),
editor_env: editor,
workspace_root,
});
}
return KeyOutcome::Continue;
}
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, _) => {
s.mode = cycle_mode(s.mode);
// Force re-search at the new mode if there's a query.
if !s.input.as_str().trim().is_empty() {
mark_input_changed(s);
}
KeyOutcome::Continue
}
(KeyCode::Enter, _) => {
// Skip debounce; refresh now if there's anything to query.
if s.input.as_str().trim().is_empty() {
KeyOutcome::Continue
} else {
s.input_dirty_at = None;
s.last_query = None;
KeyOutcome::Refresh
}
}
(KeyCode::Down, _) => {
move_selection(s, 1);
s.preview = None;
KeyOutcome::Continue
}
(KeyCode::Up, _) => {
move_selection(s, -1);
s.preview = None;
KeyOutcome::Continue
}
(KeyCode::Backspace, _) => {
if !s.input.is_empty() {
s.input.pop_char();
mark_input_changed(s);
}
KeyOutcome::Continue
}
// p9-fb-22: cursor navigation + Delete inside the query
// input. Up/Down are reserved for hit list navigation, so
// Left/Right are the only horizontal keys; Home/End jump to
// the ends. None of these mark the input dirty (the query
// string is unchanged), so the debounce timer does not
// restart on a pure cursor move.
(KeyCode::Left, _) => {
s.input.move_left();
KeyOutcome::Continue
}
(KeyCode::Right, _) => {
s.input.move_right();
KeyOutcome::Continue
}
(KeyCode::Home, _) => {
s.input.move_home();
KeyOutcome::Continue
}
(KeyCode::End, _) => {
s.input.move_end();
KeyOutcome::Continue
}
(KeyCode::Delete, _) => {
if s.input.delete_after().is_some() {
mark_input_changed(s);
}
KeyOutcome::Continue
}
// p9-fb-12 follow-up: Char dispatch is mode-gated. Normal
// mode → j/k navigate; Insert mode → typed into input.
// Single arm per key, body branches on mode (clearer than
// duplicate-arm + guard).
(KeyCode::Char('j'), KeyModifiers::NONE) => {
if is_normal {
move_selection(s, 1);
s.preview = None;
} else {
s.input.push_char('j');
mark_input_changed(s);
}
KeyOutcome::Continue
}
(KeyCode::Char('k'), KeyModifiers::NONE) => {
if is_normal {
move_selection(s, -1);
s.preview = None;
} else {
s.input.push_char('k');
mark_input_changed(s);
}
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_char(c);
mark_input_changed(s);
KeyOutcome::Continue
}
// Normal mode + un-handled Char → no-op (no typing in
// Normal). Modifier chords always no-op.
_ => KeyOutcome::Continue,
}
}
/// v0.17.0 A5 Step 5: every input-mutation site in `handle_key_search`
/// funnels through this helper so the debounce stamp and the
/// short-query advisory stay in sync. Reset is eager — the stale
/// advisory from the previous result set must not visually overlap
/// with a fresh typing session.
fn mark_input_changed(s: &mut crate::app::SearchState) {
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
}
fn cycle_mode(m: SearchMode) -> SearchMode {
match m {
SearchMode::Lexical => SearchMode::Vector,
SearchMode::Vector => SearchMode::Hybrid,
SearchMode::Hybrid => SearchMode::Lexical,
}
}
fn move_selection(s: &mut SearchState, delta: i32) {
if s.hits.is_empty() {
return;
}
let current = s.selected_hit as i32;
let last = (s.hits.len() as i32) - 1;
let next = (current + delta).clamp(0, last);
s.selected_hit = next as usize;
}
/// Build the editor command for a citation. Splits out from
/// `jump_to_citation` so unit tests can assert command shape without
/// spawning a process.
///
/// Returns `(program, args)` where `program` is the `$EDITOR` value
/// (or `vi` fallback) and `args` opens the file at the cited line /
/// page / region (best-effort for non-text citations).
pub fn build_jump_command(
citation: &Citation,
editor_env: &str,
workspace_root: &Path,
) -> (String, Vec<String>) {
let (program, leading_args) = parse_editor_env(editor_env);
let path = workspace_root.join(&citation.path().0);
let path_str = path.to_string_lossy().into_owned();
let mut args = leading_args;
let editor_basename = std::path::Path::new(&program)
.file_name()
.map_or_else(|| program.clone(), |s| s.to_string_lossy().into_owned());
match citation {
Citation::Line { start, .. } => {
if editor_basename.contains("code") || editor_basename.contains("cursor") {
// VS Code / Cursor: `code -g <path>:<line>`
args.push("-g".into());
args.push(format!("{path_str}:{start}"));
} else {
// vim / nvim / vi / emacs / hx all accept `+<N>`.
args.push(format!("+{start}"));
args.push(path_str);
}
}
Citation::Page { page, .. } => {
// No standard editor jump for PDFs across vim / VS Code /
// emacs. Earlier versions of this branch tried to push a
// `# page N` string as a final arg, but every common
// editor treats it as a *second file to open* — opening
// a stray buffer or splitting the window. Path-only is
// the honest best-effort: the user's PDF reader (or the
// editor's PDF plugin) handles in-document navigation.
// A `KEBAB_EDITOR_JUMP_FORMAT="pdf=evince -p {page} {path}"`
// env hook stays a P+ enhancement (per spec § Risks).
tracing::debug!(
target: "kebab-tui",
page,
"PDF citation — opening file only; editor page-jump unsupported"
);
args.push(path_str);
}
_ => {
args.push(path_str);
}
}
(program, args)
}
/// Suspend the TUI, spawn `$EDITOR`, restore the TUI on return.
///
/// p9-fb-09: delegates the suspend/restore dance to
/// [`crate::editor::with_external_program`] so the post-resume
/// `terminal.clear()` lands consistently — without it, the previous
/// frame leaked through the new draw and the user saw a corrupted
/// screen on return (도그푸딩 item 7).
///
/// Errors propagate; the helper's RAII guard restores the terminal
/// even on panic.
pub(crate) fn jump_to_citation(
terminal: &mut crate::terminal::TuiTerminal,
citation: &Citation,
editor_env: &str,
workspace_root: &Path,
) -> anyhow::Result<()> {
let (program, args) = build_jump_command(citation, editor_env, workspace_root);
let mut cmd = Command::new(&program);
cmd.args(&args);
let status = crate::editor::with_external_program(terminal, cmd)?;
if !status.success() {
anyhow::bail!("{program} exited with {status:?}");
}
Ok(())
}
fn parse_editor_env(env: &str) -> (String, Vec<String>) {
// `$EDITOR` may carry args, e.g. `vim -p`. Split on whitespace.
let mut parts = env.split_whitespace();
let program = parts.next().unwrap_or("vi").to_string();
let leading: Vec<String> = parts.map(str::to_string).collect();
(program, leading)
}
/// Run-loop hook: tick called every poll cycle. Returns `true` if a
/// search should fire this tick (debounce expired and query
/// changed). p9-fb-08 adds two skip cases:
/// - if a worker is already in flight for the *same* `(input, mode)`
/// the spawn is redundant — wait for the result.
/// - dedupe against `last_query` (was already there pre-fb-08, kept).
pub fn debounce_due(s: &SearchState) -> bool {
let Some(at) = s.input_dirty_at else {
return false;
};
let elapsed = (time::OffsetDateTime::now_utc() - at)
.try_into()
.unwrap_or(Duration::ZERO);
if elapsed < SEARCH_DEBOUNCE {
return false;
}
let q = s.input.as_str().trim();
if q.is_empty() {
return false;
}
// p9-fb-08: if the most-recent in-flight query is identical to
// the current input/mode pair, don't spawn another worker — the
// existing result will land via `poll_worker`.
if s.searching {
if let Some((prev_input, prev_mode)) = &s.last_query {
if prev_input.as_str() == s.input.as_str() && *prev_mode == s.mode {
return false;
}
}
}
!matches!(
&s.last_query,
Some((prev_input, prev_mode))
if prev_input.as_str() == s.input.as_str() && *prev_mode == s.mode
)
}
/// Run-loop hook: spawn an asynchronous search worker. Returns
/// immediately so the event loop keeps polling — the result lands in
/// `state.search.worker_rx` and is applied by `poll_worker` on a
/// later tick. p9-fb-08 deviation from the original synchronous
/// design (the user typed faster than vector search could complete,
/// freezing the UI for 50-200 ms per keystroke under hybrid mode).
///
/// Behavior:
/// 1. Increment `generation` so any in-flight result becomes stale
/// on receive (`poll_worker` drops it).
/// 2. Drop the prior `worker_rx` (the old worker keeps running and
/// its result is silently discarded — search is a pure read with
/// no cleanup obligation).
/// 3. Snapshot `last_query` + clear `input_dirty_at` for the
/// debounce machinery (so a no-op keystroke doesn't re-spawn).
/// 4. Spawn a fresh worker carrying its generation token.
pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> {
let cfg = state.config.clone();
let (q_text, mode, generation) = {
let s = state.search.as_mut().expect("Search slot must exist");
s.generation = s.generation.wrapping_add(1);
s.searching = true;
s.input_dirty_at = None;
let q_text = s.input.as_str().to_string();
s.last_query = Some((q_text.clone(), s.mode));
(q_text, s.mode, s.generation)
};
let (tx, rx) = std::sync::mpsc::channel();
// Fire-and-forget — `JoinHandle` is dropped immediately so the
// OS detaches the thread. Search is a pure read with no
// cleanup obligation; if the receiver is replaced (next
// keystroke spawns a fresh worker), the old worker's
// `tx.send` no-ops and it exits silently.
std::thread::Builder::new()
.name(format!("kebab-tui-search-gen{generation}"))
.spawn(move || {
let query = SearchQuery {
text: q_text,
mode,
k: SEARCH_K,
filters: kebab_core::SearchFilters::default(),
};
let result = kebab_app::search_with_config(cfg, query);
let _ = tx.send(crate::app::SearchWorkerMessage::Done { generation, result });
})
.map_err(|e| anyhow::anyhow!("spawn search worker: {e}"))?;
let s = state.search.as_mut().expect("Search slot must exist");
s.worker_rx = Some(rx);
Ok(())
}
/// Run-loop hook: drain any pending message from the search worker.
/// Stale results (newer query already in flight) are silently
/// dropped per the generation-counter contract. `pub` so integration
/// tests can drive the stale-result paths by injecting a channel.
pub fn poll_worker(state: &mut App) {
let Some(s) = state.search.as_mut() else {
return;
};
let Some(rx) = s.worker_rx.as_ref() else {
return;
};
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// Worker panicked or dropped tx without sending. Clear
// the rx + searching flag so the next debounce tick can
// re-fire if needed.
s.worker_rx = None;
s.searching = false;
return;
}
};
s.worker_rx = None;
match msg {
crate::app::SearchWorkerMessage::Done { generation, result } => {
// p9-fb-08: stale guard. The user kept typing after this
// worker spawned and a newer query is in flight — drop
// the result. Don't clear `searching` because the newer
// worker (if any) is still running; if there's no newer
// worker (rare race), the next debounce_due tick will
// re-fire `fire_search` and reset everything.
if generation != s.generation {
tracing::debug!(
target: "kebab-tui",
stale_gen = generation,
current_gen = s.generation,
"dropping stale search result"
);
return;
}
s.searching = false;
match result {
Ok(hits) => {
// v0.17.0 A5 Step 5: stale-aware short-query hint.
// The worker carries no copy of the query text;
// we ground the advisory on `s.last_query` which
// was snapshotted at `fire_search` time and (by
// the generation guard above) still matches what
// the user submitted for *this* result set. If
// input has drifted since spawn, the gen-check
// already returned early.
s.hits = hits;
s.selected_hit = 0;
s.preview = None;
}
Err(e) => {
s.hits.clear();
s.selected_hit = 0;
state.error_overlay = Some(crate::error_popup::ErrorOverlay::from_anyhow(&e));
}
}
}
}
}
/// Run-loop hook: lazy-fetch preview text for the selected hit.
pub(crate) fn refresh_preview(state: &mut App) -> anyhow::Result<()> {
let cfg = state.config.clone();
let chunk_id = {
let s = state.search.as_ref().expect("Search slot must exist");
if s.preview.is_some() || s.hits.is_empty() {
return Ok(());
}
let Some(hit) = s.hits.get(s.selected_hit) else {
return Ok(());
};
hit.chunk_id.clone()
};
let chunk = kebab_app::inspect_chunk_with_config(cfg, &chunk_id)?;
let s = state.search.as_mut().expect("Search slot must exist");
s.preview = Some(chunk.text);
Ok(())
}

View File

@@ -1,37 +0,0 @@
//! Terminal raw-mode / alternate-screen lifecycle. Critical: the
//! `Drop` impl must restore the terminal even if the run loop panics
//! — otherwise the user is left with a corrupted shell.
use anyhow::{Context, Result};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use std::io::{Stdout, stdout};
pub(crate) struct TuiTerminal {
pub inner: Terminal<CrosstermBackend<Stdout>>,
}
impl TuiTerminal {
pub fn enter() -> Result<Self> {
enable_raw_mode().context("crossterm: enable_raw_mode")?;
let mut out = stdout();
execute!(out, EnterAlternateScreen).context("crossterm: EnterAlternateScreen")?;
let backend = CrosstermBackend::new(stdout());
let inner = Terminal::new(backend).context("ratatui Terminal::new")?;
Ok(Self { inner })
}
}
impl Drop for TuiTerminal {
fn drop(&mut self) {
// Best-effort. Errors here would clobber a real panic if we
// propagated them; just log and let the OS recover any
// remaining noise.
let _ = disable_raw_mode();
let _ = execute!(stdout(), LeaveAlternateScreen);
}
}

View File

@@ -1,279 +0,0 @@
//! p9-fb-14: TUI palette + role-style mapping.
//!
//! Every pane (`library`, `search`, `ask`, `inspect`, `error_popup`,
//! the `run::render_root` shell) routes its `ratatui::style::Style`
//! through `Theme::style(role)` instead of inlining
//! `Style::default().fg(...)`. Adding a new role here is the only
//! place a color decision needs to land — accidental drift between
//! panes (`Cyan` for one badge, `LightCyan` for another) becomes a
//! single-file diff.
//!
//! ## Why role-based, not "style table"
//!
//! Earlier sketches keyed a hashmap by role at runtime. A `match`
//! against an enum is faster (no allocation, no hashing), exhaustive
//! at compile time (forgetting a role for `Theme::light` is a
//! compile error if you `match` exhaustively on `Role` in the
//! palette body — and we do), and lets `Theme::style` return
//! `Style` by value without lifetimes.
//!
//! ## Accessibility
//!
//! Color is never the *only* signal — the score badge ships
//! `[score=0.92]` text alongside its color, the mode badge ships
//! `[Hybrid]` text, the refusal renders the literal `(refused)`
//! prefix. The theme just amplifies signals that the text already
//! carries.
use ratatui::style::{Color, Modifier, Style};
/// Role-style enumeration. Adding a variant requires updating both
/// `dark_style` and `light_style` (the compiler enforces it via the
/// exhaustive `match` in each palette).
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum Role {
/// Active pane border (the focused one).
BorderActive,
/// Inactive pane border.
BorderInactive,
/// Document/section title (bold, accent color).
Title,
/// Secondary path / subpath (dim).
Path,
/// Lexical search-mode badge.
ModeLexical,
/// Vector search-mode badge.
ModeVector,
/// Hybrid search-mode badge.
ModeHybrid,
/// Selected row in any list (search hits, library docs, …).
Selected,
/// Dim hint / placeholder text (mode line subtext, "loading…").
Hint,
/// Section heading (bold + accent — Inspect uses this).
Heading,
/// Warning yellow — refusals, malformed-frontmatter notices.
Warning,
/// Error red — error overlays, "spawn failed" lines.
Error,
/// Success green — completed ingest, grounded answer.
Success,
/// Citation marker (`[1]`, `[2]`) and citation link text.
CitationMarker,
/// Bullet glyph in list rendering.
Bullet,
/// Default body text (no decoration). Returned as
/// `Style::default()` in both palettes — kept as a Role so
/// callers don't sprinkle `Style::default()` directly.
Body,
}
/// Palette identity. `Theme` carries this so panes can branch on
/// "is dark" if they need a different glyph (rarely needed since
/// roles already abstract the color), but in practice the
/// `Theme::style` dispatcher is the only consumer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Palette {
Dark,
Light,
}
#[derive(Clone, Debug)]
pub struct Theme {
palette: Palette,
}
impl Theme {
/// Default dark palette — intended for the typical terminal
/// (white-on-black scheme). Distinct from `Theme::light`.
pub fn dark() -> Self {
Self {
palette: Palette::Dark,
}
}
/// Light palette — intended for users running a light-background
/// terminal scheme. Hues stay the same; brightness shifts so the
/// foreground stays readable on white.
pub fn light() -> Self {
Self {
palette: Palette::Light,
}
}
/// Resolve a config string ("dark" / "light", case-insensitive)
/// to a `Theme`. Unknown values fall back to dark — never errors.
/// p9-fb-14 spec: "config never errors on a typo, the TUI just
/// keeps the default theme so the user has a working shell."
pub fn from_name(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Self::light(),
_ => Self::dark(),
}
}
/// The underlying palette identity. Mostly a debugging aid.
pub fn palette(&self) -> Palette {
self.palette
}
/// Resolve a `Role` to a `Style`. Both palettes implement every
/// role exhaustively (compile error if a variant is added but
/// the palette body forgets it).
pub fn style(&self, role: Role) -> Style {
match self.palette {
Palette::Dark => dark_style(role),
Palette::Light => light_style(role),
}
}
}
/// `Theme::default() == Theme::dark()` — pinned by
/// `default_palette_is_dark` test. If the default ever flips, both
/// the test and downstream callers (e.g. integration smokes that
/// rely on dark contrast) need a coordinated update.
impl Default for Theme {
fn default() -> Self {
Self::dark()
}
}
/// Dark palette — high-contrast on black. The exhaustive match
/// guarantees adding a `Role` variant here forces the same in
/// `light_style`.
fn dark_style(role: Role) -> Style {
match role {
Role::BorderActive => Style::default().fg(Color::Cyan),
Role::BorderInactive => Style::default().fg(Color::DarkGray),
Role::Title => Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
Role::Path => Style::default().fg(Color::DarkGray),
Role::ModeLexical => Style::default().fg(Color::Yellow),
Role::ModeVector => Style::default().fg(Color::Magenta),
Role::ModeHybrid => Style::default().fg(Color::Cyan),
Role::Selected => Style::default().add_modifier(Modifier::REVERSED),
Role::Hint => Style::default().add_modifier(Modifier::DIM),
Role::Heading => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Role::Warning => Style::default().fg(Color::Yellow),
Role::Error => Style::default().fg(Color::Red),
Role::Success => Style::default().fg(Color::Green),
Role::CitationMarker => Style::default().fg(Color::Cyan),
Role::Bullet => Style::default().fg(Color::DarkGray),
Role::Body => Style::default(),
}
}
/// Light palette — high-contrast on white. Same hues as dark
/// (so user mental-models transfer) but with darker variants where
/// `Color::*` differs in 16-color terminals (e.g., `LightYellow`
/// would wash out on white, so `Yellow` stays).
fn light_style(role: Role) -> Style {
match role {
Role::BorderActive => Style::default().fg(Color::Blue),
Role::BorderInactive => Style::default().fg(Color::Gray),
Role::Title => Style::default()
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
Role::Path => Style::default().fg(Color::Gray),
Role::ModeLexical => Style::default().fg(Color::Yellow),
Role::ModeVector => Style::default().fg(Color::Magenta),
Role::ModeHybrid => Style::default().fg(Color::Blue),
Role::Selected => Style::default().add_modifier(Modifier::REVERSED),
Role::Hint => Style::default().add_modifier(Modifier::DIM),
Role::Heading => Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
Role::Warning => Style::default().fg(Color::Yellow),
Role::Error => Style::default().fg(Color::Red),
Role::Success => Style::default().fg(Color::Green),
Role::CitationMarker => Style::default().fg(Color::Blue),
Role::Bullet => Style::default().fg(Color::Gray),
Role::Body => Style::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Both palettes resolve every `Role` to a `Style` (no panic /
/// no `unreachable!()` branch). The exhaustive match in
/// `dark_style` / `light_style` makes this true at compile
/// time, but we exercise it at runtime so a regression to
/// `match _ => unreachable!()` would surface in test instead
/// of in production.
#[test]
fn every_role_resolves_in_dark_and_light() {
let roles = [
Role::BorderActive,
Role::BorderInactive,
Role::Title,
Role::Path,
Role::ModeLexical,
Role::ModeVector,
Role::ModeHybrid,
Role::Selected,
Role::Hint,
Role::Heading,
Role::Warning,
Role::Error,
Role::Success,
Role::CitationMarker,
Role::Bullet,
Role::Body,
];
for r in roles {
let _ = Theme::dark().style(r);
let _ = Theme::light().style(r);
}
}
/// `Theme::from_name` recognizes exactly two palette names; any
/// other input falls back to dark. Pinned per spec: "config
/// never errors on a typo".
#[test]
fn from_name_recognizes_dark_light_and_falls_back() {
assert_eq!(Theme::from_name("dark").palette(), Palette::Dark);
assert_eq!(Theme::from_name("DARK").palette(), Palette::Dark);
assert_eq!(Theme::from_name(" dark ").palette(), Palette::Dark);
assert_eq!(Theme::from_name("light").palette(), Palette::Light);
assert_eq!(Theme::from_name("LIGHT").palette(), Palette::Light);
assert_eq!(Theme::from_name("solarized").palette(), Palette::Dark);
assert_eq!(Theme::from_name("").palette(), Palette::Dark);
}
/// `Theme::default()` is dark — pinned so the default doesn't
/// silently flip in a future refactor.
#[test]
fn default_palette_is_dark() {
assert_eq!(Theme::default().palette(), Palette::Dark);
}
/// Critical roles emit `Style` with at least one decoration —
/// catches regressions where someone replaces a styled palette
/// branch with a bare `Style::default()`. `Body` is excluded
/// (it intentionally returns the default).
#[test]
fn primary_roles_carry_decoration_in_dark() {
let theme = Theme::dark();
for r in [
Role::Title,
Role::Selected,
Role::Heading,
Role::Error,
Role::Warning,
Role::Success,
] {
let style = theme.style(r);
let has_color = style.fg.is_some() || style.bg.is_some();
let has_modifier = !style.add_modifier.is_empty();
assert!(
has_color || has_modifier,
"role {r:?} resolves to bare Style::default() in dark palette"
);
}
}
}

View File

@@ -1,139 +0,0 @@
//! p9-fb-37: TUI trace popup. Opens from Search pane via `t` key
//! when results are visible. Re-runs the current query with
//! `SearchOpts.trace = true` and displays the lex / vec / rrf union
//! + per-stage timing as a single scroll list.
use crossterm::event::{KeyCode, KeyEvent};
use kebab_core::SearchTrace;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
#[derive(Debug, Clone)]
pub struct TracePopupState {
pub trace: SearchTrace,
pub scroll: u16,
}
impl TracePopupState {
pub fn new(trace: SearchTrace) -> Self {
Self { trace, scroll: 0 }
}
}
pub fn render_trace_popup(f: &mut Frame, area: Rect, state: &TracePopupState) {
let mut lines: Vec<Line> = Vec::new();
let bold = Style::default().add_modifier(Modifier::BOLD);
lines.push(Line::from(Span::styled(
format!(
"Lexical ({} hits, {} ms)",
state.trace.lexical.len(),
state.trace.timing.lexical_ms,
),
bold,
)));
for c in &state.trace.lexical {
lines.push(Line::from(format!(
" #{:>2} score={:.4} chunk={}",
c.rank, c.score, c.chunk_id.0
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"Vector ({} hits, {} ms)",
state.trace.vector.len(),
state.trace.timing.vector_ms,
),
bold,
)));
for c in &state.trace.vector {
lines.push(Line::from(format!(
" #{:>2} score={:.4} chunk={}",
c.rank, c.score, c.chunk_id.0
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(
"RRF inputs ({} entries, {} ms fusion)",
state.trace.rrf_inputs.len(),
state.trace.timing.fusion_ms,
),
bold,
)));
for e in &state.trace.rrf_inputs {
lines.push(Line::from(format!(
" chunk={} lex={:?} vec={:?} fusion={:.4}",
e.chunk_id.0, e.lexical_rank, e.vector_rank, e.fusion_score
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("Total: {} ms", state.trace.timing.total_ms),
bold,
)));
let block = Block::default()
.title("Trace — Esc to close, j/k or ↑↓ to scroll")
.borders(Borders::ALL);
let p = Paragraph::new(lines)
.block(block)
.scroll((state.scroll, 0))
.wrap(Wrap { trim: false });
f.render_widget(p, area);
}
/// Handle keys while popup is open. Returns true if the popup should close.
pub fn handle_key_trace_popup(state: &mut TracePopupState, key: KeyEvent) -> bool {
match key.code {
KeyCode::Esc => true,
KeyCode::Char('j') | KeyCode::Down => {
state.scroll = state.scroll.saturating_add(1);
false
}
KeyCode::Char('k') | KeyCode::Up => {
state.scroll = state.scroll.saturating_sub(1);
false
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
use kebab_core::TraceTiming;
fn dummy_state() -> TracePopupState {
TracePopupState::new(SearchTrace {
lexical: vec![],
vector: vec![],
rrf_inputs: vec![],
timing: TraceTiming::default(),
})
}
#[test]
fn esc_closes() {
let mut s = dummy_state();
assert!(handle_key_trace_popup(
&mut s,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
));
}
#[test]
fn j_scrolls_down() {
let mut s = dummy_state();
assert!(!handle_key_trace_popup(
&mut s,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
));
assert_eq!(s.scroll, 1);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,146 +0,0 @@
//! p9-fb-13: cheatsheet popup. Tests `cheatsheet_intercept` (F1
//! toggle, Esc close, modifier filter) and the rendered popup
//! includes the expected pane sections.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_tui::{App, Pane, cheatsheet_intercept, render_cheatsheet};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
fn fresh_app(focus: Pane) -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-cheatsheet-tests-noop".to_string();
config.workspace.root = Some("/tmp/kebab-tui-cheatsheet-tests-noop/workspace".to_string());
let mut app = App::new(config).expect("App::new");
app.focus = focus;
app
}
/// p9-fb-13: F1 toggles cheatsheet visibility. Consumed both ways.
#[test]
fn f1_toggles_cheatsheet_visibility() {
let mut app = fresh_app(Pane::Library);
assert!(!app.cheatsheet_visible(), "starts hidden");
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(consumed, "F1 must be consumed");
assert!(app.cheatsheet_visible(), "F1 opens");
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(consumed, "second F1 also consumed");
assert!(!app.cheatsheet_visible(), "F1 closes when open");
}
/// p9-fb-13: Esc closes when visible (consumed). When hidden, Esc
/// falls through (so the global mode_intercept / pane handlers
/// keep their existing semantics).
#[test]
fn esc_closes_cheatsheet_when_visible_otherwise_falls_through() {
let mut app = fresh_app(Pane::Library);
// Hidden → Esc falls through.
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!consumed, "Esc with cheatsheet hidden must fall through");
// Visible → Esc closes + consumed.
let _ = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(app.cheatsheet_visible());
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(consumed, "Esc with cheatsheet visible must consume");
assert!(!app.cheatsheet_visible());
}
/// p9-fb-13: modifier-bearing F1 (Ctrl-F1, Alt-F1) does NOT toggle.
/// Reserves chord space for future bindings.
#[test]
fn modifier_keys_do_not_toggle_cheatsheet() {
let mut app = fresh_app(Pane::Library);
let consumed = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::F(1), KeyModifiers::CONTROL),
);
assert!(!consumed);
assert!(!app.cheatsheet_visible());
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::ALT));
assert!(!consumed);
assert!(!app.cheatsheet_visible());
}
/// p9-fb-13: arbitrary keys (j, /, q, …) while cheatsheet visible
/// fall through to the active pane. Popup auto-closes only via
/// F1 / Esc, so the user can keep it open while navigating.
#[test]
fn arbitrary_key_falls_through_when_cheatsheet_visible() {
let mut app = fresh_app(Pane::Library);
let _ = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(app.cheatsheet_visible());
for key in [
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
] {
let consumed = cheatsheet_intercept(&mut app, key);
assert!(!consumed, "non-toggle keys fall through: {key:?}");
assert!(app.cheatsheet_visible(), "popup stays open: {key:?}");
}
}
/// p9-fb-13: rendered popup includes the section headers + the
/// global toggle keys + the active pane label. Buffer-grep style
/// — same pattern P9-3's `render_grounded_answer_with_citation`
/// uses to assert visible content.
#[test]
fn cheatsheet_popup_contains_global_and_pane_sections() {
let mut app = fresh_app(Pane::Search);
app.focus = Pane::Search;
// Force visible — we're testing the renderer, not the toggle.
let _ = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 120, 40);
render_cheatsheet(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Global"), "Global section header present");
assert!(
rendered.contains("Library"),
"Library section header present"
);
assert!(rendered.contains("Search"), "Search section header present");
assert!(rendered.contains("Ask"), "Ask section header present");
assert!(rendered.contains("F1"), "F1 binding listed");
assert!(rendered.contains("Esc"), "Esc binding listed");
// p9-fb-21: Inspect (last section) overflows the 75%-height popup
// after Search + Ask each gained one row. Body has no scroll
// support yet — known limitation, tracked as a follow-up. Skip
// the Inspect assertion when the body overflows; the rest of
// the section-header asserts still cover the primary contract.
if !rendered.contains("Inspect") {
eprintln!(
"[note] Inspect section overflowed popup body — known limitation per p9-fb-21 HOTFIXES"
);
}
// The "currently focused: <pane>" line lives at the bottom of
// the popup; it might get clipped if the popup's content
// overflows the rect. Skip the assertion if the popup body
// wraps too tall — the section-header asserts already cover
// the primary contract.
let has_focused = rendered.contains("focused");
if !has_focused {
eprintln!(
"[note] 'focused' line absent — likely body overflowed popup height; sections still pinned"
);
}
}

View File

@@ -1,433 +0,0 @@
//! Unit + snapshot tests for the Inspect pane (P9-4).
//!
//! Tests bypass the facade fetch by hand-populating `InspectState.doc`
//! / `state.chunk`. The fetch path itself is exercised end-to-end by
//! manual smoke (TempDir KB).
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
AssetId, Block, BlockId, CanonicalDocument, Chunk, ChunkId, ChunkerVersion, CommonBlock,
DocumentId, HeadingBlock, Inline, Lang, Metadata, ParserVersion, Provenance, ProvenanceEvent,
ProvenanceKind, SourceSpan, SourceType, TextBlock, TrustLevel, WorkspacePath,
};
use kebab_tui::{
App, InspectState, InspectTarget, KeyOutcome, Pane, handle_key_inspect, render_inspect,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use std::path::PathBuf;
use time::OffsetDateTime;
fn fresh_app() -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-inspect-tests-noop".to_string();
config.workspace.root = Some("/tmp/kebab-tui-inspect-tests-noop/workspace".to_string());
let mut app = App::new(config).expect("App::new");
app.focus = Pane::Inspect;
app.inspect = Some(InspectState::default());
app
}
fn make_doc() -> CanonicalDocument {
let doc_id = DocumentId("d".repeat(32));
let asset_id = AssetId("a".repeat(32));
let span1 = SourceSpan::Line { start: 1, end: 1 };
let span2 = SourceSpan::Line { start: 2, end: 5 };
let common1 = CommonBlock {
block_id: BlockId("b".repeat(32)),
heading_path: vec![],
source_span: span1,
};
let common2 = CommonBlock {
block_id: BlockId("c".repeat(32)),
heading_path: vec!["Top".into()],
source_span: span2,
};
let blocks = vec![
Block::Heading(HeadingBlock {
common: common1,
level: 1,
text: "Top".into(),
}),
Block::Paragraph(TextBlock {
common: common2,
text: "first paragraph body line.".into(),
inlines: vec![Inline::Text {
text: "first paragraph body line.".into(),
}],
}),
];
let mut user = serde_json::Map::new();
user.insert(
"custom_key".into(),
serde_json::Value::String("custom_val".into()),
);
CanonicalDocument {
doc_id,
source_asset_id: asset_id,
workspace_path: WorkspacePath::new("notes/test.md".into()).unwrap(),
title: "Test Doc".into(),
lang: Lang("en".into()),
blocks,
metadata: Metadata {
aliases: vec!["alias1".into()],
tags: vec!["tag-a".into(), "tag-b".into()],
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_500).unwrap(),
source_type: SourceType::Note,
trust_level: TrustLevel::Primary,
user_id_alias: None,
user,
repo: None,
git_branch: None,
git_commit: None,
code_lang: None,
source_id: None,
},
provenance: Provenance {
events: vec![ProvenanceEvent {
at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
agent: "kb-source-fs".into(),
kind: ProvenanceKind::Discovered,
note: None,
}],
},
parser_version: ParserVersion("test-parser".into()),
schema_version: 1,
doc_version: 1,
last_chunker_version: None,
last_embedding_version: None,
}
}
fn make_chunk() -> Chunk {
Chunk {
chunk_id: ChunkId("e".repeat(32)),
doc_id: DocumentId("d".repeat(32)),
block_ids: vec![BlockId("b".repeat(32)), BlockId("c".repeat(32))],
text: "chunk body line one.\nchunk body line two.".into(),
heading_path: vec!["Top".into(), "Sub".into()],
source_spans: vec![SourceSpan::Line { start: 1, end: 5 }],
token_estimate: 12,
chunker_version: ChunkerVersion("md-heading-v1".into()),
policy_hash: "deadbeefdeadbeef".into(),
tokenized_korean_text: None,
}
}
fn render_to_string(app: &App, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, w, h);
render_inspect(f, area, app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn esc_returns_to_recorded_pane() {
let mut app = fresh_app();
{
let s = app.inspect.as_mut().unwrap();
s.return_to = Pane::Search;
}
let outcome = handle_key_inspect(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Search));
}
#[test]
fn q_also_returns() {
let mut app = fresh_app();
let outcome = handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}
#[test]
fn j_k_scroll_within_bounds_no_panic() {
let mut app = fresh_app();
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(app.inspect.as_ref().unwrap().scroll, 1);
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(app.inspect.as_ref().unwrap().scroll, 0);
// Underflow saturates at 0
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(app.inspect.as_ref().unwrap().scroll, 0);
}
/// p9-fb-24 task 2: PageDown advances scroll by `PAGE_STEP` (= 10).
/// Pins the constant so a future viewport-aware refactor surfaces
/// here, not silently in user-visible behaviour. Replaces the
/// pre-fb-24 `page_keys_scroll_by_ten` (deleted as duplicate).
#[test]
fn page_down_scrolls_by_ten_in_inspect() {
let mut app = fresh_app();
let outcome = handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Continue);
assert_eq!(app.inspect.as_ref().unwrap().scroll, 10);
}
/// p9-fb-24 task 2: PageUp rewinds scroll by `PAGE_STEP`, saturating
/// at 0 (no underflow).
#[test]
fn page_up_rewinds_by_ten_saturating_in_inspect() {
let mut app = fresh_app();
app.inspect.as_mut().unwrap().scroll = 25;
handle_key_inspect(&mut app, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
assert_eq!(app.inspect.as_ref().unwrap().scroll, 15);
app.inspect.as_mut().unwrap().scroll = 3;
handle_key_inspect(&mut app, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
assert_eq!(app.inspect.as_ref().unwrap().scroll, 0);
}
#[test]
fn c_toggles_collapse_state() {
let mut app = fresh_app();
// First press: nothing collapsed → collapse all.
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
let s = app.inspect.as_ref().unwrap();
assert!(!s.collapsed.is_empty(), "first c collapses all");
// Second press: some collapsed → expand all.
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
let s = app.inspect.as_ref().unwrap();
assert!(s.collapsed.is_empty(), "second c expands all");
}
#[test]
fn no_target_renders_hint_without_panic() {
let app = fresh_app();
let rendered = render_to_string(&app, 80, 20);
assert!(rendered.contains("Inspect"), "header visible");
assert!(
rendered.contains("no target") || rendered.contains("press Enter"),
"hint visible: {rendered}"
);
}
#[test]
fn loading_state_renders_loading_message() {
let mut app = fresh_app();
{
let s = app.inspect.as_mut().unwrap();
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
s.loading = true;
}
let rendered = render_to_string(&app, 80, 10);
assert!(rendered.contains("loading"), "loading hint: {rendered}");
}
#[test]
fn doc_view_renders_header_and_metadata() {
let mut app = fresh_app();
{
let s = app.inspect.as_mut().unwrap();
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
s.doc = Some(make_doc());
}
let rendered = render_to_string(&app, 100, 40);
assert!(rendered.contains("Test Doc"), "title rendered");
assert!(rendered.contains("notes/test.md"), "doc_path rendered");
assert!(rendered.contains("test-parser"), "parser_version rendered");
assert!(rendered.contains("metadata"), "metadata section visible");
assert!(rendered.contains("tag-a"), "tags rendered");
assert!(
rendered.contains("custom_key") || rendered.contains("custom_val"),
"user metadata pretty-printed"
);
assert!(
rendered.contains("provenance"),
"provenance section visible"
);
assert!(rendered.contains("kb-source-fs"), "agent rendered");
assert!(rendered.contains("blocks"), "blocks section visible");
assert!(rendered.contains("Heading L1"), "block describe rendered");
}
#[test]
fn doc_view_collapse_hides_section_body() {
let mut app = fresh_app();
{
let s = app.inspect.as_mut().unwrap();
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
s.doc = Some(make_doc());
}
let pre = render_to_string(&app, 100, 30);
assert!(pre.contains("kb-source-fs"), "before collapse");
assert!(pre.contains("Heading L1"), "blocks body before collapse");
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
let post = render_to_string(&app, 100, 30);
assert!(post.contains("metadata"), "section header still visible");
assert!(
post.contains("blocks (2)"),
"blocks count visible inline on collapsed header: {post}"
);
assert!(
!post.contains("kb-source-fs"),
"provenance body hidden after collapse: {post}"
);
assert!(
!post.contains("Heading L1"),
"blocks body hidden after collapse (count must collapse with body): {post}"
);
}
#[test]
fn chunk_view_renders_text_and_block_ids() {
let mut app = fresh_app();
{
let s = app.inspect.as_mut().unwrap();
s.target = Some(InspectTarget::Chunk(ChunkId("e".repeat(32))));
s.chunk = Some(make_chunk());
}
let rendered = render_to_string(&app, 100, 40);
assert!(
rendered.contains("md-heading-v1"),
"chunker_version rendered"
);
assert!(rendered.contains("Top / Sub"), "heading_path joined");
assert!(rendered.contains("Line 1-5"), "source span described");
assert!(
rendered.contains("chunk body line one"),
"text body rendered"
);
assert!(
rendered.contains("embeddings (2)"),
"block_id count rendered inline on embeddings header"
);
}
/// p9-fb-32: when a doc's `metadata.updated_at` is older than the
/// configured `stale_threshold_days`, the Inspect pane prefixes the
/// `doc_path` value with a Warning-styled `[STALE] ` Span. Threshold
/// 0 (the staleness feature off) must NOT render the badge.
#[test]
fn inspect_doc_header_shows_stale_badge_when_threshold_exceeded() {
let mut app = fresh_app();
// Force a non-zero threshold so the staleness post-process can fire.
app.config.search.stale_threshold_days = 30;
{
let s = app.inspect.as_mut().unwrap();
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
let mut doc = make_doc();
// Backdate updated_at by 60 days so 60d > 30d threshold.
doc.metadata.updated_at = OffsetDateTime::now_utc() - time::Duration::days(60);
s.doc = Some(doc);
}
let rendered = render_to_string(&app, 100, 40);
assert!(
rendered.contains("[STALE]"),
"[STALE] badge must render on stale doc header: {rendered}"
);
// Same line carrying the doc_path value must show the badge.
let path_line = rendered
.lines()
.find(|l| l.contains("notes/test.md"))
.expect("doc_path line must render");
assert!(
path_line.contains("[STALE]"),
"doc_path row must carry [STALE] badge: {path_line}"
);
}
#[test]
fn inspect_doc_header_omits_stale_badge_when_fresh() {
let mut app = fresh_app();
app.config.search.stale_threshold_days = 30;
{
let s = app.inspect.as_mut().unwrap();
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
let mut doc = make_doc();
// 1 day old — under the 30d threshold.
doc.metadata.updated_at = OffsetDateTime::now_utc() - time::Duration::days(1);
s.doc = Some(doc);
}
let rendered = render_to_string(&app, 100, 40);
assert!(
!rendered.contains("[STALE]"),
"fresh doc must NOT carry [STALE] badge: {rendered}"
);
}
#[test]
fn inspect_doc_header_omits_stale_badge_when_threshold_zero() {
let mut app = fresh_app();
// Threshold 0 = staleness feature disabled.
app.config.search.stale_threshold_days = 0;
{
let s = app.inspect.as_mut().unwrap();
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
let mut doc = make_doc();
// Even a year-old doc must not get [STALE] when threshold = 0.
doc.metadata.updated_at = OffsetDateTime::now_utc() - time::Duration::days(365);
s.doc = Some(doc);
}
let rendered = render_to_string(&app, 100, 40);
assert!(
!rendered.contains("[STALE]"),
"threshold = 0 must disable [STALE] badge regardless of age: {rendered}"
);
}
#[test]
fn no_inspect_state_returns_to_library() {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-inspect-tests-noop".into();
let mut app = App::new(config).unwrap();
app.focus = Pane::Inspect;
let outcome = handle_key_inspect(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}
#[test]
fn enter_inspect_helper_sets_target_and_marks_fetch() {
let mut app = fresh_app();
app.inspect = None; // simulate cold state
kebab_tui::enter_inspect(
&mut app,
InspectTarget::Doc(DocumentId("d".repeat(32))),
Pane::Library,
);
let s = app.inspect.as_ref().unwrap();
assert!(matches!(s.target, Some(InspectTarget::Doc(_))));
assert_eq!(s.return_to, Pane::Library);
assert!(s.needs_fetch);
assert!(s.doc.is_none());
let _ = PathBuf::from(""); // silence unused-import in some configs
}

View File

@@ -1,377 +0,0 @@
//! Unit + snapshot tests for the Library pane.
//!
//! Snapshot tests use `ratatui::backend::TestBackend` so the run loop
//! is bypassed entirely — we drive `render_library` directly against
//! a synthetic `App`.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
ChunkerVersion, DocSummary, DocumentId, Lang, ParserVersion, SourceType, TrustLevel,
WorkspacePath,
};
use kebab_tui::{App, KeyOutcome, Pane, render_library};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use time::OffsetDateTime;
fn make_doc(path: &str, title: &str, tags: Vec<&str>) -> DocSummary {
DocSummary {
doc_id: DocumentId(format!(
"{:0<32}",
path.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>()
)),
doc_path: WorkspacePath::new(path.into()).unwrap(),
title: title.into(),
lang: Lang("en".into()),
tags: tags.into_iter().map(String::from).collect(),
trust_level: TrustLevel::Primary,
source_type: SourceType::Note,
byte_len: 1024,
chunk_count: 4,
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
parser_version: ParserVersion("test-parser".into()),
chunker_version: ChunkerVersion("test-chunker".into()),
}
}
fn app_with_docs(docs: Vec<DocSummary>) -> App {
let mut config = Config::defaults();
// Storage paths point at /tmp so any accidental facade call
// would not touch the user's real KB. Tests below use the
// `populate_library_for_testing` test seam, never the facade.
config.storage.data_dir = "/tmp/kebab-tui-tests-noop".to_string();
let mut app = App::new(config).expect("App::new must succeed with defaults");
app.populate_library_for_testing(docs);
app
}
#[test]
fn empty_library_renders_block_only_no_panic() {
let app = app_with_docs(vec![]);
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 20);
render_library(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Library"),
"rendered frame must show Library header: {rendered}"
);
assert!(
rendered.contains("no docs") || rendered.contains("Library"),
"empty state hint should appear in the header line"
);
}
#[test]
fn handle_key_library_q_quits() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Quit);
}
#[test]
fn handle_key_library_esc_quits_when_no_overlay() {
let mut app = app_with_docs(vec![]);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Quit);
}
#[test]
fn handle_key_library_slash_switches_to_search() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Search));
}
#[test]
fn handle_key_library_question_switches_to_ask() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Ask));
}
#[test]
fn handle_key_library_enter_does_not_switch_when_empty() {
let mut app = app_with_docs(vec![]);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Continue);
}
#[test]
fn library_with_docs_renders_titles() {
let app = app_with_docs(vec![
make_doc("notes/foo.md", "Foo", vec!["alpha"]),
make_doc("notes/bar.md", "Bar", vec!["beta", "gamma"]),
make_doc("notes/baz.md", "Baz Title", vec![]),
]);
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 10);
render_library(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
for title in &["Foo", "Bar", "Baz Title"] {
assert!(
rendered.contains(title),
"rendered must contain {title}, got:\n{rendered}"
);
}
}
#[test]
fn handle_key_library_arrow_down_moves_selection() {
let mut app = app_with_docs(vec![
make_doc("a.md", "A", vec![]),
make_doc("b.md", "B", vec![]),
make_doc("c.md", "C", vec![]),
]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Continue);
let outcome2 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(outcome2, KeyOutcome::Continue);
// Third j hits the bottom; clamp must not panic / overflow.
let outcome3 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(outcome3, KeyOutcome::Continue);
}
#[test]
fn handle_key_library_enter_inspects_when_docs_present() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Inspect));
}
#[test]
fn handle_key_library_f_opens_filter_overlay_then_enter_refreshes() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter.
let o1 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
assert_eq!(o1, KeyOutcome::Continue);
// Type into tags buffer.
for ch in "foo".chars() {
kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
// Enter commits + refreshes.
let o2 =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(o2, KeyOutcome::Refresh);
}
/// p9-fb-10: filter overlay accepts Hangul tags via key events
/// and commits them to the doc filter.
#[test]
fn filter_overlay_accepts_hangul_tags() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter overlay.
let o1 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
assert_eq!(o1, KeyOutcome::Continue);
// Type Hangul into the tags buffer.
for ch in "한글".chars() {
kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
// Enter commits.
let o2 =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(o2, KeyOutcome::Refresh);
// The library filter should now contain "한글" as a tag.
let filter = app.library_filter_for_testing();
assert!(
filter.tags_any.iter().any(|t| t == "한글"),
"expected '한글' in tags filter: {:?}",
filter.tags_any,
);
}
/// p9-fb-10: filter overlay calls f.set_cursor_position so ratatui
/// shows the caret on the focused field. Pin: after opening the
/// overlay, render → terminal cursor is set + has non-zero x
/// (the label offset > 0).
#[test]
fn filter_overlay_render_places_cursor_on_focused_field() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter.
let _ = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 20);
render_library(f, area, &app);
})
.expect("render must not panic");
// After draw, ratatui calls backend.set_cursor_position when the
// frame's cursor_position is Some. The terminal's
// get_cursor_position proxies to the backend.
let pos = terminal
.get_cursor_position()
.expect("filter overlay must call set_cursor_position, so cursor pos must be readable");
// The Tags label ("tags_any (csv): ") has display_width 16; inner.x
// is 1 (inside border). With empty input cursor_col=0, expected x=17.
// We assert x>0 to avoid hardcoding the exact layout geometry while
// still confirming set_cursor_position was called with a meaningful
// offset (not stuck at origin).
assert!(
pos.x > 0,
"cursor x should be positive (label offset > 0): {pos:?}"
);
}
/// p9-fb-24: rendered Library pane shows the column header row above
/// the data rows. Header is in `Role::Heading` style; data rows in
/// the `Role::Body` / `Role::Selected` defaults.
#[test]
fn library_renders_column_header_row() {
let docs = vec![
make_doc("notes/alpha.md", "doc-alpha", vec!["rust"]),
make_doc("notes/beta.md", "doc-beta", vec!["docs"]),
make_doc("notes/gamma.md", "doc-gamma", vec![]),
];
let app = app_with_docs(docs);
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 20);
render_library(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("TITLE")
&& rendered.contains("TAGS")
&& rendered.contains("UPDATED")
&& rendered.contains("CHUNKS"),
"header row labels not visible in:\n{rendered}"
);
let title_line_idx = rendered
.lines()
.position(|line| line.contains("TITLE"))
.expect("TITLE header should be present");
let lines_after = rendered
.lines()
.skip(title_line_idx + 1)
.collect::<Vec<_>>();
assert!(
lines_after.iter().any(|line| line.contains("doc-")),
"no data rows after header:\n{rendered}"
);
}
/// p9-fb-10: Library renders Hangul / CJK titles without overflowing
/// the title column. Smoke pin — render with a mixed Korean fixture
/// and confirm no panic + the truncated width fits the column.
#[test]
fn library_renders_korean_titles_without_overflow() {
let docs = vec![
make_doc(
"ko/한글-노트.md",
"러스트로 만드는 지식 베이스",
vec!["rust", "한글"],
),
make_doc("jp/漢字メモ.md", "日本語のテストドキュメント", vec!["jp"]),
make_doc("mix/hello-세계.md", "Hello, 세계 mixed title", vec!["mix"]),
];
let app = app_with_docs(docs);
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 20);
render_library(f, area, &app);
})
.expect("render must not panic on CJK titles");
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
// At least one Hangul / Kanji glyph survives the render path.
// TestBackend renders wide chars one-per-cell with the trailing
// cell empty, so the joined string has spaces between adjacent
// wide chars — assert single glyphs, not multi-char substrings.
assert!(
rendered.contains('러') || rendered.contains('한'),
"expected a Hangul glyph in rendered frame: {rendered}"
);
assert!(
rendered.contains('日') || rendered.contains('漢'),
"expected a Kanji glyph in rendered frame: {rendered}"
);
}

View File

@@ -1,165 +0,0 @@
//! 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 = Some("/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-21 (was p9-fb-12): on Search/Ask the auto mode is Insert,
/// so `i` typed in that state must fall through (would otherwise
/// swallow a real letter the user is typing).
#[test]
fn i_on_search_or_ask_in_insert_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:?}/Insert must fall through to pane");
assert_eq!(app.mode, Mode::Insert, "mode unchanged");
}
}
/// p9-fb-21: `i` in Normal on Search/Ask DOES intercept — the
/// dogfooding feedback was that once the user pressed Esc to leave
/// Insert, no key brought them back. `i` is the universal toggle
/// now (Search's pre-fb-21 `i`=chunk inspect was rebound to `o`).
#[test]
fn i_on_search_or_ask_in_normal_flips_to_insert() {
for &pane in &[Pane::Search, Pane::Ask] {
let mut app = fresh_app(pane);
app.mode = Mode::Normal;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(consumed, "i on {pane:?}/Normal must intercept (p9-fb-21)");
assert_eq!(
app.mode,
Mode::Insert,
"mode flipped to Insert (pane: {pane:?})"
);
}
}
/// 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

@@ -1,747 +0,0 @@
//! Unit + snapshot tests for the Search pane (P9-2).
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
ChunkId, ChunkerVersion, Citation, DocumentId, EmbeddingModelId, IndexVersion, RetrievalDetail,
SearchHit, SearchMode, WorkspacePath,
};
use kebab_tui::{
App, KeyOutcome, Mode, Pane, SearchState, SearchWorkerMessage, build_jump_command,
handle_key_search, poll_search_worker, render_search, search_debounce_due,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use std::path::Path;
fn fresh_app() -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".to_string();
config.workspace.root = Some("/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
}
fn make_hit(rank: u32, path: &str, snippet: &str, citation: Citation) -> SearchHit {
SearchHit {
rank,
chunk_id: ChunkId(format!("{rank:0<32}")),
doc_id: DocumentId(format!("{:0<32}", rank * 2)),
doc_path: WorkspacePath::new(path.into()).unwrap(),
heading_path: vec!["Section".into(), "Sub".into()],
section_label: Some("Sub".into()),
snippet: snippet.into(),
citation,
retrieval: RetrievalDetail {
method: SearchMode::Hybrid,
fusion_score: 0.9,
lexical_score: Some(0.8),
vector_score: Some(0.95),
lexical_rank: Some(rank),
vector_rank: Some(rank),
},
index_version: IndexVersion("v1".into()),
embedding_model: Some(EmbeddingModelId("multilingual-e5-small".into())),
chunker_version: ChunkerVersion("md-heading-v1".into()),
// fb-32: TUI search test fixtures pinned to UNIX_EPOCH + stale=false;
// staleness rendering covered in dedicated tests (Task 11).
indexed_at: time::OffsetDateTime::UNIX_EPOCH,
stale: false,
score_kind: kebab_core::ScoreKind::Rrf,
repo: None,
code_lang: None,
source_id: None,
trust_level: None,
}
}
fn line_citation(path: &str, line: u32) -> Citation {
Citation::Line {
path: WorkspacePath::new(path.into()).unwrap(),
start: line,
end: line,
section: None,
}
}
#[test]
fn esc_returns_to_library() {
let mut app = fresh_app();
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}
#[test]
fn typing_appends_to_input_and_marks_dirty() {
let mut app = fresh_app();
for ch in "hello".chars() {
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
let s = app.search.as_ref().unwrap();
assert_eq!(s.input.as_str(), "hello");
assert!(s.input_dirty_at.is_some());
}
#[test]
fn backspace_removes_last_char() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.input.push_str("abc");
}
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "ab");
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 2);
}
#[test]
fn tab_cycles_mode_lex_vec_hybrid() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.mode = SearchMode::Lexical;
}
let press_tab = |app: &mut App| {
handle_key_search(app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
};
press_tab(&mut app);
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Vector);
press_tab(&mut app);
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Hybrid);
press_tab(&mut app);
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Lexical);
}
#[test]
fn enter_with_query_emits_refresh() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.input.push_str("rust");
}
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Refresh);
}
#[test]
fn enter_with_empty_query_is_continue() {
let mut app = fresh_app();
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::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![
make_hit(1, "a.md", "snip a\nline2", line_citation("a.md", 1)),
make_hit(2, "b.md", "snip b\nline2", line_citation("b.md", 5)),
make_hit(3, "c.md", "snip c\nline2", line_citation("c.md", 7)),
];
s.selected_hit = 0;
}
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().selected_hit, 1);
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().selected_hit, 2);
// Bounds clamp.
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().selected_hit, 2);
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().selected_hit, 1);
}
#[test]
fn build_jump_command_line_uses_plus_n_for_vim() {
let citation = line_citation("notes/foo.md", 42);
let (program, args) = build_jump_command(&citation, "vim", Path::new("/tmp/workspace"));
assert_eq!(program, "vim");
assert_eq!(
args,
vec!["+42".to_string(), "/tmp/workspace/notes/foo.md".into()]
);
}
#[test]
fn build_jump_command_line_uses_g_flag_for_code() {
let citation = line_citation("notes/foo.md", 42);
let (program, args) = build_jump_command(&citation, "code", Path::new("/tmp/workspace"));
assert_eq!(program, "code");
assert_eq!(
args,
vec!["-g".to_string(), "/tmp/workspace/notes/foo.md:42".into()]
);
}
#[test]
fn build_jump_command_passes_through_editor_args() {
let citation = line_citation("a.md", 7);
let (program, args) = build_jump_command(&citation, "nvim -p", Path::new("/ws"));
assert_eq!(program, "nvim");
// Leading `-p` from $EDITOR env preserved before the +N path arg.
assert!(args[0] == "-p", "leading editor arg preserved: {args:?}");
assert!(args.contains(&"+7".to_string()));
assert!(args.contains(&"/ws/a.md".to_string()));
}
#[test]
fn render_search_with_hits_shows_input_and_path() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.input.push_str("rust traits");
s.mode = SearchMode::Hybrid;
s.hits = vec![
make_hit(
1,
"notes/rust.md",
"trait dispatch\nis dynamic",
line_citation("notes/rust.md", 12),
),
make_hit(
2,
"notes/dyn.md",
"dynamic dispatch\nvtable",
line_citation("notes/dyn.md", 3),
),
];
s.selected_hit = 0;
}
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 24);
render_search(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("hybrid"),
"mode badge rendered: {rendered}"
);
assert!(rendered.contains("rust traits"), "input text rendered");
assert!(
rendered.contains("notes/rust.md"),
"first hit path rendered"
);
assert!(
rendered.contains("notes/dyn.md"),
"second hit path rendered"
);
}
/// p9-fb-32: Search pane prefixes the rank/score header line with a
/// Warning-styled `[STALE] ` Span when `hit.stale == true`. Pin the
/// text-level signal (color is exercised via the cell scan below).
#[test]
fn search_pane_shows_stale_badge_for_old_doc() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.input.push_str("rust");
s.mode = SearchMode::Hybrid;
let mut stale_hit = make_hit(
1,
"notes/old.md",
"ancient trait dispatch\nstill relevant",
line_citation("notes/old.md", 7),
);
// Synthesize an indexed_at well past any threshold; combined
// with `stale: true` this matches the post-process output of
// `kebab_app::mark_stale_in_place`.
stale_hit.indexed_at = time::OffsetDateTime::UNIX_EPOCH;
stale_hit.stale = true;
let fresh_hit = make_hit(
2,
"notes/new.md",
"modern dispatch\nvtable",
line_citation("notes/new.md", 3),
);
s.hits = vec![stale_hit, fresh_hit];
s.selected_hit = 0;
}
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 24);
render_search(f, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("[STALE]"),
"[STALE] badge must render as text on stale hit: {rendered}"
);
// The badge appears on the same line that begins with rank `1.`
// — the stale hit. The fresh `notes/new.md` row must NOT carry
// the badge.
let stale_line = rendered
.lines()
.find(|l| l.contains("notes/old.md"))
.expect("stale hit's header line must render");
assert!(
stale_line.contains("[STALE]"),
"stale row must carry [STALE] badge: {stale_line}"
);
let fresh_line = rendered
.lines()
.find(|l| l.contains("notes/new.md"))
.expect("fresh hit's header line must render");
assert!(
!fresh_line.contains("[STALE]"),
"fresh row must NOT carry [STALE] badge: {fresh_line}"
);
// Color side: the `[` of `[STALE]` must be Yellow (Warning role,
// dark palette default).
let mut stale_yellow_found = false;
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
let cell = &buffer[(x, y)];
if cell.symbol() == "[" {
// The cell to the right should be 'S' if this is the
// start of `[STALE]` — narrow check to avoid the
// rank/score `[` cells (there shouldn't be any there).
if x + 1 < buffer.area.width && buffer[(x + 1, y)].symbol() == "S" {
if let ratatui::style::Color::Yellow = cell.fg {
stale_yellow_found = true;
}
}
}
}
}
assert!(
stale_yellow_found,
"[STALE] badge must be rendered with Yellow (Warning role) fg"
);
}
#[test]
fn empty_state_renders_without_panic() {
let app = fresh_app();
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 20);
render_search(f, area, &app);
})
.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.as_str(), "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.as_str(), "", "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
// queries like \"JSON\" / \"PostgreSQL\" don't get \"J\" eaten as
// a selection move.
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.hits = vec![
make_hit(1, "a.md", "snip\nl2", line_citation("a.md", 1)),
make_hit(2, "b.md", "snip\nl2", line_citation("b.md", 1)),
];
s.selected_hit = 0;
}
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT),
);
let s = app.search.as_ref().unwrap();
assert_eq!(s.selected_hit, 0, "selection must NOT move on SHIFT-J");
assert_eq!(s.input.as_str(), "J", "SHIFT-J must reach the input buffer");
}
#[test]
fn shift_g_does_not_trigger_editor_jump() {
// R1 fix: capital G must not invoke jump_to_citation. Keep it
// as plain typing so \"Go\" / \"Greetings\" search queries work.
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(1, "a.md", "snip\nl2", line_citation("a.md", 1))];
}
let outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert_eq!(outcome, KeyOutcome::Continue);
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "G");
}
/// p9-fb-09 — `g` on a hit enqueues an `EditorRequest` on `App.pending_editor`
/// rather than spawning the child synchronously. The run loop services the
/// queue with the `TuiTerminal` handle in scope so the post-resume
/// `terminal.clear()` can land (preventing the corrupted-redraw bug).
#[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),
)];
s.selected_hit = 0;
}
assert!(app.pending_editor().is_none(), "queue starts empty");
let outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Continue);
let req = app
.pending_editor()
.expect("g on a hit must enqueue an EditorRequest");
match &req.citation {
Citation::Line { path, start, .. } => {
assert_eq!(path.0, "notes/x.md");
assert_eq!(*start, 42);
}
other => panic!("unexpected citation variant: {other:?}"),
}
// editor_env reads $EDITOR — fall back to "vi" for tests.
assert!(!req.editor_env.is_empty(), "editor_env must be populated");
}
/// p9-fb-09 — `g` with no hits is a no-op; the queue stays empty.
#[test]
fn g_key_with_no_hits_does_not_enqueue() {
let mut app = fresh_app();
// Search slot present, hits empty.
let _outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(
app.pending_editor().is_none(),
"g with no hits must not enqueue"
);
}
// ── p9-fb-08: async search worker + generation counter ────────────
/// `poll_search_worker` applies a fresh result (matching generation)
/// to `state.search.hits` and clears `searching`.
#[test]
fn poll_worker_applies_fresh_result_to_hits() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel();
{
let s = app.search.as_mut().unwrap();
s.generation = 5;
s.searching = true;
s.worker_rx = Some(rx);
}
let hit = make_hit(1, "a.md", "snip", line_citation("a.md", 1));
tx.send(SearchWorkerMessage::Done {
generation: 5,
result: Ok(vec![hit]),
})
.unwrap();
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert_eq!(s.hits.len(), 1, "fresh result populates hits");
assert!(!s.searching, "searching cleared");
assert!(s.worker_rx.is_none(), "rx drained");
}
/// p9-fb-08 — a stale result (generation mismatch) is silently
/// dropped. `searching` remains true since a newer worker is
/// (presumed) still in flight.
#[test]
fn poll_worker_drops_stale_result() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel();
{
let s = app.search.as_mut().unwrap();
s.generation = 7;
s.searching = true;
s.worker_rx = Some(rx);
}
let hit = make_hit(1, "stale.md", "snip", line_citation("stale.md", 1));
// generation 3 < current 7 → stale.
tx.send(SearchWorkerMessage::Done {
generation: 3,
result: Ok(vec![hit]),
})
.unwrap();
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert!(s.hits.is_empty(), "stale result must not populate hits");
assert!(
s.searching,
"searching stays true so newer worker can resolve it"
);
assert!(
s.worker_rx.is_none(),
"stale message still drains the rx slot — worker is one-shot"
);
}
/// p9-fb-08 — `poll_search_worker` is a no-op when no worker is in
/// flight (no rx). Common case on every tick the user isn't typing.
#[test]
fn poll_worker_noop_when_no_rx() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(1, "x.md", "snip", line_citation("x.md", 1))];
}
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert_eq!(s.hits.len(), 1, "existing hits preserved");
assert!(s.worker_rx.is_none());
}
/// Helper for the debounce_due tests — build a state with the four
/// fields the test cares about set, others default.
#[allow(clippy::field_reassign_with_default)]
fn search_state_with(
input: &str,
mode: SearchMode,
searching: bool,
last_query: Option<(String, SearchMode)>,
) -> SearchState {
let mut s = SearchState::default();
s.input.push_str(input);
s.mode = mode;
s.searching = searching;
s.last_query = last_query;
s.input_dirty_at = Some(time::OffsetDateTime::now_utc() - time::Duration::seconds(1));
s
}
/// p9-fb-08 — `debounce_due` skips when an in-flight worker is
/// already running for the same `(input, mode)` pair. Without this
/// guard, a "phantom keystroke" (re-typing the same chars) would
/// pile up workers and burn CPU.
#[test]
fn debounce_due_skips_when_in_flight_for_same_query() {
let s = search_state_with(
"hello",
SearchMode::Hybrid,
true,
Some(("hello".into(), SearchMode::Hybrid)),
);
assert!(
!search_debounce_due(&s),
"in-flight worker for same query → debounce must skip"
);
}
/// p9-fb-08 — `debounce_due` still fires when a different query is
/// in flight (user typed past the in-flight one). The new spawn
/// makes the prior result stale (handled by `poll_worker`).
#[test]
fn debounce_due_fires_when_in_flight_for_different_query() {
let s = search_state_with(
"hello world",
SearchMode::Hybrid,
true,
Some(("hello".into(), SearchMode::Hybrid)),
);
assert!(
search_debounce_due(&s),
"in-flight worker for old query → new query still spawns"
);
}
/// p9-fb-08 — disconnected channel (worker panicked) clears the rx
/// + searching flag so the next debounce tick can re-fire cleanly.
#[test]
fn poll_worker_handles_disconnected_channel() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel::<SearchWorkerMessage>();
{
let s = app.search.as_mut().unwrap();
s.searching = true;
s.worker_rx = Some(rx);
}
drop(tx); // simulate worker panic before send
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert!(!s.searching, "searching cleared on disconnect");
assert!(s.worker_rx.is_none());
}
#[test]
fn no_search_state_returns_to_library() {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".into();
let mut app = App::new(config).unwrap();
app.focus = Pane::Search;
// search slot intentionally None.
let outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}
/// p9-fb-10: typing Hangul into Search input advances cursor by 2
/// per char and round-trips through the buffer correctly.
#[test]
fn hangul_typing_in_search_input_advances_cursor_by_two_per_char() {
let mut app = fresh_app();
// Switch to search and ensure Insert mode so chars type.
app.focus = Pane::Search;
app.mode = kebab_tui::Mode::auto_for(Pane::Search);
for ch in "한글".chars() {
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "한글");
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 4);
// Backspace pops the trailing Hangul char and rewinds 2 cols.
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "");
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 2);
}
/// p9-fb-21: chunk-inspect was rebound from `i` to `o` so `i`
/// could become the universal Normal→Insert toggle. Pin the new
/// `o` key — Normal mode + at least one hit + selected → SwitchPane(Inspect).
#[test]
fn o_in_normal_with_hits_enters_inspect() {
let mut app = fresh_app();
app.focus = Pane::Search;
app.mode = Mode::Normal;
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(1, "a.md", "snippet", line_citation("a.md", 1))];
s.selected_hit = 0;
let outcome = kebab_tui::handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Inspect));
}
/// p9-fb-21: `o` with empty hits is a no-op (Continue) — do not
/// enter Inspect with no target.
#[test]
fn o_in_normal_with_empty_hits_is_continue() {
let mut app = fresh_app();
app.focus = Pane::Search;
app.mode = Mode::Normal;
let outcome = kebab_tui::handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Continue);
}
/// p9-fb-21: in Insert mode, `o` types as a regular char (the
/// chunk-inspect intercept only fires in Normal). Pin so a future
/// regression that drops the `is_normal` guard would fail this.
#[test]
fn o_in_insert_types_into_input() {
let mut app = fresh_app();
app.focus = Pane::Search;
app.mode = Mode::Insert;
let outcome = kebab_tui::handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Continue);
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "o");
}

View File

@@ -1,190 +0,0 @@
//! p9-fb-24: integration tests for the always-visible status bar.
use kebab_config::Config;
use kebab_tui::{App, Pane};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
fn fresh_app(focus: Pane) -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-status-bar-tests-noop".to_string();
config.workspace.root = Some("/tmp/kebab-tui-status-bar-tests-noop/workspace".to_string());
let mut app = App::new(config).expect("App::new");
app.focus = focus;
app
}
fn render_to_string(app: &App, width: u16) -> String {
let backend = TestBackend::new(width, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| kebab_tui::render_status_bar(f, Rect::new(0, 0, width, 1), app))
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn status_bar_shows_kebab_version_first() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
let expected = format!("kebab v{}", env!("CARGO_PKG_VERSION"));
assert!(
rendered.contains(&expected),
"version not in status bar: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_pane_label() {
for (focus, expected) in [
(Pane::Library, "Library"),
(Pane::Search, "Search"),
(Pane::Ask, "Ask"),
(Pane::Inspect, "Inspect"),
(Pane::Jobs, "Jobs"),
] {
let app = fresh_app(focus);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains(expected),
"pane label '{expected}' not visible for focus={focus:?}: rendered=\n{rendered}"
);
}
}
#[test]
fn status_bar_shows_doc_count() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("0 docs"),
"doc count missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_idle_when_no_dynamic_state() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("idle"),
"idle marker missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_streaming_when_ask_streaming() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState {
streaming: true,
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("streaming…"),
"streaming marker missing: rendered=\n{rendered}"
);
assert!(
!rendered.contains("idle"),
"idle should not appear when streaming: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_searching_when_search_worker_active() {
let mut app = fresh_app(Pane::Search);
app.search = Some(kebab_tui::SearchState {
searching: true,
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("searching…"),
"searching marker missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_ask_conv_id_when_in_ask_with_context() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState {
conversation_id: Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()),
current_question: Some("test?".to_string()),
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("conv_a3f9b2c1…"),
"8-hex prefix conv id missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_omits_conv_id_when_ask_has_no_context() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState::default());
let rendered = render_to_string(&app, 100);
assert!(
!rendered.contains("conv_"),
"conv id should not appear without context: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_omits_conv_id_outside_ask() {
let mut app = fresh_app(Pane::Library);
app.ask = Some(kebab_tui::AskState {
conversation_id: Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()),
current_question: Some("test?".to_string()),
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
!rendered.contains("conv_"),
"conv id leaked outside Ask pane: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_ingest_progress_in_dynamic_slot() {
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let mut app = fresh_app(Pane::Library);
let (_tx, rx) = std::sync::mpsc::channel();
app.ingest_state = Some(kebab_tui::IngestState {
rx,
counts: kebab_app::AggregateCounts {
scanned: 40,
..Default::default()
},
current_path: Some("notes/foo.md".to_string()),
current_idx: 12,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: None,
cancel: Arc::new(AtomicBool::new(false)),
});
let rendered = render_to_string(&app, 200);
assert!(
rendered.contains("12/40"),
"ingest progress fragment missing: rendered=\n{rendered}"
);
assert!(
rendered.contains("30%"),
"ingest percentage missing: rendered=\n{rendered}"
);
assert!(
!rendered.contains("idle"),
"idle should not appear during ingest: rendered=\n{rendered}"
);
}

View File

@@ -4,7 +4,7 @@
## 한 줄
Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-cli`, `kebab-tui`, 미래 `kebab-desktop`) 가 facade crate (`kebab-app`) 만 참조. 도메인 / 파이프라인 / 저장소 / 외부 어댑터가 명확한 boundary 로 분리.
Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-cli`, 미래 `kebab-desktop`) 가 facade crate (`kebab-app`) 만 참조. 도메인 / 파이프라인 / 저장소 / 외부 어댑터가 명확한 boundary 로 분리.
## 핵심 기술 결정 (lock 됨)
@@ -27,7 +27,6 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
| PDF parser | `lopdf` per-page 텍스트 + scanned-page image extract (`page_image::extract_dctdecode_page_image`, v0.20.0). `chunker_version = "pdf-page-v1"` 하드코딩 (HOTFIXES P7-3). `parser_version = "pdf-text-v1"` 보존 (v0.20 OCR 후에도) — provenance event 로 OCR 사용 차별화. force-reingest 가 v0.19 indexed scanned PDF 의 재처리에 필요. |
| code parser | `tree-sitter` + `tree-sitter-rust` / `tree-sitter-python` / `tree-sitter-typescript` / `tree-sitter-javascript` / `tree-sitter-go` / `tree-sitter-java` / `tree-sitter-kotlin-ng`**parser-side** (`kebab-parse-code`), chunker-side 아님 (design §6.3). chunker versions: Rust = `code-rust-ast-v1`, Python = `code-python-ast-v1`, TypeScript = `code-ts-ast-v1`, JavaScript = `code-js-ast-v1`, Go = `code-go-ast-v1`, Java = `code-java-ast-v1`, Kotlin = `code-kotlin-ast-v1`. `ast_chunk_max_lines = 200` 상수 고정 (HOTFIXES 2026-05-19 — Chunker trait 이 per-medium config 미노출). Kotlin grammar 은 `tree-sitter-kotlin-ng` 사용 — bare `tree-sitter-kotlin` 은 tree-sitter 0.210.23 에 고착되어 있어 사용 불가. **Tier 2 (p10-2)**: YAML/k8s → `serde_yaml` + `k8s-manifest-resource-v1` (apiVersion+kind per resource), Dockerfile → `dockerfile-file-v1` (whole-file), Cargo.toml/go.mod/.json/.xml/.groovy → `manifest-file-v1` (whole-file). Tier 2 chunkers live in `kebab-chunk`; no tree-sitter grammar needed (structure from file type, not AST). **Tier 3 (p10-3)**: shell scripts (`.sh`/`.bash`/`.zsh`) direct → `code-text-paragraph-v1` (blank-line paragraph segmentation + 80-line / 20-overlap line-window for oversize). Same chunker also serves as fallback when Tier 1/2 emit 0 chunks or Err — non-k8s YAML / invalid YAML / AST extractor failures all picked up. symbol = None; lang preserved from input doc. **Tier 1 family complete (p10-1D)**: C (`tree-sitter-c`, `code-c-ast-v1`, `.c`/`.h`) + C++ (`tree-sitter-cpp`, `code-cpp-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx`). C symbol = function name only; C++ symbol = `namespace::Class::method` (recursive nesting). `.h` 가 C++ syntax 만나면 tree-sitter-c parse 실패 → Tier 3 fallback. |
| symbol path 형식 | workspace path → module path: Python = dotted prefix (`kebab_eval.metrics.compute_mrr`), TypeScript/JavaScript = slash-style prefix (`src/Foo.Foo.search`), Go = `package.Func` / `package.(*Receiver).Method`, Java/Kotlin = `com.foo.Foo.bar` (패키지+클래스+메서드/필드), C = 함수명, C++ = `namespace::Class::method`. Rust 1A-2 는 file-scope nesting 만 (workspace prefix 없음, 비일관 수용 — HOTFIXES 2026-05-20). code chunk 은 `citation.kind = "code"` + `citation.lang` + `symbol` + line range, SearchHit 에 `code_lang` + `repo`(`.git` walk-up 디렉토리명) backfill. |
| TUI | Ratatui + crossterm — Library / Search / Ask / Inspect 패널 (P9-1~4 완료), vim-style NORMAL/INSERT 모드 + `F1` cheatsheet (런타임 키 매핑 권위 소스) |
| Desktop | Tauri 2 + `pdfjs-dist` (native PDF render backend 금지) — P9-5 |
| citation 형식 | URI fragment (`path#L12-L34` / `path#p=12` / `path#xywh=0,0,100,50`, W3C Media Fragments) |
| ID 생성 | `blake3(canonical_json(tuple))[..32]` hex |
@@ -47,7 +46,6 @@ Cargo workspace, 함수 호출 기반 모듈러 모놀리스. UI binary (`kebab-
flowchart TB
subgraph UI ["UI binary"]
cli["kebab-cli"]
tui["kebab-tui"]
mcp["kebab-mcp<br/>(P9-FB-30)"]
desktop["kebab-desktop<br/>(P9-5)"]
end
@@ -80,7 +78,6 @@ flowchart TB
core["kebab-core<br/>(domain types)"]
cli --> app
tui --> app
mcp --> app
desktop --> app
@@ -209,7 +206,6 @@ kebab/
│ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1)
│ ├── kebab-parse-code/ # tree-sitter AST extractors: Rust (P10-1A-2), Python + TypeScript + JavaScript (P10-1B), Go (P10-1C-Go), Java + Kotlin (P10-1C-JK — java.rs + kotlin.rs), C + C++ (P10-1D — c.rs + cpp.rs); chunker lives in kebab-chunk
│ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체). src/derivation_payload.rs = 캐시 payload 인코딩 (v0.21.0)
│ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1)
│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, ask (P9-FB-30)
│ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화)
├── migrations/ # SQLite refinery V001..V014 (V012 = derivation_cache v0.21.0, V013 = drop chunk_aliases v0.25.0, V014 = documents.source_id v0.29.0)