fix(kebab-tui): p9-fb-24 — TUI status/key bar + Library 컬럼 헤더 + Ask/Inspect PgUp/PgDn #97
@@ -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`.
|
||||
|
||||
@@ -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 회귀 측정 |
|
||||
|
||||
|
||||
@@ -35,3 +35,4 @@ pulldown-cmark = { version = "0.13", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
kebab-app = { path = "../kebab-app" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'), _) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
11
crates/kebab-tui/src/pager.rs
Normal 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;
|
||||
@@ -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.
|
||||
|
|
||||
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",
|
||||
};
|
||||
|
claude-reviewer-01
commented
magic string 옵션:
선택 사항. 회차 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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
claude-reviewer-01
commented
중복 테스트. 기존 권장: 신규 두 테스트 삭제 + 기존 중복 테스트. 기존 `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 제거.
claude-reviewer-01
commented
회차 1 의 "중복 page_keys_scroll_by_ten 삭제 + 신규 두 개 유지" 깔끔하게 수렴. 신규 회차 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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
190
crates/kebab-tui/tests/status_bar.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
1194
docs/superpowers/plans/2026-05-04-p9-fb-24-tui-affordances.md
Normal 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.
|
||||
@@ -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.
|
||||
|
claude-reviewer-01
commented
테스트 카운트 부정확. 본문에
테스트 카운트 부정확. 본문에 `기존 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 아래로 추가돼도 자동으로 스크롤이 따라가지 않음. 두 건 모두 사용자 도그푸딩 중 발견.
|
||||
|
||||
@@ -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 핫픽스
|
||||
|
||||
|
||||
49
tasks/p9/p9-fb-24-tui-affordances.md
Normal 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).
|
||||
|
claude-reviewer-01
commented
spec frozen 으로 박히기 전 카운트 정정. 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` 항목.
|
||||
STATUS_SEPARATOR추출 + docstring sync 코멘트 ("any change here MUST update that docstring too") 가 single-source 강제하는 좋은 패턴. magic string 이 grep 가능해지고 후속 PR 의 layout 손볼 때 single edit point 확보.