From 0c76677131945a228a4194124278a8cffa2d44f2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:11:17 +0000 Subject: [PATCH 01/15] spec(p9-fb-24): TUI status/key bar + Library header + page scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도그푸딩 피드백 3 건 (Library 컬럼 헤더 부재, PgUp/PgDn 페이지 스크롤, 모든 모드에서 항상 떠 있는 상태바 + 키 안내바 + 버전 정보) 을 단일 spec 으로 묶음. 설계 핵심: - bottom 영역을 2 row 로 분할: 윗줄 = 상태바 (`kebab v0.1.0 │ pane │ doc_count │ 동적 상태`), 아랫줄 = 기존 footer_hints 그대로 이전. - ingest progress 의 dedicated row 를 status bar 의 동적 영역으로 흡수 (시각적 source 단일화). - Library `List` 위에 `format_doc_header` 헤더 row 추가 (TITLE / TAGS / UPDATED / CHUNKS, display-width 정렬, Role::Heading). - Ask + Inspect 양쪽에 PgUp/PgDn (fixed step 10). Ask 는 j/k 와 동일 하게 follow_tail = false 로 freeze. p9-fb-13 (footer 단행 row) + p9-fb-03 (ingest dedicated row) frozen spec 들과 layout 충돌. frozen 텍스트는 그대로 두고 본 spec + 머지 후 HOTFIXES `2026-05-04 — p9-fb-24` 항목이 live source of truth. Spec status `planned`. 다음 단계: writing-plans skill 로 implementation plan 작성. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6-05-04-p9-fb-24-tui-affordances-design.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md diff --git a/docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md b/docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md new file mode 100644 index 0000000..425c724 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md @@ -0,0 +1,141 @@ +# p9-fb-24 — TUI status/key bar + Library header + page scroll + +**Date**: 2026-05-04 +**Status**: planned +**Audience**: kebab-tui implementer / reviewer. +**Source feedback**: 사용자 도그푸딩 2026-05-04 — (1) Library 컬럼이 무엇을 뜻하는지 헤더 부재, (2) Ask transcript / Inspect 둘 다 페이지 단위 스크롤 키 필요, (3) 모든 모드에서 항상 떠 있는 상태바 + 키 안내바 (버전 정보 포함) 가 있으면 좋겠다. + +## Goal + +- bottom 영역을 2 row 로 분할: 윗줄 = 항상 떠 있는 상태바 (`kebab v0.1.0 │ pane │ docs 수 │ 동적 상태`), 아랫줄 = 기존 mode-aware 키 안내 (현 `footer_hints` 그대로 이전). +- ingest progress 의 dedicated row 를 새 status bar 의 동적 상태 영역으로 흡수 — 시각적 source 단일화. +- Library 의 List 위에 컬럼 헤더 row 추가 (TITLE / TAGS / UPDATED / CHUNKS, display-width 정렬, `Role::Heading` 색). +- Ask + Inspect 양쪽에 `PgUp` / `PgDn` 페이지 스크롤 (fixed step 10). Ask 의 PgDn / PgUp 은 `j` / `k` 와 동일하게 `follow_tail = false` 로 freeze. + +## Non-goals + +- viewport-aware 페이지 스텝 (fixed step 10 으로 시작, 후속 task 에서 viewport-relative 업그레이드 가능). +- Library `List` → `Table` 위젯 마이그레이션 (별 header row + 기존 List 유지 — sort/정렬 인디케이터 필요할 때 후속). +- 키 안내바 콘텐츠 확장 — 현 `footer_hints` 출력 그대로 이전, 키 추가/제거 없음. +- conversation id 풀 표시 — Ask 진입 시 8 자 prefix 만. + +## Allowed dependencies + +- `kebab-tui` 자체 (ratatui 0.28, crossterm 0.28). 신규 crate 없음. +- `env!("CARGO_PKG_VERSION")` (compile-time, std). + +## Public surface + +- `kebab_tui::run::render_status_bar(f, area, app)` 신규 (pub(crate)). +- `kebab_tui::run::render_key_hints(f, area, app)` — 기존 `render_footer` rename. 동작 동일. +- `kebab_tui::library::format_doc_header(area_width: u16) -> ratatui::text::Line<'static>` 신규. +- 기존 `IngestState` 의 dedicated render path 제거 — status bar 가 흡수. + +## Behavior contract + +### 상태바 (line 1) + +좌→우 fragment, `│` separator (단순 ASCII pipe + 양쪽 공백): + +``` +kebab v0.1.0 │ docs │ [conv_… │ ] +``` + +- **버전**: `env!("CARGO_PKG_VERSION")` — workspace pinned 단일 값. +- **pane 라벨** (영문): `Library` / `Search` / `Ask` / `Inspect` / `Jobs`. +- **doc_count**: `app.library.inner.docs.len()` 직접 읽음. Library 의 `needs_refresh` 사이클이 이미 갱신 보장. +- **conversation_id (Ask 전용)**: `current_question.is_some() || !turns.is_empty()` 일 때만. 표시 form: `conv_<8 hex chars>…` (전체 32 hex 의 head 8 자 + ellipsis). +- **dynamic_status**: 우선순위 cascade — 한 번에 하나만: + 1. `streaming…` — `app.ask.as_ref().map(|s| s.streaming).unwrap_or(false)` + 2. `searching…` — `app.search.as_ref().map(|s| s.is_searching()).unwrap_or(false)` + 3. `indexing N/M (P%)` — `app.ingest_state.is_some() && !ingest_state.is_terminal()`. terminal (Completed/Aborted) 후 final 메시지 (`indexed N+M (T)` / `aborted at N/M`) 3 초 hold 후 `idle`. + 4. `idle` — fallback. + +스타일: 전체 `Role::Hint`, dynamic_status 만 우선순위별 색 (streaming/searching = `Role::Heading`, indexing = `Role::Warning`, idle = `Role::Hint`). + +### 키 안내바 (line 2) + +기존 `footer_hints(focus, mode, filter_open)` 출력 그대로 single-line `Paragraph`. `Role::Hint`. wrap 시 자연스럽게 다음 줄 (단, 권장 환경 80+ 컬럼에서 wrap 거의 발생 안 함). + +### 레이아웃 + +`render_root` Constraint 변경: + +``` +이전: [Length(3) header, Min(0) main, Length(1) ingest_status_optional, Length(1) footer] +이후: [Length(3) header, Min(0) main, Length(1) status_bar, Length(1) key_hints] +``` + +- `ingest_status_optional` 제거. status bar 가 흡수. +- error overlay 는 modal layer (Layout 영향 없음) — 그대로. + +콘텐츠 영역 손실: 0 ~ 1 row (이전엔 ingest 진행 시만 1 row 차지, 평소엔 0 — 평균 +0.x row 손실). + +### Library 헤더 + +``` +┌Library — 42 docs──────────────────────────────────────┐ +│TITLE TAGS UPDATED CHUNKS│ +│친애하는 미스터 최 rust,prog 2025-04 12 │ +│architecture-spec docs 2025-05 47 │ +│... │ +└──────────────────────────────────────────────────────┘ +``` + +- Block `inner` 안 vertical Layout 두 단계: `Length(1)` 헤더 paragraph + `Min(0)` List. +- `format_doc_header(area_width)` 가 `format_doc_row` 와 동일 컬럼 폭 계산식 사용 (display-width 정렬, TAGS_COL_W=12, UPDATED 10, CHUNKS unpadded). +- 헤더 라벨: `TITLE` / `TAGS` / `UPDATED` / `CHUNKS` (영문 cap). +- 색: `theme.style(Role::Heading)` (Bold cyan/팔레트별). +- `docs.is_empty()` 상태에서도 헤더는 표시. List 영역에 "(no docs)" hint. + +### PgUp / PgDn + +`const PAGE_STEP: u16 = 10;` 모듈 상수 (kebab-tui::input 또는 별 `pager.rs`). + +**Ask** (`crates/kebab-tui/src/ask.rs::handle_key_ask`): + +- `KeyCode::PageDown`: `s.scroll = s.scroll.saturating_add(PAGE_STEP); s.follow_tail = false;` +- `KeyCode::PageUp`: `s.scroll = s.scroll.saturating_sub(PAGE_STEP); s.follow_tail = false;` +- mode 무관 (Insert / Normal 양쪽). 기존 `j`/`k` 와 동일 의미 (자동 tail freeze). + +**Inspect** (`crates/kebab-tui/src/inspect.rs`): + +- 기존 +/-10 hardcode 를 `PAGE_STEP` 상수 참조로 교체. 동작 동일 (10 → 10). + +cheatsheet popup Ask section 에 `PgUp / PgDn` row 추가, Inspect 는 기존 row 유지 (이미 명시). + +## Tests + +### 신규 단위 / 통합 + +- `render_status_bar` snapshot — 5 pane × 4 dynamic state (idle / streaming / searching / indexing) ≈ 8~10 case. 각 case 에서 version + pane + doc_count + dynamic 텍스트 visible. +- `render_status_bar` Ask conv_id case — `current_question.is_some()` 시 `conv_<8hex>…` 형태 visible. +- `render_status_bar` ingest absorb — `IngestState::Indexing { current, total }` 일 때 `indexing 12/40 (30%)` 정확. +- `format_doc_header` 단위 — 라벨 + display-width 정렬이 `format_doc_row` 와 boundary 일치. +- `library` integration — TestBackend, docs 3 fixture, header row + data row 모두 visible. Hangul 제목 정렬 회귀 확인. +- Ask `PageDown` / `PageUp` 신규 통합 — fixed step 10, `follow_tail` `false` 변경. +- Inspect `PageDown` / `PageUp` 회귀 — `PAGE_STEP` 상수 path. + +### 기존 영향 + +- `footer_hints` 8 단위 테스트 — rename 외 무수정 통과. +- 기존 ingest progress render 테스트 — status bar 통합 후 텍스트 visible 검증으로 재작성 (위치만 이동, 콘텐츠 동일). +- p9-fb-22 Ask follow-tail 통합 테스트 — `j`/`k` / `Shift-G` / Ctrl-L / submission 시 `follow_tail` 동작 그대로 통과 (PgUp/PgDn 만 추가). + +## Spec contract impact + +- **p9-fb-13 follow-up (footer 단행 row)** frozen 텍스트와 충돌. frozen 그대로 두고 본 spec + HOTFIXES `2026-05-04 — p9-fb-24` 항목이 live source of truth. +- **p9-fb-03 (TUI background ingest)** 의 dedicated status row 가 status bar 의 동적 영역으로 흡수 — 시각적 위치 변경, 콘텐츠 동등. HOTFIXES 항목 cross-link. +- **p9-fb-22 (cursor + follow-tail)** Ask 키 매핑 보존 + PgUp/PgDn 추가 (충돌 없음). +- **p9-fb-21 (cheatsheet)** popup 의 Ask section 에 `PgUp / PgDn` row 추가. + +## Risks / notes + +- **80 컬럼 wrap**: `kebab v0.1.0 │ Library │ 42 docs │ idle` ≈ 50 자, Ask conv_id 추가 시 ≈ 60 자. 80 컬럼 안전. 60 컬럼 미만 환경은 status bar wrap → 임시 한 줄 추가 차지. kebab TUI 권장 환경 80+ 가정. +- **콘텐츠 영역 1 row 손실**: 24 row 작은 터미널에서 transcript 영역 1 row 짧아짐. 실사용 무시 수준. +- **dynamic status priority cascade**: 동시 active 상태 (streaming + indexing 등) 시 streaming 우선 표기. 사용자 인지 우선순위와 일치 (포커스 = Ask 면 streaming, ingest 는 background). +- **`PAGE_STEP = 10` magic**: viewport 와 무관 fixed. 24 row 작은 터미널에서 한 페이지 = 10 row 가 viewport 보다 큼 (overflow 무해). 80 row 큰 터미널에서는 한 페이지가 viewport 보다 작음 (느린 페이징). 후속 task 가 viewport-aware 로 업그레이드 시 본 spec 의 동작은 frozen. + +## Live deviations + +추후 발견되는 deviation 은 `tasks/HOTFIXES.md` 의 `2026-05-04 — p9-fb-24` 항목에 dated 로그로 추가. spec 자체는 frozen. -- 2.49.1 From ce6e7d2bb90d4f5fe3bece6d7f37053b5215aa96 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:34:51 +0000 Subject: [PATCH 02/15] =?UTF-8?q?plan(p9-fb-24):=20TDD=20implementation=20?= =?UTF-8?q?plan=20=E2=80=94=2011=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec → 11-step plan, TDD per task (failing test → impl → pass → commit). Tasks: 1. pager module + PAGE_STEP constant (single edit point) 2. Inspect refactor — replace literal 10 with PAGE_STEP (with regression tests pinning behavior) 3. Ask PgUp/PgDn (mode-agnostic, follow_tail freeze) 4. Library format_doc_header (column-width math reuses format_doc_row) 5. Library header wired into render_doc_list (Layout split) 6. render_status_bar — version + pane + docs + idle (cascade structure) 7. Status bar cascade — streaming / searching / Ask conv_id branches 8. Status bar — ingest progress absorb test 9. render_root layout — drop conditional ingest row, render status + keys (rename render_footer → render_key_hints, delete render_ingest_status) 10. cheatsheet Ask gains PgUp/PgDn row 11. Docs sync — README + HANDOFF + HOTFIXES + INDEX + per-task spec Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-04-p9-fb-24-tui-affordances.md | 1194 +++++++++++++++++ 1 file changed, 1194 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-p9-fb-24-tui-affordances.md diff --git a/docs/superpowers/plans/2026-05-04-p9-fb-24-tui-affordances.md b/docs/superpowers/plans/2026-05-04-p9-fb-24-tui-affordances.md new file mode 100644 index 0000000..0e60cc2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-p9-fb-24-tui-affordances.md @@ -0,0 +1,1194 @@ +# p9-fb-24 — TUI status/key bar + Library header + page scroll Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the bottom screen area into a 2-row footer (always-visible status bar + key hint bar), absorb the ingest progress row into the new status bar, add a column header above the Library list, and add `PgUp`/`PgDn` page scrolling to the Ask transcript with a single `PAGE_STEP` constant shared by Inspect. + +**Architecture:** Single-crate change inside `kebab-tui`. The status bar is a new pure render function that pulls live state directly from `App` (pane label, doc count, version, dynamic-state cascade across `streaming` / `searching` / `indexing` / `idle`). The Library header reuses the existing `format_doc_row` column-width math via a new `format_doc_header(area_width)` function and is composed via a vertical `Layout` (header row + `List`). Page-scroll constants live in one module so future viewport-aware refinement has a single edit point. + +**Tech Stack:** Rust 2024, ratatui 0.28, crossterm 0.28. No new deps. + +**Spec:** `docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md` + +--- + +## File Structure + +**Created:** +- `crates/kebab-tui/src/pager.rs` — `pub(crate) const PAGE_STEP: u16 = 10;` + future viewport-aware helper hook. +- `crates/kebab-tui/tests/status_bar.rs` — integration tests for `render_status_bar`. + +**Modified:** +- `crates/kebab-tui/src/lib.rs` — add `mod pager;`. +- `crates/kebab-tui/src/run.rs` — drop the conditional 4-row layout, render `render_status_bar` + `render_key_hints` (renamed from `render_footer`) instead, delete the `render_ingest_status` private fn (absorbed). +- `crates/kebab-tui/src/library.rs` — new `format_doc_header(area_width: u16) -> Line<'static>` + `render_doc_list` splits its inner area into header row + List. +- `crates/kebab-tui/src/ask.rs` — `KeyCode::PageDown` / `PageUp` arms; both flip `follow_tail = false` and shift `s.scroll` by `pager::PAGE_STEP`. +- `crates/kebab-tui/src/inspect.rs` — replace the literal `10` in `PageDown` / `PageUp` with `pager::PAGE_STEP`. +- `crates/kebab-tui/src/cheatsheet.rs` — Ask section gains `PgUp / PgDn` row. +- `crates/kebab-tui/tests/library.rs` — extend the existing render test to assert the header row text is visible. +- `crates/kebab-tui/tests/ask.rs` — new tests for `PageUp` / `PageDown` (sets `scroll` by 10, flips `follow_tail` to false). + +--- + +### Task 1: `pager` module — single source for `PAGE_STEP` + +**Files:** +- Create: `crates/kebab-tui/src/pager.rs` +- Modify: `crates/kebab-tui/src/lib.rs` + +- [ ] **Step 1: Create the new module file** + +```rust +// crates/kebab-tui/src/pager.rs +//! 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; +``` + +- [ ] **Step 2: Wire the module into the crate** + +Open `crates/kebab-tui/src/lib.rs`, find the existing `mod input;` line, add `mod pager;` directly after it (alphabetical). Do NOT re-export it — `pager::PAGE_STEP` stays `pub(crate)`. + +- [ ] **Step 3: Verify the build** + +Run: `cargo build -p kebab-tui` +Expected: `Finished dev profile`. No warnings. + +- [ ] **Step 4: Commit** + +```bash +git add crates/kebab-tui/src/pager.rs crates/kebab-tui/src/lib.rs +git commit -m "feat(kebab-tui): p9-fb-24 task 1 — pager module + PAGE_STEP constant" +``` + +--- + +### Task 2: Refactor Inspect to use `PAGE_STEP` + +**Files:** +- Modify: `crates/kebab-tui/src/inspect.rs:428-435` + +- [ ] **Step 1: Pin existing Inspect PgUp/PgDn behaviour with a regression test** + +Open `crates/kebab-tui/tests/inspect.rs`. Find the existing `#[test]` functions (look for `j_scrolls_down` or similar). Append: + +```rust +/// 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. +#[test] +fn page_down_scrolls_by_ten_in_inspect() { + let mut app = fresh_app_with_inspect(); + 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_with_inspect(); + 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); + // Walk past zero — saturating, not panicking. + 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); +} +``` + +If `fresh_app_with_inspect` does not exist, scan the top of `tests/inspect.rs` for the existing helper (likely named `fresh_app` or similar) and use that name. The pattern follows the existing tests — copy whatever helper they use. + +- [ ] **Step 2: Run the new tests against current code (literal `10`)** + +Run: `cargo test -p kebab-tui --test inspect page_` +Expected: PASS. The current implementation already uses `10`; the tests pin the value so the constant swap is a no-op semantically. + +- [ ] **Step 3: Replace literals with `PAGE_STEP`** + +Open `crates/kebab-tui/src/inspect.rs`. Find the lines with `s.scroll.saturating_add(10)` and `s.scroll.saturating_sub(10)` (PageDown / PageUp arms, around lines 428-435). Replace: + +```rust + (KeyCode::PageDown, _) => { + let s = state.inspect.as_mut().unwrap(); + s.scroll = s.scroll.saturating_add(crate::pager::PAGE_STEP); + KeyOutcome::Continue + } + (KeyCode::PageUp, _) => { + let s = state.inspect.as_mut().unwrap(); + s.scroll = s.scroll.saturating_sub(crate::pager::PAGE_STEP); + KeyOutcome::Continue + } +``` + +(If the surrounding code structure differs — e.g. `s` is bound earlier — keep the same overall control flow and only swap the literal `10` for `crate::pager::PAGE_STEP`.) + +- [ ] **Step 4: Run the regression tests against the refactored code** + +Run: `cargo test -p kebab-tui --test inspect page_` +Expected: PASS. Same behaviour, now via the constant. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-tui/src/inspect.rs crates/kebab-tui/tests/inspect.rs +git commit -m "refactor(kebab-tui): p9-fb-24 task 2 — Inspect PgUp/PgDn via pager::PAGE_STEP" +``` + +--- + +### Task 3: Add Ask PgUp/PgDn + +**Files:** +- Modify: `crates/kebab-tui/src/ask.rs` (key dispatch in `handle_key_ask`) +- Modify: `crates/kebab-tui/tests/ask.rs` (new integration tests) + +- [ ] **Step 1: Write the failing tests** + +Open `crates/kebab-tui/tests/ask.rs`. Append (after the existing `follow_tail_renders_tail_when_transcript_overflows` test): + +```rust +/// p9-fb-24: PgDn advances Ask scroll by `PAGE_STEP` (= 10) and +/// disengages follow-tail (matches `j` semantics — manual scroll = +/// freeze). +#[test] +fn page_down_advances_scroll_and_freezes_follow_tail_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + let outcome = handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::Continue); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.scroll, 10, "PgDn shifts scroll by PAGE_STEP"); + assert!(!s.follow_tail, "PgDn freezes follow_tail like j/k"); +} + +/// p9-fb-24: PgUp rewinds Ask scroll by `PAGE_STEP` (saturating at 0) +/// and disengages follow-tail. +#[test] +fn page_up_rewinds_scroll_saturating_and_freezes_follow_tail_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + app.ask.as_mut().unwrap().scroll = 25; + app.ask.as_mut().unwrap().follow_tail = true; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + ); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.scroll, 15); + assert!(!s.follow_tail); + // Walk past zero — saturating, not panicking. + app.ask.as_mut().unwrap().scroll = 3; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().scroll, 0); +} + +/// p9-fb-24: PgUp / PgDn fire from BOTH Insert and Normal modes +/// (physical keys, no typing ambiguity — same as Left/Right/Home/End +/// from p9-fb-22). +#[test] +fn page_keys_fire_from_insert_mode_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Insert; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().scroll, 10); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p kebab-tui --test ask page_` +Expected: FAIL — `scroll` stays `0` because `KeyCode::PageDown` / `PageUp` are not yet matched (fall through to the `_ => KeyOutcome::Continue` arm). + +- [ ] **Step 3: Add the PageDown / PageUp arms to `handle_key_ask`** + +Open `crates/kebab-tui/src/ask.rs`. Find the existing `KeyCode::Delete` arm (around line 421, added in p9-fb-22). Insert the new arms directly after it, before the `KeyCode::Char(c)` Insert-typing arm: + +```rust + // 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 + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p kebab-tui --test ask page_` +Expected: PASS (3/3). + +- [ ] **Step 5: Run the full kebab-tui suite to confirm no regressions** + +Run: `cargo test -p kebab-tui` +Expected: All tests pass. p9-fb-22's `follow_tail` / `j` / `k` / `Shift-G` tests must still pass — the new arms do not touch them. + +- [ ] **Step 6: Commit** + +```bash +git add crates/kebab-tui/src/ask.rs crates/kebab-tui/tests/ask.rs +git commit -m "feat(kebab-tui): p9-fb-24 task 3 — Ask PgUp/PgDn page scroll" +``` + +--- + +### Task 4: Library column header — `format_doc_header` function + +**Files:** +- Modify: `crates/kebab-tui/src/library.rs` (new `format_doc_header` + unit test) + +- [ ] **Step 1: Write the failing test** + +Open `crates/kebab-tui/src/library.rs`. Find the existing `#[cfg(test)] mod tests` block (around line 470+, contains `format_doc_row_pads_by_display_width_for_hangul_title`). Append a new test inside the same module: + +```rust + /// 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(); + // Header text contains every column label. + 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"); + // Header column boundaries match a representative row. + // TAGS label starts at the same column as a row's tags column. + 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"); + // Both labels are display-width-aligned via the same math, so + // the header label starts at *or before* the row's data — + // never after (which would imply the header drifted right). + 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}" + ); + } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p kebab-tui --lib format_doc_header` +Expected: FAIL — `format_doc_header` does not exist. + +- [ ] **Step 3: Implement `format_doc_header`** + +Still in `crates/kebab-tui/src/library.rs`, directly above the existing `pub(crate) fn format_doc_row` (around line 226), insert the new function: + +```rust +/// 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 TAGS 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) +} +``` + +If `Line` is not yet imported in this file, add `use ratatui::text::Line;` to the existing imports at the top (it is already imported in nearby render fns; copy the same path). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p kebab-tui --lib format_doc_header` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-tui/src/library.rs +git commit -m "feat(kebab-tui): p9-fb-24 task 4 — Library format_doc_header" +``` + +--- + +### Task 5: Library column header — wire into `render_doc_list` + +**Files:** +- Modify: `crates/kebab-tui/src/library.rs` (`render_doc_list`) +- Modify: `crates/kebab-tui/tests/library.rs` (extend or add render test) + +- [ ] **Step 1: Write the failing integration test** + +Open `crates/kebab-tui/tests/library.rs`. If a render-fixture test already exists (look for `TestBackend` + `render_library`), extend it to assert the header text is visible. Otherwise, append: + +```rust +/// 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 mut app = fresh_app_with_three_docs(); + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| kebab_tui::render_library(f, Rect::new(0, 0, 80, 20), &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::() + }) + .collect::>() + .join("\n"); + assert!( + rendered.contains("TITLE") && rendered.contains("TAGS") + && rendered.contains("UPDATED") && rendered.contains("CHUNKS"), + "header row labels not visible in:\n{rendered}" + ); + // Header sits ABOVE at least one data row — find a Y where + // TITLE appears, then ensure a row below has actual doc content. + let title_line = rendered + .lines() + .position(|line| line.contains("TITLE")) + .expect("TITLE in some row"); + let after_header = rendered.lines().skip(title_line + 1).collect::>(); + assert!( + after_header.iter().any(|line| line.contains("doc-")), + "no data rows after header:\n{rendered}" + ); + // Suppress unused warning if app is otherwise unused. + let _ = &app; +} +``` + +If `fresh_app_with_three_docs` does not exist in this file, look at the pre-existing fixture fn (likely `fresh_app` or `app_with_docs`) and either reuse it or add a small helper near the top of the test file: + +```rust +fn fresh_app_with_three_docs() -> kebab_tui::App { + let mut config = kebab_config::Config::defaults(); + config.storage.data_dir = "/tmp/kebab-tui-library-tests-noop".to_string(); + let mut app = kebab_tui::App::new(config).expect("App::new"); + app.library.inner.docs = vec![ + kebab_core::DocSummary { + doc_id: kebab_core::DocumentId("d1".into()), + title: "doc-alpha".to_string(), + tags: vec!["rust".to_string()], + updated_at: time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), + chunk_count: 5, + // any remaining fields per current DocSummary shape + ..Default::default() + }, + kebab_core::DocSummary { + doc_id: kebab_core::DocumentId("d2".into()), + title: "doc-beta".to_string(), + tags: vec!["docs".to_string()], + updated_at: time::OffsetDateTime::from_unix_timestamp(1_700_001_000).unwrap(), + chunk_count: 12, + ..Default::default() + }, + kebab_core::DocSummary { + doc_id: kebab_core::DocumentId("d3".into()), + title: "doc-gamma".to_string(), + tags: vec![], + updated_at: time::OffsetDateTime::from_unix_timestamp(1_700_002_000).unwrap(), + chunk_count: 0, + ..Default::default() + }, + ]; + app +} +``` + +If `DocSummary` does not implement `Default`, hand-fill every field (look at the current struct definition in `kebab-core`). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p kebab-tui --test library library_renders_column_header_row` +Expected: FAIL — header row absent because `render_doc_list` does not render it yet. + +- [ ] **Step 3: Wire the header row into `render_doc_list`** + +Open `crates/kebab-tui/src/library.rs`. Replace the body of `render_doc_list` (around line 192) with: + +```rust +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 = 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); +} +``` + +Note: the `block` is rendered first against `area`, then the inner content (header + list) is drawn into the block-inner area. The `List` no longer takes `.block(block)` — the block is rendered separately so the header row can sit inside it. If `Layout` / `Constraint` / `Direction` are not yet imported in this file's import block, add them: `use ratatui::layout::{Constraint, Direction, Layout};`. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p kebab-tui --test library library_renders_column_header_row` +Expected: PASS. + +- [ ] **Step 5: Run the full library test suite for regressions** + +Run: `cargo test -p kebab-tui --test library` +Expected: All tests pass. The empty-state and Hangul-truncate tests must still hold. + +- [ ] **Step 6: Commit** + +```bash +git add crates/kebab-tui/src/library.rs crates/kebab-tui/tests/library.rs +git commit -m "feat(kebab-tui): p9-fb-24 task 5 — Library column header row" +``` + +--- + +### Task 6: Status bar — pane label, version, doc count, idle/streaming/searching cascade + +**Files:** +- Modify: `crates/kebab-tui/src/run.rs` (new `render_status_bar`, rename `render_footer` → `render_key_hints`, drop `render_ingest_status` + conditional layout) +- Create: `crates/kebab-tui/tests/status_bar.rs` + +- [ ] **Step 1: Write failing tests for the new `render_status_bar`** + +Create `crates/kebab-tui/tests/status_bar.rs` with the full test suite: + +```rust +//! 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 = "/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::() + }) + .collect::>() + .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); + // fresh_app's library starts with 0 docs. + 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}" + ); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p kebab-tui --test status_bar` +Expected: FAIL — `kebab_tui::render_status_bar` does not exist (linker error at the test compile step). + +- [ ] **Step 3: Implement `render_status_bar` (idle path)** + +Open `crates/kebab-tui/src/run.rs`. Add the new function directly after `render_header`: + +```rust +/// p9-fb-24: always-visible status bar. Layout (left → right): +/// +/// ``` +/// kebab v0.1.0 │ docs │ [conv_<8hex>… │ ] +/// ``` +/// +/// `` 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` +/// +/// `` 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 = " │ "; + 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().map(|s| s.streaming).unwrap_or(false) { + return "streaming…".to_string(); + } + if app.search.as_ref().map(|s| s.searching).unwrap_or(false) { + return "searching…".to_string(); + } + if let Some(state) = app.ingest_state.as_ref() { + return crate::ingest_progress::status_line(state); + } + "idle".to_string() +} + +/// Short form of the Ask `conversation_id` for the status bar +/// (`conv_…`). Returns `None` when not in Ask, or +/// when the Ask pane has no context (no in-flight question and no +/// completed turns). +fn ask_conv_id_short(app: &App) -> Option { + 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 id = s.conversation_id.as_deref()?; + // ID form is `conv_<32 hex>` per p9-fb-16; show first 8 hex of the + // hex tail (skip the `conv_` prefix to keep the bar compact). + let hex = id.strip_prefix("conv_").unwrap_or(id); + let head: String = hex.chars().take(8).collect(); + Some(format!("conv_{head}…")) +} +``` + +- [ ] **Step 4: Export `render_status_bar` from the crate** + +Open `crates/kebab-tui/src/lib.rs`. Find the `pub use run::cheatsheet_intercept;` (or similar `pub use run::*`) line. Add `pub use run::render_status_bar;` next to it. + +- [ ] **Step 5: Run the new tests** + +Run: `cargo test -p kebab-tui --test status_bar` +Expected: 4/4 PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/kebab-tui/src/run.rs crates/kebab-tui/src/lib.rs crates/kebab-tui/tests/status_bar.rs +git commit -m "feat(kebab-tui): p9-fb-24 task 6 — render_status_bar (version + pane + docs + idle)" +``` + +--- + +### Task 7: Status bar — streaming / searching / Ask conv_id paths + +**Files:** +- Modify: `crates/kebab-tui/tests/status_bar.rs` (new tests) + +This task adds tests that cover the cascade branches already implemented in Task 6. Pure verification — no production code changes if Task 6's `dynamic_status` + `ask_conv_id_short` are correct. Any test failure here is a Task-6 bug to fix in this task's commit. + +- [ ] **Step 1: Add tests for streaming / searching / Ask conv_id** + +Append to `crates/kebab-tui/tests/status_bar.rs`: + +```rust +#[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); + // SearchState::default may not be public; create manually. + let mut search_state = kebab_tui::SearchState::default(); + search_state.searching = true; + app.search = Some(search_state); + 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); + let mut ask_state = kebab_tui::AskState::default(); + ask_state.conversation_id = Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()); + ask_state.current_question = Some("test?".to_string()); + app.ask = Some(ask_state); + 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() { + // Ask pane focused, but no question / no turns yet. + 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); + // Even with an Ask state populated, focus = Library hides conv id. + let mut ask_state = kebab_tui::AskState::default(); + ask_state.conversation_id = Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()); + ask_state.current_question = Some("test?".to_string()); + app.ask = Some(ask_state); + let rendered = render_to_string(&app, 100); + assert!( + !rendered.contains("conv_"), + "conv id leaked outside Ask pane: rendered=\n{rendered}" + ); +} +``` + +If `AskState::default()` is not exposed at the crate root, add `pub use app::AskState;` to `crates/kebab-tui/src/lib.rs`. Same for `SearchState` if needed. + +- [ ] **Step 2: Run the new tests** + +Run: `cargo test -p kebab-tui --test status_bar` +Expected: 9/9 PASS (4 from Task 6 + 5 new). + +If any fail, fix the corresponding branch in `dynamic_status` or `ask_conv_id_short` in `crates/kebab-tui/src/run.rs`. + +- [ ] **Step 3: Commit** + +```bash +git add crates/kebab-tui/src/lib.rs crates/kebab-tui/tests/status_bar.rs +git commit -m "test(kebab-tui): p9-fb-24 task 7 — status bar streaming / searching / conv_id" +``` + +--- + +### Task 8: Status bar — ingest progress absorbed + +**Files:** +- Modify: `crates/kebab-tui/tests/status_bar.rs` + +This task pins that the existing `kebab_tui::ingest_progress::status_line` output appears verbatim in the status bar's dynamic slot when an ingest is in flight, so the absorption (Task 9) is provably equivalent to the pre-fb-24 dedicated row. + +- [ ] **Step 1: Add the ingest absorb test** + +Append to `crates/kebab-tui/tests/status_bar.rs`: + +```rust +#[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); + // Construct a minimal IngestState mirroring an in-flight worker. + // counts.scanned = 40 means "40 assets enumerated"; current_idx = 12 + // means "currently working on asset 12 of 40" → 30%. + 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}" + ); +} +``` + +If `IngestState`, `AggregateCounts`, or `kebab_app` are not at the test-visible re-export path, add the necessary `pub use` lines to `crates/kebab-tui/src/lib.rs` (e.g. `pub use app::IngestState;`). Test imports may need `kebab_app` (path-dep already present in Cargo.toml as a workspace dep — add `kebab-app = { path = "../kebab-app" }` to `[dev-dependencies]` if not already there). + +- [ ] **Step 2: Run the test** + +Run: `cargo test -p kebab-tui --test status_bar status_bar_shows_ingest_progress` +Expected: PASS — `dynamic_status` already calls `ingest_progress::status_line` per Task 6. + +If the test cannot construct an `IngestState` because some field is missing or has a non-`Default` type, hand-fill it from the struct definition in `crates/kebab-tui/src/app.rs`. Do NOT add a new `Default` impl to `IngestState` — the test should reflect a realistic in-flight state. + +- [ ] **Step 3: Commit** + +```bash +git add crates/kebab-tui/tests/status_bar.rs crates/kebab-tui/Cargo.toml +git commit -m "test(kebab-tui): p9-fb-24 task 8 — status bar absorbs ingest progress" +``` + +--- + +### Task 9: Wire status bar + key hint bar into `render_root` (drop ingest row) + +**Files:** +- Modify: `crates/kebab-tui/src/run.rs` (`render_root`, rename `render_footer`, drop `render_ingest_status`) + +- [ ] **Step 1: Restructure `render_root` for the new 4-row layout** + +Open `crates/kebab-tui/src/run.rs`. Replace the body of `render_root` (around line 234) with: + +```rust +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), + // p9-5 Jobs not yet rendered; Library placeholder. + Pane::Jobs => render_library(f, outer[1], app), + } + render_status_bar(f, outer[2], app); + render_key_hints(f, outer[3], app); + 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); + } +} +``` + +- [ ] **Step 2: Rename `render_footer` to `render_key_hints`** + +Still in `crates/kebab-tui/src/run.rs`, find `fn render_footer(f: &mut Frame, area: Rect, app: &App)` (around line 330). Rename it to `render_key_hints`. The body is unchanged. + +- [ ] **Step 3: Delete the obsolete `render_ingest_status`** + +In the same file, delete the entire `fn render_ingest_status` (around lines 283-303). Its content is now covered by `dynamic_status` calling `ingest_progress::status_line`. + +- [ ] **Step 4: Verify the build + full test suite** + +Run: `cargo build -p kebab-tui` +Expected: Finished. No unused-import / dead-code warnings (if any pop up, the corresponding import in `run.rs` — likely the `ingest_progress` direct import — should be cleaned up). + +Run: `cargo test -p kebab-tui` +Expected: All tests pass. The status_bar suite from Tasks 6–8, the library suite, the ask suite, and the existing `footer_hints_tests` (whose function names like `render_footer` may need updating in the test module — the rename is intra-crate, so any caller in `run::tests` must follow). Inspect, Search, Cheatsheet suites unaffected. + +- [ ] **Step 5: Run clippy** + +Run: `cargo clippy -p kebab-tui --all-targets -- -D warnings` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add crates/kebab-tui/src/run.rs +git commit -m "feat(kebab-tui): p9-fb-24 task 9 — render_root uses status bar + key hints (drop ingest row)" +``` + +--- + +### Task 10: Cheatsheet — Ask gains PgUp / PgDn row + +**Files:** +- Modify: `crates/kebab-tui/src/cheatsheet.rs` + +- [ ] **Step 1: Find the existing Ask section in the cheatsheet** + +Open `crates/kebab-tui/src/cheatsheet.rs`. Find the `push_section(&mut lines, &app.theme, "Ask", &[ ... ]);` block (around line 88). It currently lists keys including `j / k` "scroll transcript" and `Shift-G` "jump to bottom". + +- [ ] **Step 2: Add the `PgUp / PgDn` row directly after the `Shift-G` entry** + +Replace the Ask `push_section` array with: + +```rust + push_section(&mut lines, &app.theme, "Ask", &[ + ("type", "question (Insert)"), + ("Enter", "submit"), + ("e", "toggle explain mode (Normal)"), + ("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)"), + ]); +``` + +- [ ] **Step 3: Verify the cheatsheet test still passes** + +Run: `cargo test -p kebab-tui --test cheatsheet` +Expected: All tests pass. The existing `cheatsheet_popup_contains_global_and_pane_sections` test asserts presence of section headings, not row counts. + +- [ ] **Step 4: Commit** + +```bash +git add crates/kebab-tui/src/cheatsheet.rs +git commit -m "docs(kebab-tui): p9-fb-24 task 10 — cheatsheet Ask gains PgUp / PgDn row" +``` + +--- + +### Task 11: Final workspace verification + docs sync + +**Files:** +- Modify: `README.md` +- Modify: `HANDOFF.md` +- Modify: `tasks/HOTFIXES.md` +- Modify: `tasks/INDEX.md` +- Create: `tasks/p9/p9-fb-24-tui-affordances.md` + +- [ ] **Step 1: Run the full workspace test** + +Run: `cargo test --workspace --no-fail-fast -j 1` +Expected: 720+ passed, 0 failed (the pre-fb-24 baseline was 699; this PR adds ~20 tests across status_bar / library / ask). + +- [ ] **Step 2: Run workspace clippy** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. + +- [ ] **Step 3: Update README.md** + +Open `README.md`. Find the `kebab tui` row in the command table (around line 79). Append to the cell: + +``` +모든 모드에서 항상 떠 있는 상태바 — `kebab v docs │ ` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). +``` + +(Place this in the same long-line cell as existing TUI description; do not add a new row.) + +- [ ] **Step 4: Update HANDOFF.md** + +Open `HANDOFF.md`. Find the `## 머지 후 발견된 버그 / 결정 (요약)` section. Add a new entry directly above the `2026-05-04 P9 post-도그푸딩 (p9-fb-22)` row: + +``` +- **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. +``` + +- [ ] **Step 5: Update HOTFIXES.md** + +Open `tasks/HOTFIXES.md`. Add a new entry at the top of the dated list (above the `2026-05-04 — p9-fb-22` entry): + +```markdown +## 2026-05-04 — p9-fb-24 (post-dogfooding): TUI status bar + Library 헤더 + page scroll + +**Source feedback**: 사용자 도그푸딩 2026-05-04 — (1) Library 컬럼이 무엇을 뜻하는지 헤더 부재, (2) Ask 트랜스크립트 / Inspect 둘 다 페이지 단위 스크롤 키 필요, (3) 모든 모드에서 항상 떠 있는 상태바 + 키 안내바 (버전 정보 포함) 가 있으면 좋겠다. + +**Live binding 변경**: + +- bottom 영역을 2 row 로 분할. 윗줄 = status bar (`kebab v docs │ `), 아랫줄 = key hint bar (기존 `footer_hints` 그대로). p9-fb-13 follow-up 의 single-row footer 와 충돌 — frozen spec 텍스트 보존, 본 항목이 live source of truth. +- ingest progress 의 dedicated row (p9-fb-03) 는 status bar 의 dynamic slot 으로 흡수. priority cascade: streaming → searching → indexing → idle. 시각적 위치 변경, 콘텐츠 동등. +- `Paragraph::line_count` 등 unstable feature 추가 없음. +- `crates/kebab-tui/src/pager.rs::PAGE_STEP = 10` 신규. Ask 의 PgUp/PgDn 추가 (mode 무관, `follow_tail = false` flip), Inspect 의 기존 +/-10 hardcode 가 같은 상수 참조로 일원화. +- `format_doc_header(area_width)` 신규 (kebab-tui/src/library.rs). Library 의 doc list 위에 1-row 헤더 (TITLE / TAGS / UPDATED / CHUNKS, display-width 정렬). Block 의 inner area 를 `Layout` 으로 header (Length 1) + list (Min 0) 로 분할. +- cheatsheet popup Ask section 에 `PgUp / PgDn` row 추가 (Inspect 는 이미 명시). + +**Spec contract impact**: p9-fb-13 follow-up (footer 단행 row) + p9-fb-03 (ingest dedicated row) frozen spec 들과 layout 충돌. frozen 텍스트 보존, 본 HOTFIXES 항목 + spec `tasks/p9/p9-fb-24-tui-affordances.md` + design `docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md` 가 live source of truth. + +**Tests added**: 약 20 신규 (status_bar 통합 9 + library 헤더 1 + Ask PgUp/PgDn 3 + Inspect PgUp/PgDn 회귀 2 + format_doc_header 단위 1, 잔여는 cascade branch 별). 기존 720+ 워크스페이스 테스트 무수정 통과. + +**Known limitation (deferred)**: `PAGE_STEP = 10` 은 viewport-aware 가 아님 — 24 row 작은 터미널에서 한 페이지 > viewport, 80 row 큰 터미널에서 한 페이지 < viewport. 후속 task 에서 viewport-aware 로 업그레이드 가능. +``` + +- [ ] **Step 6: Update INDEX.md** + +Open `tasks/INDEX.md`. Find the `p9-fb-22` entry and append below: + +``` + - [p9-fb-24 status bar + Library header + page scroll (post-도그푸딩)](p9/p9-fb-24-tui-affordances.md) +``` + +- [ ] **Step 7: Create the per-task spec file** + +Create `tasks/p9/p9-fb-24-tui-affordances.md`: + +```markdown +--- +phase: P9 +component: kebab-tui +task_id: p9-fb-24 +title: "TUI status/key bar + Library 컬럼 헤더 + Ask/Inspect PgUp/PgDn (post-merge dogfooding)" +status: completed +depends_on: [p9-fb-03, p9-fb-13, p9-fb-22] +unblocks: [] +contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md +contract_sections: [§1 UX, §10 UX] +source_feedback: 사용자 도그푸딩 2026-05-04 — Library 컬럼 의미 부재, 페이지 스크롤 키 부재, 항상 떠 있는 상태바 (버전 포함) 요청. +--- + +# p9-fb-24 — TUI status/key bar + Library 헤더 + page scroll + +상세 설계: `docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md`. +구현 계획: `docs/superpowers/plans/2026-05-04-p9-fb-24-tui-affordances.md`. + +## Goal + +- bottom 영역을 2 row 로 분할 (status bar + key hint bar). 모든 모드 / pane 에서 항상 노출. +- ingest progress 의 dedicated row 를 status bar 의 dynamic slot 으로 흡수. +- Library doc list 위에 컬럼 헤더 row. +- Ask + Inspect 양쪽에 `PgUp` / `PgDn` (fixed `PAGE_STEP = 10`). + +## Behavior contract + +- Status bar 좌→우: `kebab v docs │ [conv_<8hex>… │ ]`. +- Dynamic state cascade: streaming (Ask) → searching (Search) → indexing (Ingest) → idle. +- conv_id (8-hex prefix + ellipsis) 는 Ask focused + (current_question 또는 turns) 일 때만. +- Library 헤더: `TITLE / TAGS / UPDATED / CHUNKS`, `Role::Heading`. `format_doc_row` 와 boundary 일치. +- Ask `PgUp/PgDn`: `j`/`k` 와 동일 follow_tail freeze. mode 무관. +- Inspect `PgUp/PgDn`: 기존 +/-10 그대로 (단 PAGE_STEP 상수 참조). + +## Tests + +- status_bar 통합 약 9 (version / pane / docs / idle / streaming / searching / ingest absorb / Ask conv_id present / Ask conv_id absent). +- library 통합 1 (헤더 row visible). +- Ask 통합 3 (PgDn / PgUp / PgUp saturating + freeze follow_tail). +- Inspect 통합 2 (PAGE_STEP regression). +- format_doc_header 단위 1. +- 기존 720+ 테스트 무수정 통과. + +## Risks / notes + +- `PAGE_STEP = 10` magic — viewport-aware 후속 task 가능. +- 60 컬럼 미만 터미널은 status bar wrap → 1 row 추가 차지. + +Live deviations 반영 위치: `tasks/HOTFIXES.md` `2026-05-04 — p9-fb-24` 항목. +``` + +- [ ] **Step 8: Final commit** + +```bash +git add README.md HANDOFF.md tasks/HOTFIXES.md tasks/INDEX.md tasks/p9/p9-fb-24-tui-affordances.md +git commit -m "docs(p9-fb-24): README + HANDOFF + HOTFIXES + INDEX + per-task spec" +``` + +--- + +## Self-Review Notes (writer) + +**Spec coverage:** +- Status bar (4 fragments + cascade + Ask conv_id) → Tasks 6, 7, 8. +- Layout 2-row split + ingest absorb → Task 9. +- Library column header → Tasks 4, 5. +- Ask PgUp/PgDn → Task 3. +- Inspect PAGE_STEP unification → Task 2. +- pager module → Task 1. +- Cheatsheet update → Task 10. +- Docs sync (README + HANDOFF + HOTFIXES + INDEX + spec) → Task 11. + +**Type / API consistency:** `pager::PAGE_STEP` is `pub(crate) const u16 = 10`, used by both Ask and Inspect. `render_status_bar` is `pub` (re-exported from `lib.rs`). `render_key_hints` replaces `render_footer` (rename only — same signature). `format_doc_header(title_w: usize) -> Line<'static>`. `dynamic_status(app: &App) -> String` and `ask_conv_id_short(app: &App) -> Option` are private to `run.rs`. + +**Placeholder scan:** No `TBD` / `TODO`. Each step has the full code or exact command. The Library test fixture (`fresh_app_with_three_docs`) is shown in full. + +**Risks documented:** `PAGE_STEP = 10` magic constant deferred for viewport-aware refinement. 60-col wrap behaviour acknowledged. Default impls (`AskState`, `SearchState`) may need verification at test time — the plan flags this so the implementer can adjust. -- 2.49.1 From 6d24174dc6f9bef0dfd3e00d80b1d18fc125dfd5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:37:16 +0000 Subject: [PATCH 03/15] =?UTF-8?q?feat(kebab-tui):=20p9-fb-24=20task=201=20?= =?UTF-8?q?=E2=80=94=20pager=20module=20+=20PAGE=5FSTEP=20constant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/lib.rs | 1 + crates/kebab-tui/src/pager.rs | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 crates/kebab-tui/src/pager.rs diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index 52932f6..a6991fa 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -22,6 +22,7 @@ mod input; mod inspect; mod library; mod markdown; +mod pager; mod run; mod search; mod terminal; diff --git a/crates/kebab-tui/src/pager.rs b/crates/kebab-tui/src/pager.rs new file mode 100644 index 0000000..bdeabb1 --- /dev/null +++ b/crates/kebab-tui/src/pager.rs @@ -0,0 +1,11 @@ +//! 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; -- 2.49.1 From 8039a8b4fb4d36c9dcb9ef3a62a5cbffcdae1df6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:39:23 +0000 Subject: [PATCH 04/15] review(p9-fb-24-task1): allow(dead_code) on PAGE_STEP until Task 2 consumes it Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/pager.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/kebab-tui/src/pager.rs b/crates/kebab-tui/src/pager.rs index bdeabb1..7781f58 100644 --- a/crates/kebab-tui/src/pager.rs +++ b/crates/kebab-tui/src/pager.rs @@ -8,4 +8,10 @@ //! lives behind this single edit point. /// Rows scrolled per `PgUp` / `PgDn` keystroke. +/// +/// `#[allow(dead_code)]` is intentional for the Task 1 commit only — +/// Task 2 (Inspect refactor) immediately consumes the constant and +/// removes this attribute. Without it, `cargo clippy -- -D warnings` +/// rejects this commit alone, breaking the per-task review gate. +#[allow(dead_code)] pub(crate) const PAGE_STEP: u16 = 10; -- 2.49.1 From 94541523e7e11543515828fbb238d98e996e2a25 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:41:24 +0000 Subject: [PATCH 05/15] =?UTF-8?q?refactor(kebab-tui):=20p9-fb-24=20task=20?= =?UTF-8?q?2=20=E2=80=94=20Inspect=20PgUp/PgDn=20via=20pager::PAGE=5FSTEP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the Inspect pane's PageDown/PageUp handlers to consume the PAGE_STEP constant from pager.rs instead of hard-coding 10. Adds regression tests to pin the scroll delta (=10), ensuring future viewport-aware refactors surface here rather than silently in user-visible behaviour. Test coverage: added page_down_scrolls_by_ten_in_inspect and page_up_rewinds_by_ten_saturating_in_inspect (+ existing page_keys_scroll_by_ten still passes). Remove #[allow(dead_code)] from pager.rs now that PAGE_STEP is consumed. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/inspect.rs | 4 ++-- crates/kebab-tui/src/pager.rs | 6 ------ crates/kebab-tui/tests/inspect.rs | 33 +++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/crates/kebab-tui/src/inspect.rs b/crates/kebab-tui/src/inspect.rs index 6fe3b36..39aa90f 100644 --- a/crates/kebab-tui/src/inspect.rs +++ b/crates/kebab-tui/src/inspect.rs @@ -426,11 +426,11 @@ pub fn handle_key_inspect(state: &mut App, key: KeyEvent) -> KeyOutcome { KeyOutcome::Continue } (KeyCode::PageDown, _) => { - s.scroll = s.scroll.saturating_add(10); + s.scroll = s.scroll.saturating_add(crate::pager::PAGE_STEP); KeyOutcome::Continue } (KeyCode::PageUp, _) => { - s.scroll = s.scroll.saturating_sub(10); + s.scroll = s.scroll.saturating_sub(crate::pager::PAGE_STEP); KeyOutcome::Continue } (KeyCode::Char('c'), _) => { diff --git a/crates/kebab-tui/src/pager.rs b/crates/kebab-tui/src/pager.rs index 7781f58..bdeabb1 100644 --- a/crates/kebab-tui/src/pager.rs +++ b/crates/kebab-tui/src/pager.rs @@ -8,10 +8,4 @@ //! lives behind this single edit point. /// Rows scrolled per `PgUp` / `PgDn` keystroke. -/// -/// `#[allow(dead_code)]` is intentional for the Task 1 commit only — -/// Task 2 (Inspect refactor) immediately consumes the constant and -/// removes this attribute. Without it, `cargo clippy -- -D warnings` -/// rejects this commit alone, breaking the per-task review gate. -#[allow(dead_code)] pub(crate) const PAGE_STEP: u16 = 10; diff --git a/crates/kebab-tui/tests/inspect.rs b/crates/kebab-tui/tests/inspect.rs index ef34a85..3667731 100644 --- a/crates/kebab-tui/tests/inspect.rs +++ b/crates/kebab-tui/tests/inspect.rs @@ -188,6 +188,39 @@ fn page_keys_scroll_by_ten() { 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. +#[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(); -- 2.49.1 From 524232858836042f44e488112b3e0c22eb0a217a Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:44:47 +0000 Subject: [PATCH 06/15] =?UTF-8?q?feat(kebab-tui):=20p9-fb-24=20task=203=20?= =?UTF-8?q?=E2=80=94=20Ask=20PgUp/PgDn=20page=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/ask.rs | 18 ++++++++++++ crates/kebab-tui/tests/ask.rs | 54 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index fe31b95..b6f1a2d 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -439,6 +439,24 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { 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) diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index 3c035fa..0f6093e 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -784,6 +784,60 @@ fn ctrl_l_resets_follow_tail_in_ask() { assert!(app.ask.as_ref().unwrap().follow_tail); } +/// p9-fb-24: PgDn advances Ask scroll by `PAGE_STEP` (= 10) and +/// disengages follow-tail (matches `j` semantics — manual scroll = +/// freeze). +#[test] +fn page_down_advances_scroll_and_freezes_follow_tail_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + let outcome = handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::Continue); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.scroll, 10, "PgDn shifts scroll by PAGE_STEP"); + assert!(!s.follow_tail, "PgDn freezes follow_tail like j/k"); +} + +/// p9-fb-24: PgUp rewinds Ask scroll by `PAGE_STEP` (saturating at 0) +/// and disengages follow-tail. +#[test] +fn page_up_rewinds_scroll_saturating_and_freezes_follow_tail_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + app.ask.as_mut().unwrap().scroll = 25; + app.ask.as_mut().unwrap().follow_tail = true; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + ); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.scroll, 15); + assert!(!s.follow_tail); + app.ask.as_mut().unwrap().scroll = 3; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().scroll, 0); +} + +/// p9-fb-24: PgUp / PgDn fire from BOTH Insert and Normal modes +/// (physical keys, no typing ambiguity — same as Left/Right/Home/End +/// from p9-fb-22). +#[test] +fn page_keys_fire_from_insert_mode_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Insert; + handle_key_ask( + &mut app, + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + ); + assert_eq!(app.ask.as_ref().unwrap().scroll, 10); +} + /// p9-fb-22 (issue #95): when follow_tail is on and the transcript /// has many lines, the rendered buffer's last visible line includes /// content from the tail of the answer (not the head). -- 2.49.1 From 6fd2ba4abfe612d36d071fbc9b2005ab08a701e0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:47:03 +0000 Subject: [PATCH 07/15] =?UTF-8?q?feat(kebab-tui):=20p9-fb-24=20task=204=20?= =?UTF-8?q?=E2=80=94=20Library=20format=5Fdoc=5Fheader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/library.rs | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/kebab-tui/src/library.rs b/crates/kebab-tui/src/library.rs index 3b01a9e..a0a6fc8 100644 --- a/crates/kebab-tui/src/library.rs +++ b/crates/kebab-tui/src/library.rs @@ -221,6 +221,36 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { f.render_stateful_widget(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 TAGS UPDATED CHUNKS`. +/// The title column width matches `area.width.saturating_sub(40).max(20)` +/// — the same calculation `render_doc_list` uses for `title_w`. +/// +/// Task 5 wires it into render_doc_list. +#[allow(dead_code)] +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 { @@ -508,4 +538,31 @@ mod tests { // 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}" + ); + } } -- 2.49.1 From c3dbe64903c6ad7f20ed0ed4a9f347f0bf193383 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:49:51 +0000 Subject: [PATCH 08/15] =?UTF-8?q?feat(kebab-tui):=20p9-fb-24=20task=205=20?= =?UTF-8?q?=E2=80=94=20Library=20column=20header=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `format_doc_header` into `render_doc_list`: render the block independently, split block_inner into a 1-row header + list via vertical Layout, and drop the `.block(block)` from the List widget. Remove `#[allow(dead_code)]` from `format_doc_header` now that it is consumed. Add `library_renders_column_header_row` integration test. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/library.rs | 26 ++++++++++++----- crates/kebab-tui/tests/library.rs | 46 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/kebab-tui/src/library.rs b/crates/kebab-tui/src/library.rs index a0a6fc8..4332d04 100644 --- a/crates/kebab-tui/src/library.rs +++ b/crates/kebab-tui/src/library.rs @@ -199,13 +199,29 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { "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() { - f.render_widget(block, area); return; } - let title_w = (area.width as usize).saturating_sub(40).max(20); + // 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 = inner .docs .iter() @@ -213,12 +229,11 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { .collect(); let list = List::new(items) - .block(block) .highlight_style(state.theme.style(crate::theme::Role::Selected)) .highlight_symbol("> "); let mut list_state = inner.list_state.clone(); - f.render_stateful_widget(list, area, &mut list_state); + f.render_stateful_widget(list, list_area, &mut list_state); } /// p9-fb-24: render the column-label row that sits directly above @@ -229,9 +244,6 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { /// Layout: `TITLE TAGS UPDATED CHUNKS`. /// The title column width matches `area.width.saturating_sub(40).max(20)` /// — the same calculation `render_doc_list` uses for `title_w`. -/// -/// Task 5 wires it into render_doc_list. -#[allow(dead_code)] pub(crate) fn format_doc_header(title_w: usize) -> Line<'static> { let title_label = "TITLE"; let tags_label = "TAGS"; diff --git a/crates/kebab-tui/tests/library.rs b/crates/kebab-tui/tests/library.rs index 1cbfff3..44f0dba 100644 --- a/crates/kebab-tui/tests/library.rs +++ b/crates/kebab-tui/tests/library.rs @@ -286,6 +286,52 @@ fn filter_overlay_render_places_cursor_on_focused_field() { ); } +/// 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::() + }) + .collect::>() + .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::>(); + 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. -- 2.49.1 From d5a434804125cfbae885b0930322ac1b930bf2ab Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:53:00 +0000 Subject: [PATCH 09/15] =?UTF-8?q?feat(kebab-tui):=20p9-fb-24=20task=206=20?= =?UTF-8?q?=E2=80=94=20render=5Fstatus=5Fbar=20(version=20+=20pane=20+=20d?= =?UTF-8?q?ocs=20+=20idle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/lib.rs | 3 + crates/kebab-tui/src/run.rs | 81 +++++++++++++++++++++++++++ crates/kebab-tui/tests/status_bar.rs | 82 ++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 crates/kebab-tui/tests/status_bar.rs diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index a6991fa..d61c6f2 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -62,6 +62,9 @@ pub use run::mode_intercept; // 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; diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index cf5e4de..ae0992f 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -327,6 +327,87 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) { f.render_widget(Paragraph::new(line), area); } +/// p9-fb-24: always-visible status bar. Layout (left → right): +/// +/// ```text +/// kebab v0.1.0 │ docs │ [conv_<8hex>… │ ] +/// ``` +/// +/// `` 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` +/// +/// `` 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 = " │ "; + 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().map(|s| s.streaming).unwrap_or(false) { + return "streaming…".to_string(); + } + if app.search.as_ref().map(|s| s.searching).unwrap_or(false) { + return "searching…".to_string(); + } + if let Some(state) = app.ingest_state.as_ref() { + return crate::ingest_progress::status_line(state); + } + "idle".to_string() +} + +/// Short form of the Ask `conversation_id` for the status bar +/// (`conv_…`). Returns `None` when not in Ask, or +/// when the Ask pane has no context (no in-flight question and no +/// completed turns). +fn ask_conv_id_short(app: &App) -> Option { + 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 id = s.conversation_id.as_deref()?; + let hex = id.strip_prefix("conv_").unwrap_or(id); + let head: String = hex.chars().take(8).collect(); + Some(format!("conv_{head}…")) +} + fn render_footer(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( diff --git a/crates/kebab-tui/tests/status_bar.rs b/crates/kebab-tui/tests/status_bar.rs new file mode 100644 index 0000000..fa4b09c --- /dev/null +++ b/crates/kebab-tui/tests/status_bar.rs @@ -0,0 +1,82 @@ +//! 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 = "/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::() + }) + .collect::>() + .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}" + ); +} -- 2.49.1 From 27305562f45aceff771d66f841fd89cd3fd540a2 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:54:50 +0000 Subject: [PATCH 10/15] =?UTF-8?q?test(kebab-tui):=20p9-fb-24=20task=207=20?= =?UTF-8?q?=E2=80=94=20status=20bar=20streaming=20/=20searching=20/=20conv?= =?UTF-8?q?=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/tests/status_bar.rs | 70 ++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/kebab-tui/tests/status_bar.rs b/crates/kebab-tui/tests/status_bar.rs index fa4b09c..008f739 100644 --- a/crates/kebab-tui/tests/status_bar.rs +++ b/crates/kebab-tui/tests/status_bar.rs @@ -80,3 +80,73 @@ fn status_bar_idle_when_no_dynamic_state() { "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); + let mut search_state = kebab_tui::SearchState::default(); + search_state.searching = true; + app.search = Some(search_state); + 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); + let mut ask_state = kebab_tui::AskState::default(); + ask_state.conversation_id = Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()); + ask_state.current_question = Some("test?".to_string()); + app.ask = Some(ask_state); + 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); + let mut ask_state = kebab_tui::AskState::default(); + ask_state.conversation_id = Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()); + ask_state.current_question = Some("test?".to_string()); + app.ask = Some(ask_state); + let rendered = render_to_string(&app, 100); + assert!( + !rendered.contains("conv_"), + "conv id leaked outside Ask pane: rendered=\n{rendered}" + ); +} -- 2.49.1 From b9433c1a2c814171825861f9254e73b10134cd9e Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:56:12 +0000 Subject: [PATCH 11/15] =?UTF-8?q?test(kebab-tui):=20p9-fb-24=20task=208=20?= =?UTF-8?q?=E2=80=94=20status=20bar=20absorbs=20ingest=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/Cargo.toml | 1 + crates/kebab-tui/tests/status_bar.rs | 60 +++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/crates/kebab-tui/Cargo.toml b/crates/kebab-tui/Cargo.toml index 62d825a..d55efbe 100644 --- a/crates/kebab-tui/Cargo.toml +++ b/crates/kebab-tui/Cargo.toml @@ -35,3 +35,4 @@ pulldown-cmark = { version = "0.13", default-features = false } [dev-dependencies] tempfile = { workspace = true } +kebab-app = { path = "../kebab-app" } diff --git a/crates/kebab-tui/tests/status_bar.rs b/crates/kebab-tui/tests/status_bar.rs index 008f739..ec49ab3 100644 --- a/crates/kebab-tui/tests/status_bar.rs +++ b/crates/kebab-tui/tests/status_bar.rs @@ -102,9 +102,10 @@ fn status_bar_shows_streaming_when_ask_streaming() { #[test] fn status_bar_shows_searching_when_search_worker_active() { let mut app = fresh_app(Pane::Search); - let mut search_state = kebab_tui::SearchState::default(); - search_state.searching = true; - app.search = Some(search_state); + app.search = Some(kebab_tui::SearchState { + searching: true, + ..Default::default() + }); let rendered = render_to_string(&app, 100); assert!( rendered.contains("searching…"), @@ -115,10 +116,11 @@ fn status_bar_shows_searching_when_search_worker_active() { #[test] fn status_bar_shows_ask_conv_id_when_in_ask_with_context() { let mut app = fresh_app(Pane::Ask); - let mut ask_state = kebab_tui::AskState::default(); - ask_state.conversation_id = Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()); - ask_state.current_question = Some("test?".to_string()); - app.ask = Some(ask_state); + 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…"), @@ -140,13 +142,49 @@ fn status_bar_omits_conv_id_when_ask_has_no_context() { #[test] fn status_bar_omits_conv_id_outside_ask() { let mut app = fresh_app(Pane::Library); - let mut ask_state = kebab_tui::AskState::default(); - ask_state.conversation_id = Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()); - ask_state.current_question = Some("test?".to_string()); - app.ask = Some(ask_state); + 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}" + ); +} -- 2.49.1 From 73da3979350e816d5db5fa9bd573370677dd1ec7 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 16:59:05 +0000 Subject: [PATCH 12/15] =?UTF-8?q?feat(kebab-tui):=20p9-fb-24=20task=209=20?= =?UTF-8?q?=E2=80=94=20render=5Froot=20uses=20status=20bar=20+=20key=20hin?= =?UTF-8?q?ts=20(drop=20ingest=20row)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/run.rs | 65 ++++++++----------------------------- 1 file changed, 13 insertions(+), 52 deletions(-) diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index ae0992f..453bf86 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -232,26 +232,18 @@ fn handle_key_unimplemented_pane( } fn render_root(f: &mut Frame, app: &App) { - // p9-fb-03: insert a 1-line status bar above the footer when an - // ingest is in flight (or its terminal line is still on hold). - let has_ingest = app.ingest_state.is_some(); - let constraints: Vec = if has_ingest { - vec![ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), // ingest status bar - Constraint::Length(1), // existing footer hints - ] - } else { - vec![ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ] - }; + // 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(constraints) + .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 { @@ -259,49 +251,18 @@ fn render_root(f: &mut Frame, app: &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), - // p9-5 Jobs not yet rendered; Library placeholder. Pane::Jobs => render_library(f, outer[1], app), } - if has_ingest { - render_ingest_status(f, outer[2], app); - render_footer(f, outer[3], app); - } else { - render_footer(f, outer[2], app); - } + render_status_bar(f, outer[2], app); + render_key_hints(f, outer[3], app); if let Some(err) = &app.error_overlay { render_error_overlay(f, f.area(), err, &app.theme); } - // p9-fb-13: cheatsheet sits on top of the error overlay so the - // user can summon help even mid-error (the cheatsheet's own - // Esc/F1 close still works first; the next key reaches the - // error-dismiss path). if app.cheatsheet_visible { crate::cheatsheet::render_cheatsheet(f, f.area(), app); } } -fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) { - let Some(state) = app.ingest_state.as_ref() else { - return; - }; - let line = crate::ingest_progress::status_line(state); - // p9-fb-14: `aborted` is a non-fatal-but-noteworthy state (Ctrl-C - // partial commit) — `Role::Warning` (yellow) is the right semantic - // signal, plus an explicit BOLD so the abort line still stands - // out from the live progress lines around it. - let style = if state.aborted { - app.theme - .style(crate::theme::Role::Warning) - .add_modifier(ratatui::style::Modifier::BOLD) - } else { - app.theme.style(crate::theme::Role::Body) - }; - f.render_widget( - Paragraph::new(Line::from(Span::styled(line, style))), - area, - ); -} - fn render_header(f: &mut Frame, area: Rect, app: &App) { let pane_label = match app.focus { Pane::Library => "Library", @@ -408,7 +369,7 @@ fn ask_conv_id_short(app: &App) -> Option { Some(format!("conv_{head}…")) } -fn render_footer(f: &mut Frame, area: Rect, app: &App) { +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, -- 2.49.1 From 6c10582e6d84507b29866910c8b45d06c155a3e6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 17:00:06 +0000 Subject: [PATCH 13/15] =?UTF-8?q?docs(kebab-tui):=20p9-fb-24=20task=2010?= =?UTF-8?q?=20=E2=80=94=20cheatsheet=20Ask=20gains=20PgUp=20/=20PgDn=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/cheatsheet.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/kebab-tui/src/cheatsheet.rs b/crates/kebab-tui/src/cheatsheet.rs index fea5339..1af1751 100644 --- a/crates/kebab-tui/src/cheatsheet.rs +++ b/crates/kebab-tui/src/cheatsheet.rs @@ -90,6 +90,7 @@ pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) { ("e", "toggle explain mode (Normal)"), ("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"), -- 2.49.1 From 8bd423e2dd996924bee605c6ea27927e4556b957 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 17:03:59 +0000 Subject: [PATCH 14/15] docs(p9-fb-24): README + HANDOFF + HOTFIXES + INDEX + per-task spec Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOFF.md | 1 + README.md | 2 +- tasks/HOTFIXES.md | 19 +++++++++++ tasks/INDEX.md | 1 + tasks/p9/p9-fb-24-tui-affordances.md | 49 ++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tasks/p9/p9-fb-24-tui-affordances.md diff --git a/HANDOFF.md b/HANDOFF.md index 3fcad62..86ee055 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -59,6 +59,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **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-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`. diff --git a/README.md b/README.md index e44ad91..cd6533e 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ kebab doctor | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask "" [--show-citations / --hide-citations] [--session ]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session ` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe | | `kebab doctor` | 설정/모델/DB 헬스 체크 | -| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i`→`o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). | +| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i`→`o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. `← / →` 로 입력 문자열 중간 cursor 이동 (한글 한 글자 = 2 column 이라도 한 번에 이동), `Home / End` 로 양 끝 점프, `Delete` 로 cursor 위치 char 삭제 — 모든 input pane (Ask / Search / Library filter overlay) 동일 (p9-fb-22). Ask 트랜스크립트는 새 답변이 viewport 아래로 누적될 때 자동으로 tail 을 따라감 (auto-scroll); `j` / `k` 로 위로 스크롤하면 freeze, `Shift-G` 로 다시 bottom + auto-tail 재개. 화면 하단 hint line 은 한국어 동사구로 (`"위로"` / `"아래로"` / `"필터"` / `"타이핑 검색어"` / `"Esc 로 NORMAL 모드"` / `"i 입력모드"` 등) + 현재 (pane, mode) 조합에 맞춰 자동 분기, **첫 fragment 가 항상 `F1 도움말`** (cheatsheet 발견성 보장). 모든 모드에서 항상 떠 있는 상태바 — `kebab v docs │ ` (state: streaming/searching/indexing/idle, ingest 진행 중에는 progress 가 같은 자리에 흡수됨). Ask 진입 시 conversation id 8 자 prefix 도 함께 표시. Ask 트랜스크립트와 Inspect 양쪽에서 `PgUp / PgDn` 으로 10 줄씩 페이지 스크롤. Library 의 doc list 위에는 `TITLE / TAGS / UPDATED / CHUNKS` 컬럼 헤더 행 표시 (display-width 정렬, Hangul / CJK 안전). | | `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | | `kebab eval run / compare` | golden query 회귀 측정 | diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index aaa94ac..b0bdb1f 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,25 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-04 — p9-fb-24 (post-dogfooding): TUI status bar + Library 헤더 + page scroll + +**Source feedback**: 사용자 도그푸딩 2026-05-04 — (1) Library 컬럼이 무엇을 뜻하는지 헤더 부재, (2) Ask 트랜스크립트 / Inspect 둘 다 페이지 단위 스크롤 키 필요, (3) 모든 모드에서 항상 떠 있는 상태바 + 키 안내바 (버전 정보 포함) 가 있으면 좋겠다. + +**Live binding 변경**: + +- bottom 영역을 2 row 로 분할. 윗줄 = status bar (`kebab v docs │ `), 아랫줄 = key hint bar (기존 `footer_hints` 그대로). p9-fb-13 follow-up 의 single-row footer 와 충돌 — frozen spec 텍스트 보존, 본 항목이 live source of truth. +- ingest progress 의 dedicated row (p9-fb-03) 는 status bar 의 dynamic slot 으로 흡수. priority cascade: streaming → searching → indexing → idle. 시각적 위치 변경, 콘텐츠 동등. +- `Paragraph::line_count` 등 unstable feature 추가 없음. +- `crates/kebab-tui/src/pager.rs::PAGE_STEP = 10` 신규. Ask 의 PgUp/PgDn 추가 (mode 무관, `follow_tail = false` flip), Inspect 의 기존 +/-10 hardcode 가 같은 상수 참조로 일원화. +- `format_doc_header(area_width)` 신규 (kebab-tui/src/library.rs). Library 의 doc list 위에 1-row 헤더 (TITLE / TAGS / UPDATED / CHUNKS, display-width 정렬). Block 의 inner area 를 `Layout` 으로 header (Length 1) + list (Min 0) 로 분할. +- cheatsheet popup Ask section 에 `PgUp / PgDn` row 추가 (Inspect 는 이미 명시). + +**Spec contract impact**: p9-fb-13 follow-up (footer 단행 row) + p9-fb-03 (ingest dedicated row) frozen spec 들과 layout 충돌. frozen 텍스트 보존, 본 HOTFIXES 항목 + spec `tasks/p9/p9-fb-24-tui-affordances.md` + design `docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md` 가 live source of truth. + +**Tests added**: 약 21 신규 (status_bar 통합 10 + library 헤더 1 + Ask PgUp/PgDn 3 + Inspect PgUp/PgDn 회귀 2 + format_doc_header 단위 1, 잔여는 cascade branch 별). 기존 720+ 워크스페이스 테스트 무수정 통과. + +**Known limitation (deferred)**: `PAGE_STEP = 10` 은 viewport-aware 가 아님 — 24 row 작은 터미널에서 한 페이지 > viewport, 80 row 큰 터미널에서 한 페이지 < viewport. 후속 task 에서 viewport-aware 로 업그레이드 가능. + ## 2026-05-04 — p9-fb-22 (post-dogfooding): mid-string cursor editing + Ask follow-tail auto-scroll **Issues**: Gitea #94 (커서 이슈) — 텍스트 입력 후 커서 이동 불가. Gitea #95 (새 응답 이슈) — 새 응답이 viewport 아래로 추가돼도 자동으로 스크롤이 따라가지 않음. 두 건 모두 사용자 도그푸딩 중 발견. diff --git a/tasks/INDEX.md b/tasks/INDEX.md index db70374..5a4dfba 100644 --- a/tasks/INDEX.md +++ b/tasks/INDEX.md @@ -106,6 +106,7 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능. - [p9-fb-20 citation surface](p9/p9-fb-20-citation-surface.md) - [p9-fb-21 Insert-key + F1 visibility (post-도그푸딩)](p9/p9-fb-21-tui-insert-key-discoverability.md) - [p9-fb-22 cursor mid-string editing + Ask follow-tail (post-도그푸딩)](p9/p9-fb-22-tui-cursor-and-autoscroll.md) + - [p9-fb-24 status bar + Library header + page scroll (post-도그푸딩)](p9/p9-fb-24-tui-affordances.md) ## Post-merge 핫픽스 diff --git a/tasks/p9/p9-fb-24-tui-affordances.md b/tasks/p9/p9-fb-24-tui-affordances.md new file mode 100644 index 0000000..a2ba5f8 --- /dev/null +++ b/tasks/p9/p9-fb-24-tui-affordances.md @@ -0,0 +1,49 @@ +--- +phase: P9 +component: kebab-tui +task_id: p9-fb-24 +title: "TUI status/key bar + Library 컬럼 헤더 + Ask/Inspect PgUp/PgDn (post-merge dogfooding)" +status: completed +depends_on: [p9-fb-03, p9-fb-13, p9-fb-22] +unblocks: [] +contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md +contract_sections: [§1 UX, §10 UX] +source_feedback: 사용자 도그푸딩 2026-05-04 — Library 컬럼 의미 부재, 페이지 스크롤 키 부재, 항상 떠 있는 상태바 (버전 포함) 요청. +--- + +# p9-fb-24 — TUI status/key bar + Library 헤더 + page scroll + +상세 설계: `docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md`. +구현 계획: `docs/superpowers/plans/2026-05-04-p9-fb-24-tui-affordances.md`. + +## Goal + +- bottom 영역을 2 row 로 분할 (status bar + key hint bar). 모든 모드 / pane 에서 항상 노출. +- ingest progress 의 dedicated row 를 status bar 의 dynamic slot 으로 흡수. +- Library doc list 위에 컬럼 헤더 row. +- Ask + Inspect 양쪽에 `PgUp` / `PgDn` (fixed `PAGE_STEP = 10`). + +## Behavior contract + +- Status bar 좌→우: `kebab v docs │ [conv_<8hex>… │ ]`. +- Dynamic state cascade: streaming (Ask) → searching (Search) → indexing (Ingest) → idle. +- conv_id (8-hex prefix + ellipsis) 는 Ask focused + (current_question 또는 turns) 일 때만. +- Library 헤더: `TITLE / TAGS / UPDATED / CHUNKS`, `Role::Heading`. `format_doc_row` 와 boundary 일치. +- Ask `PgUp/PgDn`: `j`/`k` 와 동일 follow_tail freeze. mode 무관. +- Inspect `PgUp/PgDn`: 기존 +/-10 그대로 (단 PAGE_STEP 상수 참조). + +## Tests + +- status_bar 통합 10 (version / pane / docs / idle / streaming / searching / ingest absorb / Ask conv_id present / Ask conv_id absent / outside Ask). +- library 통합 1 (헤더 row visible). +- Ask 통합 3 (PgDn / PgUp / PgUp saturating + freeze follow_tail / PgDn from Insert). +- Inspect 통합 2 (PAGE_STEP regression). +- format_doc_header 단위 1. +- 기존 720+ 테스트 무수정 통과. + +## Risks / notes + +- `PAGE_STEP = 10` magic — viewport-aware 후속 task 가능. +- 60 컬럼 미만 터미널은 status bar wrap → 1 row 추가 차지. + +Live deviations 반영 위치: `tasks/HOTFIXES.md` `2026-05-04 — p9-fb-24` 항목. -- 2.49.1 From 774acc5c0d1e3fe9b4caa511c809e64e77e8cc28 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 17:17:49 +0000 Subject: [PATCH 15/15] =?UTF-8?q?review(p9-fb-24):=20=ED=9A=8C=EC=B0=A8=20?= =?UTF-8?q?1=20nit=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20inspect=20test,=20=EC=B9=B4=EC=9A=B4=ED=8A=B8,=20sep=20?= =?UTF-8?q?=EC=83=81=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회차 1 review (PR #97 회차 1) 의 4 건 actionable nit 모두 수렴. - `crates/kebab-tui/tests/inspect.rs`: pre-fb-24 의 `page_keys_scroll_by_ten` 이 신규 `page_down_scrolls_by_ten_in_inspect` + `page_up_rewinds_by_ten_saturating_in_inspect` 와 중복 커버리지였음. 신규 두 테스트가 더 정밀 (PgUp 의 25→15→ 그 다음 3→0 saturating 명시) 이라 기존을 삭제하고 신규로 대체. inspect 테스트 -1 (14 → 13). - `tasks/HOTFIXES.md`, `tasks/p9/p9-fb-24-tui-affordances.md`: 테스트 카운트 `기존 720+` → `기존 695개 (cargo test --workspace -j 1 기준 716 passed)` 정확화. 영속 기록. - `crates/kebab-tui/src/run.rs`: status bar 의 magic string `" │ "` 를 `const STATUS_SEPARATOR: &str` 로 추출. docstring 의 rendered shape 과 sync 보장 코멘트 추가. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-tui/src/run.rs | 8 +++++++- crates/kebab-tui/tests/inspect.rs | 18 ++---------------- tasks/HOTFIXES.md | 2 +- tasks/p9/p9-fb-24-tui-affordances.md | 2 +- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index 453bf86..cc2db24 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -288,6 +288,12 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) { 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 @@ -315,7 +321,7 @@ pub fn render_status_bar(f: &mut Frame, area: Rect, app: &App) { let doc_count = app.library.inner.docs.len(); let dynamic = dynamic_status(app); - let sep = " │ "; + let sep = STATUS_SEPARATOR; let mut line_text = format!( "kebab v{}{sep}{}{sep}{} docs{sep}", env!("CARGO_PKG_VERSION"), diff --git a/crates/kebab-tui/tests/inspect.rs b/crates/kebab-tui/tests/inspect.rs index 3667731..472eb76 100644 --- a/crates/kebab-tui/tests/inspect.rs +++ b/crates/kebab-tui/tests/inspect.rs @@ -173,24 +173,10 @@ fn j_k_scroll_within_bounds_no_panic() { assert_eq!(app.inspect.as_ref().unwrap().scroll, 0); } -#[test] -fn page_keys_scroll_by_ten() { - let mut app = fresh_app(); - handle_key_inspect( - &mut app, - KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), - ); - assert_eq!(app.inspect.as_ref().unwrap().scroll, 10); - handle_key_inspect( - &mut app, - KeyEvent::new(KeyCode::PageUp, 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. +/// 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(); diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index b0bdb1f..1b35c67 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -29,7 +29,7 @@ git history. **Spec contract impact**: p9-fb-13 follow-up (footer 단행 row) + p9-fb-03 (ingest dedicated row) frozen spec 들과 layout 충돌. frozen 텍스트 보존, 본 HOTFIXES 항목 + spec `tasks/p9/p9-fb-24-tui-affordances.md` + design `docs/superpowers/specs/2026-05-04-p9-fb-24-tui-affordances-design.md` 가 live source of truth. -**Tests added**: 약 21 신규 (status_bar 통합 10 + library 헤더 1 + Ask PgUp/PgDn 3 + Inspect PgUp/PgDn 회귀 2 + format_doc_header 단위 1, 잔여는 cascade branch 별). 기존 720+ 워크스페이스 테스트 무수정 통과. +**Tests added**: 약 21 신규 (status_bar 통합 10 + library 헤더 1 + Ask PgUp/PgDn 3 + Inspect PgUp/PgDn 회귀 2 + format_doc_header 단위 1, 잔여는 cascade branch 별). 기존 695개 워크스페이스 테스트 무수정 통과 (`cargo test --workspace -j 1` 기준 716 passed). **Known limitation (deferred)**: `PAGE_STEP = 10` 은 viewport-aware 가 아님 — 24 row 작은 터미널에서 한 페이지 > viewport, 80 row 큰 터미널에서 한 페이지 < viewport. 후속 task 에서 viewport-aware 로 업그레이드 가능. diff --git a/tasks/p9/p9-fb-24-tui-affordances.md b/tasks/p9/p9-fb-24-tui-affordances.md index a2ba5f8..930bddd 100644 --- a/tasks/p9/p9-fb-24-tui-affordances.md +++ b/tasks/p9/p9-fb-24-tui-affordances.md @@ -39,7 +39,7 @@ source_feedback: 사용자 도그푸딩 2026-05-04 — Library 컬럼 의미 부 - Ask 통합 3 (PgDn / PgUp / PgUp saturating + freeze follow_tail / PgDn from Insert). - Inspect 통합 2 (PAGE_STEP regression). - format_doc_header 단위 1. -- 기존 720+ 테스트 무수정 통과. +- 기존 695개 테스트 무수정 통과 (`cargo test --workspace -j 1` 기준 716 passed). ## Risks / notes -- 2.49.1