refactor: kebab-tui crate + tui 서브커맨드 제거 (UI=CLI/MCP)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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]`
|
||||
|
||||
12
HANDOFF.md
12
HANDOFF.md
@@ -19,7 +19,7 @@ P0–P5 + 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 후보
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -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)를 무효화한다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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.21–0.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)
|
||||
|
||||
Reference in New Issue
Block a user