fix(kebab-tui): p9-fb-24 — TUI status/key bar + Library 컬럼 헤더 + Ask/Inspect PgUp/PgDn #97

Merged
altair823 merged 15 commits from fix/p9-fb-24-tui-affordances into main 2026-05-04 17:20:17 +00:00
19 changed files with 1927 additions and 61 deletions

View File

@@ -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`.

View File

@@ -76,7 +76,7 @@ kebab doctor
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe |
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). 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<version> │ <pane> │ <docs> docs │ <state>` (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 회귀 측정 |

View File

@@ -35,3 +35,4 @@ pulldown-cmark = { version = "0.13", default-features = false }
[dev-dependencies]
tempfile = { workspace = true }
kebab-app = { path = "../kebab-app" }

View File

@@ -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)

View File

@@ -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"),

View File

@@ -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'), _) => {

View File

@@ -22,6 +22,7 @@ mod input;
mod inspect;
mod library;
mod markdown;
mod pager;
mod run;
mod search;
mod terminal;
@@ -61,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;

View File

@@ -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<ListItem> = inner
.docs
.iter()
@@ -213,12 +229,38 @@ 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
/// the doc list. Uses the same width math as `format_doc_row` so
/// the labels line up with their data columns regardless of Hangul
/// / CJK width drift.
///
/// Layout: `TITLE<title_pad> TAGS<tags_pad> UPDATED CHUNKS`.
/// The title column width matches `area.width.saturating_sub(40).max(20)`
/// — the same calculation `render_doc_list` uses for `title_w`.
pub(crate) fn format_doc_header(title_w: usize) -> Line<'static> {
let title_label = "TITLE";
let tags_label = "TAGS";
let title_pad = title_w.saturating_sub(display_width(title_label));
let tags_pad = TAGS_COL_W.saturating_sub(display_width(tags_label));
let text = format!(
"{title_label}{:title_pad$} {tags_label}{:tags_pad$} {updated:<10} {chunks}",
"",
"",
title_label = title_label,
tags_label = tags_label,
updated = "UPDATED",
chunks = "CHUNKS",
title_pad = title_pad,
tags_pad = tags_pad,
);
Line::from(text)
}
/// Format a `DocSummary` row using display-width-aware truncation
@@ -508,4 +550,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}"
);
}
}

View File

@@ -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;

View File

@@ -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<Constraint> = 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",
@@ -327,7 +288,94 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(Paragraph::new(line), area);
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
/// 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.

STATUS_SEPARATOR 추출 + docstring sync 코멘트 ("any change here MUST update that docstring too") 가 single-source 강제하는 좋은 패턴. magic string 이 grep 가능해지고 후속 PR 의 layout 손볼 때 single edit point 확보.

`STATUS_SEPARATOR` 추출 + docstring sync 코멘트 ("any change here MUST update that docstring too") 가 single-source 강제하는 좋은 패턴. magic string 이 grep 가능해지고 후속 PR 의 layout 손볼 때 single edit point 확보.
const STATUS_SEPARATOR: &str = "";
/// p9-fb-24: always-visible status bar. Layout (left → right):
///
/// ```text
/// kebab v0.1.0 │ <pane> │ <docs> docs │ [conv_<8hex>… │ ]<state>
/// ```
///
/// `<state>` is one of `streaming…` / `searching…` / `indexing N/M (P%)` / `idle`,
/// chosen via the priority cascade:
/// 1. Ask streaming → `streaming…`
/// 2. Search worker active → `searching…`
/// 3. Ingest worker active (or terminal-line still on hold) → ingest `status_line`
/// 4. fallback → `idle`
///
/// `<conv_…>` only appears when `app.focus == Ask` AND the pane has
/// either an in-flight question or at least one completed turn — the
/// signal that "this Ask session has context".
pub fn render_status_bar(f: &mut Frame, area: Rect, app: &App) {
let pane_label = match app.focus {
Pane::Library => "Library",
Pane::Search => "Search",
Pane::Ask => "Ask",
Pane::Inspect => "Inspect",
Pane::Jobs => "Jobs",
};

magic string " │ " (sep) 가 render_status_bar 본문에 inline. 한 곳뿐이라 critical 은 아니지만 후속 PR 에서 layout 손볼 때 grep 안 됨.

옵션:

  • const STATUS_SEPARATOR: &str = " │ "; 모듈 상단 ���출.
  • 또는 그대로 두고 docstring 의 kebab v0.1.0 │ <pane> 표기와 sync 보장 — 본문이 짧고 가독성 OK.

선택 사항. 회차 2 에서 추출해도 좋고 acceptable as-is 로 닫아도 무방.

magic string `" │ "` (sep) 가 `render_status_bar` 본문에 inline. 한 곳뿐이라 critical 은 아니지만 후속 PR 에서 layout 손볼 때 grep 안 됨. 옵션: - `const STATUS_SEPARATOR: &str = " │ ";` 모듈 상단 ���출. - 또는 그대로 두고 docstring 의 `kebab v0.1.0 │ <pane>` 표기와 sync 보장 — 본문이 짧고 가독성 OK. 선택 사항. 회차 2 에서 추출해도 좋고 acceptable as-is 로 닫아도 무방.
let doc_count = app.library.inner.docs.len();
let dynamic = dynamic_status(app);
let sep = STATUS_SEPARATOR;
let mut line_text = format!(
"kebab v{}{sep}{}{sep}{} docs{sep}",
env!("CARGO_PKG_VERSION"),
pane_label,
doc_count,
);
if let Some(conv) = ask_conv_id_short(app) {
line_text.push_str(&conv);
line_text.push_str(sep);
}
line_text.push_str(&dynamic);
let line = Line::from(Span::styled(
line_text,
app.theme.style(crate::theme::Role::Hint),
));
f.render_widget(Paragraph::new(line), area);
}
/// Priority-cascade dynamic state for the status bar. See
/// `render_status_bar` for the priority order.
fn dynamic_status(app: &App) -> String {
if app.ask.as_ref().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_<first 8 hex chars>…`). 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<String> {
if app.focus != Pane::Ask {
return None;
}
let s = app.ask.as_ref()?;
let has_context = s.current_question.is_some() || !s.turns.is_empty();
if !has_context {
return None;
}
let 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_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,

View File

@@ -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).

View File

@@ -173,14 +173,33 @@ fn j_k_scroll_within_bounds_no_panic() {
assert_eq!(app.inspect.as_ref().unwrap().scroll, 0);
}
/// p9-fb-24 task 2: PageDown advances scroll by `PAGE_STEP` (= 10).
/// Pins the constant so a future viewport-aware refactor surfaces
/// here, not silently in user-visible behaviour. Replaces the
/// pre-fb-24 `page_keys_scroll_by_ten` (deleted as duplicate).
#[test]
fn page_keys_scroll_by_ten() {
fn page_down_scrolls_by_ten_in_inspect() {
let mut app = fresh_app();
handle_key_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

중복 테스트. 기존 page_keys_scroll_by_ten (line 188 위) 이 이미 PgDn(+10) + PgUp(-10) + saturating-at-0 셋 다 검증. 신규 page_down_scrolls_by_ten_in_inspect + page_up_rewinds_by_ten_saturating_in_inspect 는 같은 동작을 한 번 더 어설션. 둘 중 하나만 남기는 게 깔끔 — plan 이 신규 작성을 명시했지만 pre-existing 테스트가 plan 작성 시점에 보이지 않았던 것으로 보임.

권장: 신규 두 테스트 삭제 + 기존 page_keys_scroll_by_ten 의 docstring 에 p9-fb-24: PAGE_STEP 상수 회귀 핀 한 줄 추가. 신규 테스트와 동일 의미가 됨. 또는 기존을 삭제하고 신규로 교체. 어느 쪽이든 33 줄 dead test 제거.

중복 테스트. 기존 `page_keys_scroll_by_ten` (line 188 위) 이 이미 PgDn(+10) + PgUp(-10) + saturating-at-0 셋 다 검증. 신규 `page_down_scrolls_by_ten_in_inspect` + `page_up_rewinds_by_ten_saturating_in_inspect` 는 같은 동작을 한 번 더 어설션. 둘 중 하나만 남기는 게 깔끔 — plan 이 신규 작성을 명시했지만 pre-existing 테스트가 plan 작성 시점에 보이지 않았던 것으로 보임. 권장: 신규 두 테스트 삭제 + 기존 `page_keys_scroll_by_ten` 의 docstring 에 `p9-fb-24: PAGE_STEP 상수 회귀 핀` 한 줄 추가. 신규 테스트와 동일 의미가 됨. 또는 기존을 삭제하고 신규로 교체. 어느 쪽이든 33 줄 dead test 제거.

회차 1 의 "중복 page_keys_scroll_by_ten 삭제 + 신규 두 개 유지" 깔끔하게 수렴. 신규 page_up_rewinds_by_ten_saturating_in_inspect 가 25→15 이어서 3→0 두 saturating boundary 를 한 테스트에서 명시적으로 체크하는 게 기존보다 정밀도 ↑. PAGE_STEP regression 핀의 의도와 정확히 부합.

회차 1 의 "중복 page_keys_scroll_by_ten 삭제 + 신규 두 개 유지" 깔끔하게 수렴. 신규 `page_up_rewinds_by_ten_saturating_in_inspect` 가 25→15 이어서 3→0 두 saturating boundary 를 한 테스트에서 명시적으로 체크하는 게 기존보다 정밀도 ↑. PAGE_STEP regression 핀의 의도와 정확히 부합.
/// 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),

View File

@@ -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::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("TITLE")
&& rendered.contains("TAGS")
&& rendered.contains("UPDATED")
&& rendered.contains("CHUNKS"),
"header row labels not visible in:\n{rendered}"
);
let title_line_idx = rendered
.lines()
.position(|line| line.contains("TITLE"))
.expect("TITLE header should be present");
let lines_after = rendered.lines().skip(title_line_idx + 1).collect::<Vec<_>>();
assert!(
lines_after.iter().any(|line| line.contains("doc-")),
"no data rows after header:\n{rendered}"
);
}
/// p9-fb-10: Library renders Hangul / CJK titles without overflowing
/// the title column. Smoke pin — render with a mixed Korean fixture
/// and confirm no panic + the truncated width fits the column.

View File

@@ -0,0 +1,190 @@
//! 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::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn status_bar_shows_kebab_version_first() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
let expected = format!("kebab v{}", env!("CARGO_PKG_VERSION"));
assert!(
rendered.contains(&expected),
"version not in status bar: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_pane_label() {
for (focus, expected) in [
(Pane::Library, "Library"),
(Pane::Search, "Search"),
(Pane::Ask, "Ask"),
(Pane::Inspect, "Inspect"),
(Pane::Jobs, "Jobs"),
] {
let app = fresh_app(focus);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains(expected),
"pane label '{expected}' not visible for focus={focus:?}: rendered=\n{rendered}"
);
}
}
#[test]
fn status_bar_shows_doc_count() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("0 docs"),
"doc count missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_idle_when_no_dynamic_state() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("idle"),
"idle marker missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_streaming_when_ask_streaming() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState {
streaming: true,
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("streaming…"),
"streaming marker missing: rendered=\n{rendered}"
);
assert!(
!rendered.contains("idle"),
"idle should not appear when streaming: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_searching_when_search_worker_active() {
let mut app = fresh_app(Pane::Search);
app.search = Some(kebab_tui::SearchState {
searching: true,
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("searching…"),
"searching marker missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_ask_conv_id_when_in_ask_with_context() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState {
conversation_id: Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()),
current_question: Some("test?".to_string()),
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("conv_a3f9b2c1…"),
"8-hex prefix conv id missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_omits_conv_id_when_ask_has_no_context() {
let mut app = fresh_app(Pane::Ask);
app.ask = Some(kebab_tui::AskState::default());
let rendered = render_to_string(&app, 100);
assert!(
!rendered.contains("conv_"),
"conv id should not appear without context: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_omits_conv_id_outside_ask() {
let mut app = fresh_app(Pane::Library);
app.ask = Some(kebab_tui::AskState {
conversation_id: Some("conv_a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5".to_string()),
current_question: Some("test?".to_string()),
..Default::default()
});
let rendered = render_to_string(&app, 100);
assert!(
!rendered.contains("conv_"),
"conv id leaked outside Ask pane: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_ingest_progress_in_dynamic_slot() {
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
let mut app = fresh_app(Pane::Library);
let (_tx, rx) = std::sync::mpsc::channel();
app.ingest_state = Some(kebab_tui::IngestState {
rx,
counts: kebab_app::AggregateCounts {
scanned: 40,
..Default::default()
},
current_path: Some("notes/foo.md".to_string()),
current_idx: 12,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: None,
cancel: Arc::new(AtomicBool::new(false)),
});
let rendered = render_to_string(&app, 200);
assert!(
rendered.contains("12/40"),
"ingest progress fragment missing: rendered=\n{rendered}"
);
assert!(
rendered.contains("30%"),
"ingest percentage missing: rendered=\n{rendered}"
);
assert!(
!rendered.contains("idle"),
"idle should not appear during ingest: rendered=\n{rendered}"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 │ <pane> │ <doc_count> docs │ [conv_<id_8>… │ ]<dynamic_status>
```
- **버전**: `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.

View File

@@ -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<version> │ <pane> │ <docs> docs │ <state>`), 아랫줄 = key hint bar (기존 `footer_hints` 그대로). p9-fb-13 follow-up 의 single-row footer 와 충돌 — frozen spec 텍스트 보존, 본 항목이 live source of truth.

테스트 카운트 부정확. 본문에 기존 720+ 워크스페이스 테스트 무수정 통과 인데 실제 cargo test --workspace -j 1 결과는 716 passed (= 신규 21 + 기존 695). plan 작성 시점의 추정치 720+ 가 그대로 박힘.

기존 695 워크스페이스 테스트 또는 기존 695개 워크스페이스 테스트 (cargo test --workspace -j 1 기준 716 passed) 로 정정 권장. 영속 기록이라 정확한 카운트가 의미 있음.

테스트 카운트 부정확. 본문에 `기존 720+ 워크스페이스 테스트 무수정 통과` 인데 실제 cargo test --workspace -j 1 결과는 716 passed (= 신규 21 + 기존 695). plan 작성 시점의 추정치 `720+` 가 그대로 박힘. `기존 695 워크스페이스 테스트` 또는 `기존 695개 워크스페이스 테스트 (cargo test --workspace -j 1 기준 716 passed)` 로 정정 권장. 영속 기록이라 정확한 카운트가 의미 있음.
- 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 별). 기존 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 로 업그레이드 가능.
## 2026-05-04 — p9-fb-22 (post-dogfooding): mid-string cursor editing + Ask follow-tail auto-scroll
**Issues**: Gitea #94 (커서 이슈) — 텍스트 입력 후 커서 이동 불가. Gitea #95 (새 응답 이슈) — 새 응답이 viewport 아래로 추가돼도 자동으로 스크롤이 따라가지 않음. 두 건 모두 사용자 도그푸딩 중 발견.

View File

@@ -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 핫픽스

View File

@@ -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<version> │ <pane> │ <docs> docs │ [conv_<8hex>… │ ]<dynamic_status>`.
- 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).

spec frozen 으로 박히기 전 카운트 정정. 기존 720+ 테스트 무수정 통과기존 695개 테스트 무수정 통과. HOTFIXES 와 같은 출처 문제.

spec frozen 으로 박히기 전 카운트 정정. `기존 720+ 테스트 무수정 통과` → `기존 695개 테스트 무수정 통과`. HOTFIXES 와 같은 출처 문제.
- Ask 통합 3 (PgDn / PgUp / PgUp saturating + freeze follow_tail / PgDn from Insert).
- Inspect 통합 2 (PAGE_STEP regression).
- format_doc_header 단위 1.
- 기존 695개 테스트 무수정 통과 (`cargo test --workspace -j 1` 기준 716 passed).
## 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` 항목.