diff --git a/HANDOFF.md b/HANDOFF.md index 4613e68..3fcad62 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -59,6 +59,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 partial)** — TUI vim-style mode machine (절반 ship — heuristic 제거는 follow-up). `kebab_tui::Mode::{Normal, Insert}` enum + `Mode::auto_for(pane)` (Library/Inspect/Jobs → Normal, Search/Ask → Insert) + `Mode::label()` (`"-- NORMAL --"` / `"-- INSERT --"`) + `App.mode: Mode` field. run loop `mode_intercept(app, key)` 가 dispatch 전 intercept — Insert 에서 `Esc` → Normal (어디서나), Normal 에서 `i` → Insert (Library/Inspect/Jobs 만, Search/Ask 는 자동 Insert 라 `i` 가 typed char). 헤더 우측에 mode label colored (Insert = Role::Success green, Normal = Role::Heading cyan+bold). pane 전환 시 `app.mode = Mode::auto_for(p)` 자동 flip. **Deferred (HOTFIXES entry)**: `is_typing_mod` (search) + input-empty heuristic (ask) 는 후속 PR 에서 mode-authoritative 로 교체 — 현재는 user-visible signal (label + auto flip + i/Esc) 만 ship, 키 dispatch 는 heuristic 유지. spec status `in_progress` (not `completed`). spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 follow-up)** — heuristic 제거 (partial PR 의 deferred 부분 finalize). `search::is_typing_mod` (CTRL/ALT chord filter) 함수 삭제 + `ask::handle_key_ask` 의 input-empty heuristic 삭제. 새 dispatch: `search::handle_key_search` 의 `i` (chunk inspect) / `g` (editor jump) pre-pass 가 `state.mode == Mode::Normal` 일 때만 fire (Insert 에서는 typed char). main match 의 `j`/`k`/Char(c) 가 `state.mode` 로 분기 (Normal → 선택 이동, Insert → input.push). `ask::handle_key_ask` 의 `e`/`j`/`k` 도 동일 패턴 — Normal 에서 toggle/scroll, Insert 에서 input typing. 테스트 fixture (`tests/search.rs::fresh_app`, `tests/ask.rs::fresh_app`) 가 `app.mode = Mode::auto_for(focus)` 로 run-loop 동작 mirror. 기존 nav 테스트 (j_k_move, g_key_enqueues, e_toggles) 는 explicit `app.mode = Mode::Normal` 추가, 신규 4 테스트 (j_in_insert_types / arbitrary_char_in_normal_noop / e_types_in_insert / jk_scroll-in-normal-type-in-insert) 가 mode-authoritative 동작 pin. spec status `in_progress` → `completed`. spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-10 partial)** — TUI CJK rendering helpers. `kebab-tui::input::{display_width, truncate_to_display_width}` 신규 — `unicode-width` 위에서 column-단위 width 계산 (ASCII=1, Hangul/CJK/fullwidth=2, combining=0) + char-boundary 안전 truncate (wide char 를 split 없이 keep-or-omit, ellipsis 1 col). library.rs 의 중복 `truncate_to_display_width` private fn 제거 — 단일 source. 9 unit tests (ASCII / Hangul / Japanese / mixed / truncate fits·overflow·zero-cols·wide-char-boundary / `String::pop` char-aware sanity) + 1 integration render test (Korean + Japanese fixture, TestBackend 80×20, 한글/일본어 글자가 frame 에 살아남음 확인). spec 의 `InputBuffer` struct (cursor 가 column 단위 wide-char width 추적) 도입은 follow-up — Ask/Search/Editor pane 의 String + cursor 일괄 마이그레이션이 회귀 표면이 커서 helper 만 먼저 머지. backspace 는 모든 pane 이 이미 `String::pop()` 사용 (char-aware) → byte-boundary 안전성 helper 없이도 확보. crossterm 0.28 이 native IME composing 미노출 — preedit handling out of scope. spec status `planned` → `in_progress`. spec: `tasks/p9/p9-fb-10-tui-cjk-input.md`. +- **2026-05-04 P9 post-도그푸딩 (p9-fb-22)** — TUI 입력 cursor mid-string 편집 + Ask follow-tail auto-scroll. Gitea #94 (입력 후 커서 이동 안 됨) + #95 (새 응답 자동 스크롤 안 됨) 두 건. `InputBuffer` 의 cursor 모델을 byte-position 기반으로 재구성 — cursor 가 끝일 때 기존 append 동작과 backwards-compatible, mid-string 일 때는 `←/→/Home/End/Delete` 로 편집. `AskState` 에 `follow_tail: bool` (default true). `Paragraph::line_count(width)` (ratatui `unstable-rendered-line-info` feature 활성화) 로 매 프레임 wrapped row 수 계산해 follow-tail 시 scroll 을 bottom 에 pin. `j`/`k` 가 follow-tail 끄고 `Shift-G` 가 다시 켬. 12 신규 InputBuffer unit + 6 신규 Ask integration. spec: `tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md`. HOTFIXES 항목 `2026-05-04` 가 live cursor 모델 source of truth. - **2026-05-03 P9 post-도그푸딩 (p9-fb-21)** — `i` 가 universal Normal→Insert toggle (모든 pane). 이전 mode_intercept 는 Library/Inspect/Jobs 만 `i` intercept 였고 Search/Ask 는 fall-through (자동 INSERT 가정). 사용자가 Esc 로 NORMAL 로 빠진 후 Insert 복귀 키 없어 dead-end → 도그푸딩에서 보고됨. mode_intercept 의 `(Char('i'), Normal, _)` arm 이 pane 무관 모두 INSERT flip. Search 의 chunk inspect 키 `i`→`o` rebind (vim "open") 으로 충돌 해소. footer hint 모든 (pane, mode, filter) 조합 첫 fragment = `F1 도움말` (cheatsheet binding discoverability). Search/Ask Normal hint 에 `i 입력모드` fragment 추가. cheatsheet popup Global/Search/Ask section 갱신. 6 신규 unit + 3 기존 갱신. spec: `tasks/p9/p9-fb-21-tui-insert-key-discoverability.md` (status `completed` 직접). HOTFIXES 항목이 Search `i`→`o` rebind 의 source of truth. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-10 follow-up)** — InputBuffer struct + 모든 text-input pane 마이그레이션 + cursor column 정렬. `kebab-tui::input::InputBuffer { content, cursor_col }` 신규 — `push_char` / `pop_char` / `clear` / `take` 가 wide-char 단위로 cursor_col 진행 (ASCII=1, Hangul/CJK=2, combining=0). `SearchState.input` / `AskState.input` / `FilterEdit.{tags_buf, lang_buf}` 가 InputBuffer 로 교체. render 단계에서 `f.set_cursor_position(...)` 가 `block.inner(area)` 기반 prompt 폭 + cursor_col 으로 caret 을 정확한 column 에 배치 (right-edge clamp). ratatui 0.28 의 cursor visibility 는 `cursor_position` Some/None 으로 자동 결정 — Search/Ask/Filter 가 `Some` 이라 caret 보임, Library/Inspect 는 `None` 이라 hidden. Korean lexical 검색은 `crates/kebab-app/tests/search_korean.rs` 에서 ingest → search → 결과 한 건 이상 + Korean 파일 stem 매칭 assert 로 회귀 핀. `lexical_query` test helper 가 `crates/kebab-app/tests/common/mod.rs` 로 promotion. spec status `in_progress` → `completed`. spec: `tasks/p9/p9-fb-10-tui-cjk-input.md`. - **2026-05-03 P9 도그푸딩 피드백 20/20 ✅** — `tasks/p9/p9-fb-01..20` 모든 spec status `completed`. 사용자가 `kebab` 직접 돌려서 수집한 UX 잡음 (ingest 진행 표시 부재, mode 혼란, CJK column drift, multi-turn 부재, citation 부재 등) 이 모두 코드 또는 spec-acknowledged-deferred 형태로 해소. 도그푸딩 사이클 한 바퀴 완성 — P9-5 desktop tauri 와 별개로 TUI/CLI 사용자 경험 측면은 한 단계 안정화. P9 phase row 는 P9-5 미진행이라 🟡 유지. diff --git a/README.md b/README.md index 1fc56e4..e44ad91 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ kebab doctor | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask "" [--show-citations / --hide-citations] [--session ]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session ` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe | | `kebab doctor` | 설정/모델/DB 헬스 체크 | -| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i`→`o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. 화면 하단 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 reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | | `kebab eval run / compare` | golden query 회귀 측정 | diff --git a/crates/kebab-tui/Cargo.toml b/crates/kebab-tui/Cargo.toml index 619f175..62d825a 100644 --- a/crates/kebab-tui/Cargo.toml +++ b/crates/kebab-tui/Cargo.toml @@ -13,7 +13,11 @@ kebab-config = { path = "../kebab-config" } # UI facade rule (design §8): UI crates may only touch `kebab-app`. The # search / store / embed / llm / rag layers stay invisible behind it. kebab-app = { path = "../kebab-app" } -ratatui = "0.28" +# p9-fb-22: `unstable-rendered-line-info` exposes +# `Paragraph::line_count(width)` for the Ask follow-tail scroll +# math. Pinned ratatui 0.28.x means the unstable surface is fixed +# until we deliberately bump the dep. +ratatui = { version = "0.28", features = ["unstable-rendered-line-info"] } crossterm = "0.28" anyhow = { workspace = true } tracing = { workspace = true } diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 38665d9..44ed73c 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -197,7 +197,6 @@ impl Default for SearchState { /// on the first submission (timestamp-based — unique per session, /// not cryptographic). `Ctrl-L` clears `turns + conversation_id` to /// start a fresh conversation. -#[derive(Default)] pub struct AskState { /// p9-fb-10: `InputBuffer` tracks display-column cursor position /// alongside content so wide chars (Hangul, CJK) place the @@ -217,8 +216,16 @@ pub struct AskState { /// every render frame. pub rx: Option>, /// Vertical scroll offset for the transcript area when content - /// exceeds the viewport. + /// exceeds the viewport. Only consulted when `follow_tail` is + /// false; otherwise the renderer overrides this with the + /// computed bottom offset. pub scroll: u16, + /// p9-fb-22: when true, the renderer pins the transcript to the + /// bottom on every frame (so streaming tokens and freshly- + /// completed turns are visible without manual scrolling). Set + /// to false the first time the user scrolls up (`k`); restored + /// to true by `G`, `Ctrl-L`, and a new submission. + pub follow_tail: bool, /// Last error from the worker thread (rendered in popup if Some). pub last_error: Option, /// p9-fb-16: completed turns of the current conversation. Each @@ -242,6 +249,28 @@ pub struct AskState { pub last_answer: Option, } +impl Default for AskState { + fn default() -> Self { + Self { + input: crate::input::InputBuffer::default(), + explain: false, + streaming: false, + partial: String::new(), + thread: None, + rx: None, + scroll: 0, + // p9-fb-22: default to follow-tail so a freshly opened + // Ask pane auto-scrolls when the first answer streams in. + follow_tail: true, + last_error: None, + turns: Vec::new(), + current_question: None, + conversation_id: None, + last_answer: None, + } + } +} + /// What the Inspect pane is currently showing — owned by p9-4. #[derive(Clone, Debug)] diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index 1a02bf9..fe31b95 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -150,10 +150,21 @@ fn render_answer(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme:: return; } - let para = Paragraph::new(lines) - .wrap(Wrap { trim: false }) - .scroll((s.scroll, 0)); - f.render_widget(para.block(block), area); + // p9-fb-22: follow-tail render. Build the paragraph, ask it for + // the post-wrap line count at the viewport width (via ratatui's + // `unstable-rendered-line-info` feature, pinned to ratatui 0.28 + // in our Cargo.toml), then pin the scroll offset to + // `line_count - inner_height` when `follow_tail` is set. + let inner = block.inner(area); + let para = Paragraph::new(lines).wrap(Wrap { trim: false }); + let scroll = if s.follow_tail { + let total_lines = para.line_count(inner.width); + u16::try_from(total_lines.saturating_sub(inner.height as usize)) + .unwrap_or(u16::MAX) + } else { + s.scroll + }; + f.render_widget(para.scroll((scroll, 0)).block(block), area); } fn push_turn_lines( @@ -312,6 +323,9 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { s.partial.clear(); s.current_question = None; s.scroll = 0; + // p9-fb-22: re-engage follow-tail on Ctrl-L so the next + // submission's stream auto-scrolls. + s.follow_tail = true; // p9-fb-16: detach the in-flight worker so its eventual // result does NOT graduate into the new conversation as // a stale Turn. JoinHandle Drop on `None` assignment is @@ -367,20 +381,64 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { KeyOutcome::Continue } (KeyCode::Char('j'), KeyModifiers::NONE) if state.mode == crate::app::Mode::Normal => { + // p9-fb-22: scrolling down via `j` opts out of follow- + // tail. The renderer uses `s.scroll` (not the computed + // bottom offset) until the user presses `G` to re-pin. let s = state.ask.as_mut().unwrap(); + s.follow_tail = false; s.scroll = s.scroll.saturating_add(1); KeyOutcome::Continue } (KeyCode::Char('k'), KeyModifiers::NONE) if state.mode == crate::app::Mode::Normal => { + // p9-fb-22: scrolling up via `k` opts out of follow-tail. let s = state.ask.as_mut().unwrap(); + s.follow_tail = false; s.scroll = s.scroll.saturating_sub(1); KeyOutcome::Continue } + // p9-fb-22: `G` jumps the transcript to the bottom and + // re-engages follow-tail so subsequent streaming auto- + // scrolls. Only available in Normal mode (Insert mode + // types `G` into the input). + (KeyCode::Char('G'), KeyModifiers::SHIFT) if state.mode == crate::app::Mode::Normal => { + let s = state.ask.as_mut().unwrap(); + s.follow_tail = true; + s.scroll = 0; + KeyOutcome::Continue + } (KeyCode::Backspace, _) => { let s = state.ask.as_mut().unwrap(); s.input.pop_char(); KeyOutcome::Continue } + // p9-fb-22: arrow keys + Home/End + Delete edit at the cursor. + // Available in both Normal and Insert mode (no shift to typing + // ambiguity — these are physical keys, not Char codes). + (KeyCode::Left, _) => { + let s = state.ask.as_mut().unwrap(); + s.input.move_left(); + KeyOutcome::Continue + } + (KeyCode::Right, _) => { + let s = state.ask.as_mut().unwrap(); + s.input.move_right(); + KeyOutcome::Continue + } + (KeyCode::Home, _) => { + let s = state.ask.as_mut().unwrap(); + s.input.move_home(); + KeyOutcome::Continue + } + (KeyCode::End, _) => { + let s = state.ask.as_mut().unwrap(); + s.input.move_end(); + KeyOutcome::Continue + } + (KeyCode::Delete, _) => { + let s = state.ask.as_mut().unwrap(); + s.input.delete_after(); + KeyOutcome::Continue + } // Insert mode: every non-chord Char (incl. e/j/k) types into // input. CTRL/ALT chords stay reserved. (KeyCode::Char(c), m) @@ -409,6 +467,9 @@ fn spawn_ask_worker(state: &mut App) { s.last_answer = None; s.streaming = true; s.scroll = 0; + // p9-fb-22: every new submission re-engages follow-tail so the + // streaming answer auto-scrolls into view as tokens arrive. + s.follow_tail = true; s.rx = Some(rx); // p9-fb-16: graduate the typed input into the in-flight turn, // clear the input box, ensure conversation_id exists, snapshot diff --git a/crates/kebab-tui/src/cheatsheet.rs b/crates/kebab-tui/src/cheatsheet.rs index bfd824e..fea5339 100644 --- a/crates/kebab-tui/src/cheatsheet.rs +++ b/crates/kebab-tui/src/cheatsheet.rs @@ -75,6 +75,9 @@ pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) { ("Tab", "cycle search mode (lexical / vector / hybrid)"), ("Enter", "force search now (skip debounce)"), ("j / k", "move selection (Normal)"), + ("← / →", "move cursor in query (p9-fb-22)"), + ("Home / End", "cursor to start / end of query"), + ("Delete", "remove char at cursor"), ("g", "open hit's citation in $EDITOR (Normal)"), ("o", "inspect selected hit's chunk (Normal — was `i` pre-fb-21)"), ("i", "Normal → Insert (toggle back to typing)"), @@ -85,7 +88,11 @@ pub fn render_cheatsheet(f: &mut Frame, area: Rect, app: &App) { ("type", "question (Insert)"), ("Enter", "submit"), ("e", "toggle explain mode (Normal)"), - ("j / k", "scroll transcript (Normal)"), + ("j / k", "scroll transcript (Normal — disengages auto-tail)"), + ("Shift-G", "jump to bottom + re-engage auto-tail (p9-fb-22)"), + ("← / →", "move cursor in input (p9-fb-22)"), + ("Home / End", "cursor to start / end of input"), + ("Delete", "remove char at cursor"), ("i", "Normal → Insert (toggle back to typing)"), ("Ctrl-L", "new conversation (clears turns)"), ("Esc", "back to Library (cancels in-flight worker)"), diff --git a/crates/kebab-tui/src/input.rs b/crates/kebab-tui/src/input.rs index 6f9cf73..53326e0 100644 --- a/crates/kebab-tui/src/input.rs +++ b/crates/kebab-tui/src/input.rs @@ -95,20 +95,26 @@ pub fn truncate_to_display_width(s: &str, max_cols: usize) -> String { out } -/// Text input buffer that tracks **display column** position, not -/// char count. Every wide char (Hangul / Kanji / fullwidth) advances -/// `cursor_col` by 2; every ASCII char by 1. Backspace pops one -/// char (`String::pop()` is char-aware) and rewinds the cursor by -/// that char's width. +/// Text input buffer with mid-string cursor editing. The cursor +/// position is stored as a byte index into `content` (UTF-8 char +/// boundary), and the display column is derived on demand by +/// summing `unicode-width` over the prefix. /// -/// Cursor invariant: `cursor_col == display_width(&content)` — -/// the cursor sits at the right edge of the typed content. v1 -/// is append-only; mid-string editing (insert at cursor / arrow -/// key navigation) is out of scope and would relax this invariant. +/// Wide chars (Hangul / Kanji / fullwidth) count 2 columns; ASCII +/// counts 1; combining marks 0. The cursor lives **between** chars, +/// not on them — `cursor_byte == 0` is "before the first char", +/// `cursor_byte == content.len()` is "after the last char". +/// +/// `push_char` / `pop_char` operate **at the cursor**, not at the +/// end. When the cursor is at the end (the freshly-typed state), +/// behavior matches the pre-fb-22 append-only buffer. When the +/// cursor is mid-string (after a Left arrow), `push_char` inserts +/// at that position and `pop_char` deletes the char immediately +/// before the cursor (Backspace semantics). #[derive(Debug, Default, Clone)] pub struct InputBuffer { content: String, - cursor_col: usize, + cursor_byte: usize, } impl InputBuffer { @@ -117,42 +123,96 @@ impl InputBuffer { Self::default() } - /// Append a single char and advance cursor by its display width. - /// Zero-width chars (combining marks) leave the cursor in place - /// but still extend `content`. + /// Insert a single char at the cursor and advance the cursor + /// past it. Zero-width chars (combining marks) leave the + /// display column unchanged but still extend `content`. pub fn push_char(&mut self, ch: char) { - let w = UnicodeWidthChar::width(ch).unwrap_or(0); - self.content.push(ch); - self.cursor_col += w; + self.content.insert(self.cursor_byte, ch); + self.cursor_byte += ch.len_utf8(); } - /// Append a `&str` char-by-char. Same width semantics as - /// `push_char` per element. + /// Insert a `&str` char-by-char at the cursor. Same width + /// semantics as `push_char` per element. pub fn push_str(&mut self, s: &str) { for ch in s.chars() { self.push_char(ch); } } - /// Remove the trailing char (Backspace) and rewind the cursor - /// by that char's display width. No-op on empty input. + /// Delete the char immediately before the cursor (Backspace) + /// and rewind the cursor onto its byte position. No-op on + /// empty input or when the cursor is already at the start. pub fn pop_char(&mut self) -> Option { - let ch = self.content.pop()?; - let w = UnicodeWidthChar::width(ch).unwrap_or(0); - self.cursor_col = self.cursor_col.saturating_sub(w); - Some(ch) + if self.cursor_byte == 0 { + return None; + } + let prev = self.content[..self.cursor_byte] + .chars() + .next_back() + .expect("cursor_byte > 0 implies at least one prior char"); + let new_byte = self.cursor_byte - prev.len_utf8(); + self.content.remove(new_byte); + self.cursor_byte = new_byte; + Some(prev) + } + + /// Delete the char at the cursor (Delete key). Cursor stays + /// in place. No-op when the cursor is at the end. + pub fn delete_after(&mut self) -> Option { + if self.cursor_byte >= self.content.len() { + return None; + } + Some(self.content.remove(self.cursor_byte)) + } + + /// Move the cursor one char to the left (toward index 0). + /// Returns true when the cursor moved. + pub fn move_left(&mut self) -> bool { + if self.cursor_byte == 0 { + return false; + } + let prev = self.content[..self.cursor_byte] + .chars() + .next_back() + .expect("cursor_byte > 0 implies at least one prior char"); + self.cursor_byte -= prev.len_utf8(); + true + } + + /// Move the cursor one char to the right (toward end of content). + /// Returns true when the cursor moved. + pub fn move_right(&mut self) -> bool { + if self.cursor_byte >= self.content.len() { + return false; + } + let next = self.content[self.cursor_byte..] + .chars() + .next() + .expect("cursor_byte < len implies at least one trailing char"); + self.cursor_byte += next.len_utf8(); + true + } + + /// Move the cursor to the start of the buffer. + pub fn move_home(&mut self) { + self.cursor_byte = 0; + } + + /// Move the cursor to the end of the buffer. + pub fn move_end(&mut self) { + self.cursor_byte = self.content.len(); } /// Reset to empty. pub fn clear(&mut self) { self.content.clear(); - self.cursor_col = 0; + self.cursor_byte = 0; } /// Move the typed string out, leaving the buffer empty (cursor 0). /// Convenience for "submit" flows that consume the input. pub fn take(&mut self) -> String { - self.cursor_col = 0; + self.cursor_byte = 0; std::mem::take(&mut self.content) } @@ -161,10 +221,11 @@ impl InputBuffer { &self.content } - /// Cursor column (display-width units). Matches - /// `display_width(self.as_str())` by construction. + /// Cursor column in display-width units — sum of every char's + /// `unicode-width` reading from the start of the buffer up to + /// (but not including) the cursor. pub fn cursor_col(&self) -> usize { - self.cursor_col + self.content[..self.cursor_byte].width() } /// True when no chars have been typed. @@ -173,6 +234,7 @@ impl InputBuffer { } } + #[cfg(test)] mod tests { use super::*; @@ -352,4 +414,141 @@ mod tests { fn place_cursor_x_keeps_position_when_within_bounds() { assert_eq!(place_cursor_x(10, 20, 2, 5), 17); // 10 + 2 + 5 } + + /// p9-fb-22: Left arrow moves cursor back by one char (ASCII). + #[test] + fn input_buffer_move_left_ascii() { + let mut b = InputBuffer::new(); + b.push_str("abc"); + assert_eq!(b.cursor_col(), 3); + assert!(b.move_left()); + assert_eq!(b.cursor_col(), 2); + assert!(b.move_left()); + assert_eq!(b.cursor_col(), 1); + assert!(b.move_left()); + assert_eq!(b.cursor_col(), 0); + assert!(!b.move_left()); + assert_eq!(b.cursor_col(), 0); + } + + /// p9-fb-22: Left arrow rewinds by full Hangul width (2 cols, 3 bytes). + #[test] + fn input_buffer_move_left_hangul() { + let mut b = InputBuffer::new(); + b.push_str("러스트"); + assert_eq!(b.cursor_col(), 6); + assert!(b.move_left()); + assert_eq!(b.cursor_col(), 4); + assert_eq!(b.as_str(), "러스트"); + } + + /// p9-fb-22: Right arrow advances by one char until the end. + #[test] + fn input_buffer_move_right_until_end() { + let mut b = InputBuffer::new(); + b.push_str("ab"); + b.move_home(); + assert_eq!(b.cursor_col(), 0); + assert!(b.move_right()); + assert_eq!(b.cursor_col(), 1); + assert!(b.move_right()); + assert_eq!(b.cursor_col(), 2); + assert!(!b.move_right()); + assert_eq!(b.cursor_col(), 2); + } + + /// p9-fb-22: Home / End cursor jumps. + #[test] + fn input_buffer_move_home_end() { + let mut b = InputBuffer::new(); + b.push_str("hello"); + b.move_home(); + assert_eq!(b.cursor_col(), 0); + b.move_end(); + assert_eq!(b.cursor_col(), 5); + } + + /// p9-fb-22: typing mid-string inserts at cursor (not append). + #[test] + fn input_buffer_insert_at_cursor_mid_string() { + let mut b = InputBuffer::new(); + b.push_str("abc"); + b.move_left(); // cursor between b and c + b.move_left(); // cursor between a and b + b.push_char('X'); // insert X between a and b + assert_eq!(b.as_str(), "aXbc"); + assert_eq!(b.cursor_col(), 2); + } + + /// p9-fb-22: Backspace mid-string removes the char before the cursor. + #[test] + fn input_buffer_backspace_at_cursor() { + let mut b = InputBuffer::new(); + b.push_str("abcde"); + b.move_left(); // cursor between d and e + b.move_left(); // cursor between c and d + b.pop_char(); // delete c + assert_eq!(b.as_str(), "abde"); + assert_eq!(b.cursor_col(), 2); + } + + /// p9-fb-22: Backspace at start of buffer is a no-op. + #[test] + fn input_buffer_backspace_at_home_is_noop() { + let mut b = InputBuffer::new(); + b.push_str("abc"); + b.move_home(); + assert!(b.pop_char().is_none()); + assert_eq!(b.as_str(), "abc"); + assert_eq!(b.cursor_col(), 0); + } + + /// p9-fb-22: Delete key removes the char AT the cursor; cursor stays. + #[test] + fn input_buffer_delete_after_at_cursor() { + let mut b = InputBuffer::new(); + b.push_str("abc"); + b.move_home(); + assert_eq!(b.delete_after(), Some('a')); + assert_eq!(b.as_str(), "bc"); + assert_eq!(b.cursor_col(), 0); + } + + /// p9-fb-22: Delete on empty buffer / at end → no-op. + #[test] + fn input_buffer_delete_after_at_end_is_noop() { + let mut b = InputBuffer::new(); + b.push_str("ab"); + // cursor at end + assert!(b.delete_after().is_none()); + assert_eq!(b.as_str(), "ab"); + } + + /// p9-fb-22: cursor_col stays consistent after mixed mid-string edits + /// with wide chars. + #[test] + fn input_buffer_cursor_col_after_mixed_hangul_edits() { + let mut b = InputBuffer::new(); + b.push_str("a한b"); // cursor at end, col = 1 + 2 + 1 = 4 + assert_eq!(b.cursor_col(), 4); + b.move_left(); // before 'b': col = 3 + assert_eq!(b.cursor_col(), 3); + b.move_left(); // before '한': col = 1 + assert_eq!(b.cursor_col(), 1); + b.push_char('글'); // insert 글 → "a글한b", cursor between 글 and 한, col = 1 + 2 = 3 + assert_eq!(b.as_str(), "a글한b"); + assert_eq!(b.cursor_col(), 3); + } + + /// p9-fb-22: take() resets cursor even when it was mid-string. + #[test] + fn input_buffer_take_resets_mid_string_cursor() { + let mut b = InputBuffer::new(); + b.push_str("abc"); + b.move_left(); + let s = b.take(); + assert_eq!(s, "abc"); + assert!(b.is_empty()); + assert_eq!(b.cursor_col(), 0); + } } diff --git a/crates/kebab-tui/src/library.rs b/crates/kebab-tui/src/library.rs index 4863caf..66fa062 100644 --- a/crates/kebab-tui/src/library.rs +++ b/crates/kebab-tui/src/library.rs @@ -369,6 +369,49 @@ fn handle_filter_edit_key(state: &mut App, key: KeyEvent) -> KeyOutcome { buf.pop_char(); KeyOutcome::Continue } + // p9-fb-22: cursor navigation + Delete inside the active filter + // field. Tab still cycles between Tags / Lang fields; arrows + // only move within the focused buffer. + KeyCode::Left => { + let buf = match edit.field { + FilterField::Tags => &mut edit.tags_buf, + FilterField::Lang => &mut edit.lang_buf, + }; + buf.move_left(); + KeyOutcome::Continue + } + KeyCode::Right => { + let buf = match edit.field { + FilterField::Tags => &mut edit.tags_buf, + FilterField::Lang => &mut edit.lang_buf, + }; + buf.move_right(); + KeyOutcome::Continue + } + KeyCode::Home => { + let buf = match edit.field { + FilterField::Tags => &mut edit.tags_buf, + FilterField::Lang => &mut edit.lang_buf, + }; + buf.move_home(); + KeyOutcome::Continue + } + KeyCode::End => { + let buf = match edit.field { + FilterField::Tags => &mut edit.tags_buf, + FilterField::Lang => &mut edit.lang_buf, + }; + buf.move_end(); + KeyOutcome::Continue + } + KeyCode::Delete => { + let buf = match edit.field { + FilterField::Tags => &mut edit.tags_buf, + FilterField::Lang => &mut edit.lang_buf, + }; + buf.delete_after(); + KeyOutcome::Continue + } KeyCode::Char(c) => { let buf = match edit.field { FilterField::Tags => &mut edit.tags_buf, diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index 03d9bc3..14dc816 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -307,6 +307,34 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { } KeyOutcome::Continue } + // p9-fb-22: cursor navigation + Delete inside the query + // input. Up/Down are reserved for hit list navigation, so + // Left/Right are the only horizontal keys; Home/End jump to + // the ends. None of these mark the input dirty (the query + // string is unchanged), so the debounce timer does not + // restart on a pure cursor move. + (KeyCode::Left, _) => { + s.input.move_left(); + KeyOutcome::Continue + } + (KeyCode::Right, _) => { + s.input.move_right(); + KeyOutcome::Continue + } + (KeyCode::Home, _) => { + s.input.move_home(); + KeyOutcome::Continue + } + (KeyCode::End, _) => { + s.input.move_end(); + KeyOutcome::Continue + } + (KeyCode::Delete, _) => { + if s.input.delete_after().is_some() { + s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + } + KeyOutcome::Continue + } // p9-fb-12 follow-up: Char dispatch is mode-gated. Normal // mode → j/k navigate; Insert mode → typed into input. // Single arm per key, body branches on mode (clearer than diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index b456cf0..3c035fa 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -654,3 +654,176 @@ fn hangul_typing_in_ask_input_advances_cursor_by_two_per_char() { assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한"); assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 2); } + +// ── p9-fb-22: cursor mid-string editing in Ask input ────────────────────── + +/// p9-fb-22 (issue #94): Left arrow rewinds the cursor; subsequent +/// Char insertion lands at that mid-string position (not at the end). +#[test] +fn left_arrow_then_typing_inserts_at_cursor_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Insert; + for ch in "abc".chars() { + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE)); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.input.as_str(), "abXc", "X inserts before c, not at end"); + assert_eq!(s.input.cursor_col(), 3, "cursor sits between X and c"); +} + +/// p9-fb-22 (issue #94): Right arrow at end of input is a no-op +/// (no overflow, no panic). +#[test] +fn right_arrow_at_end_is_noop_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Insert; + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.input.cursor_col(), 1); +} + +/// p9-fb-22 (issue #94): Home jumps cursor to the start; End to +/// the end. Available regardless of mode. +#[test] +fn home_end_jump_cursor_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Insert; + for ch in "hello".chars() { + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 0); + handle_key_ask(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 5); +} + +/// p9-fb-22 (issue #94): Delete key at the cursor removes the next +/// char without rewinding the cursor. +#[test] +fn delete_key_removes_char_at_cursor_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Insert; + for ch in "abc".chars() { + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + let s = app.ask.as_ref().unwrap(); + assert_eq!(s.input.as_str(), "bc", "Delete removed the leading 'a'"); + assert_eq!(s.input.cursor_col(), 0, "cursor stayed at column 0"); +} + +/// p9-fb-22 (issue #94): Hangul + Left arrow rewinds by 2 display +/// columns (one wide char), keeping the byte boundary intact. +#[test] +fn hangul_left_arrow_rewinds_by_two_cols_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Insert; + for ch in "한글".chars() { + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 4); + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 2); + // Inserting at the new cursor position lands between the two + // syllables, proving cursor_col is not just a display annotation. + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE)); + assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한X글"); +} + +// ── p9-fb-22: follow-tail auto-scroll on new transcript content ─────────── + +/// p9-fb-22 (issue #95): a freshly constructed AskState defaults to +/// `follow_tail = true` so the first answer streams into view. +#[test] +fn ask_state_default_follow_tail_is_true() { + let s = AskState::default(); + assert!(s.follow_tail, "follow_tail is on by default"); +} + +/// p9-fb-22 (issue #95): pressing `k` in Normal disengages follow- +/// tail so the user can review prior turns without the renderer +/// snapping back to the bottom on the next streamed token. +#[test] +fn k_disengages_follow_tail_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert!(!app.ask.as_ref().unwrap().follow_tail); +} + +/// p9-fb-22 (issue #95): Shift-G jumps the transcript to the bottom +/// and re-engages follow-tail so subsequent streaming auto-scrolls +/// again. +#[test] +fn shift_g_re_engages_follow_tail_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + { + let s = app.ask.as_mut().unwrap(); + s.follow_tail = false; + s.scroll = 7; + } + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)); + let s = app.ask.as_ref().unwrap(); + assert!(s.follow_tail, "Shift-G re-engages follow-tail"); + assert_eq!(s.scroll, 0, "scroll cleared (renderer recomputes)"); +} + +/// p9-fb-22 (issue #95): Ctrl-L clears the conversation AND resets +/// follow_tail to true so the next submission auto-scrolls. +#[test] +fn ctrl_l_resets_follow_tail_in_ask() { + let mut app = fresh_app(); + app.mode = kebab_tui::Mode::Normal; + app.ask.as_mut().unwrap().follow_tail = false; + handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert!(app.ask.as_ref().unwrap().follow_tail); +} + +/// 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). +#[test] +fn follow_tail_renders_tail_when_transcript_overflows() { + let mut app = fresh_app(); + { + let s = app.ask.as_mut().unwrap(); + // Stuff the transcript with 30 turns so the rendered viewport + // (height 12 → ~9 inner rows after borders + bottom split) + // can't show them all. + for i in 0..30 { + s.turns.push(Turn { + question: format!("Q{i}"), + answer: format!("A{i}-body-text"), + citations: Vec::new(), + created_at: OffsetDateTime::from_unix_timestamp(0).unwrap(), + }); + } + s.follow_tail = true; + } + let backend = TestBackend::new(60, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| render_ask(f, Rect::new(0, 0, 60, 20), &app)) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let rendered: String = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + // The very last turn (Q29 / A29) must be visible somewhere in + // the buffer — without follow-tail, the renderer would pin to + // the top and only the first few turns would show. + assert!( + rendered.contains("A29-body-text"), + "tail of transcript must be visible when follow_tail is on; got:\n{rendered}" + ); +} diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 341a556..aa4a26d 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,29 @@ 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-22 (post-dogfooding): mid-string cursor editing + Ask follow-tail auto-scroll + +**Issues**: Gitea #94 (커서 이슈) — 텍스트 입력 후 커서 이동 불가. Gitea #95 (새 응답 이슈) — 새 응답이 viewport 아래로 추가돼도 자동으로 스크롤이 따라가지 않음. 두 건 모두 사용자 도그푸딩 중 발견. + +**Root cause**: + +- p9-fb-10 의 `InputBuffer` 가 의도적으로 append-only (cursor invariant: `cursor_col == display_width(content)`). 화살표 / Home / End / Delete 가 어떤 pane 에서도 wired 되어 있지 않아 입력한 텍스트의 중간을 편집할 수 없었다. +- p9-3 의 Ask 트랜스크립트는 `Paragraph::scroll((s.scroll, 0))` 의 offset 을 위에서부터 카운트한다. 새 답변 도착 시 `s.scroll = 0` 으로 리셋하면 viewport 가 *위쪽* 에 고정되어, 트랜스크립트가 길어지면 새 응답이 시야 밖으로 밀려 사용자가 직접 `j` 로 스크롤해야 했다. + +**Live binding 변경**: + +- `InputBuffer` cursor 모델을 byte position 기반으로 재구성. `cursor_col` 은 prefix slice 의 `unicode-width` 합으로 derive. 새 메서드: `move_left / move_right / move_home / move_end / delete_after`. `push_char` / `pop_char` 는 cursor 위치에서 동작하도록 의미 변경 (cursor 가 끝에 있을 때 기존 append 동작과 동일 — 호환). +- Ask / Search / Library filter overlay 세 곳에 `←` / `→` / `Home` / `End` / `Delete` key handler 추가. Search 는 cursor 이동만으로는 input_dirty_at 을 바꾸지 않고, `Delete` 로 실제로 char 가 사라질 때만 debounce 타이머를 reset (커서 이동 ≠ 쿼리 변경). +- `AskState` 에 `follow_tail: bool` 필드 추가 (default `true`). `render_answer` 가 `follow_tail` 인 동안 매 프레임마다 `Paragraph::line_count(width)` 로 wrapped row 수를 재계산해 스크롤을 `line_count - inner_height` 로 pin. 사용자가 `j` / `k` 누르면 `follow_tail = false` 로 freeze, `Shift-G` 로 다시 활성화. 새 submission 과 `Ctrl-L` 도 follow-tail 을 재활성화. +- `kebab-tui` 의 `ratatui` dep 에 `unstable-rendered-line-info` feature 활성화 — `Paragraph::line_count` 가 ratatui 0.28 에서 unstable. ratatui 버전 bump 시 본 feature 의 안정 여부 재확인 필요 (현재는 0.28.1 에 pin). +- cheatsheet popup 의 Search / Ask section 에 화살표 + Home/End + Delete row 추가, Ask section 에 `Shift-G` row 추가. + +**Spec contract impact**: p9-fb-10 frozen spec 의 "v1 is append-only; mid-string editing... is out of scope" 문구와 충돌. p9-fb-10 의 frozen 텍스트는 그대로 두고 본 HOTFIXES 항목이 InputBuffer 의 live cursor 모델 source of truth. p9-3 frozen spec 에는 follow-tail 동작이 명시되지 않았음 — 본 항목이 추가 동작 기록. + +**Tests added**: 12 신규 InputBuffer unit (move_left/right ASCII/Hangul, home/end, mid-string insert, backspace at cursor, delete_after, mixed-width cursor invariant), 5 신규 Ask integration (left/right/home/end/Delete on Ask input, Hangul left arrow, follow_tail default, k disengages, Shift-G re-engages, Ctrl-L resets, follow-tail rendering bottom of long transcript). 기존 30 개 InputBuffer + Ask 테스트는 backwards-compat 으로 그대로 통과 (cursor 가 끝에 있을 때 push_char/pop_char 의미 동일). + +**Known limitation (deferred)**: cheatsheet popup body 가 Search +3 row, Ask +4 row 로 늘어나 75% height 한계가 더 빡빡해짐. p9-fb-21 의 deferred 한계와 같은 후속 task (popup scroll 또는 multi-column layout) 가 점점 더 필요함. + ## 2026-05-03 — p9-fb-21 (post-dogfooding): `i` universal Insert toggle + Search `i`→`o` rebind + F1 prefix **Spec added**: `tasks/p9/p9-fb-21-tui-insert-key-discoverability.md` (status `completed` 직접). 이전 도그푸딩 사이클 (p9-fb-01..20) 닫은 후 사용자가 다시 TUI 돌려보며 발견: diff --git a/tasks/INDEX.md b/tasks/INDEX.md index 97beb45..db70374 100644 --- a/tasks/INDEX.md +++ b/tasks/INDEX.md @@ -105,6 +105,7 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능. - [p9-fb-19 search cache](p9/p9-fb-19-search-cache.md) - [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) ## Post-merge 핫픽스 diff --git a/tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md b/tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md new file mode 100644 index 0000000..ac827b2 --- /dev/null +++ b/tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md @@ -0,0 +1,76 @@ +--- +phase: P9 +component: kebab-tui +task_id: p9-fb-22 +title: "Mid-string cursor editing + Ask follow-tail auto-scroll (post-merge dogfooding)" +status: completed +depends_on: [p9-fb-10, p9-3] +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 — Gitea #94 (입력 후 커서 이동 안 됨), Gitea #95 (새 응답이 아래로 추가돼도 자동 스크롤 안 됨). +--- + +# p9-fb-22 — InputBuffer cursor editing + Ask follow-tail + +## Goal + +- 모든 input pane (Ask / Search / Library filter overlay) 에서 화살표 / Home / End / Delete 로 mid-string 커서 편집 가능. +- Ask 트랜스크립트가 새 응답 도착 시 자동으로 viewport bottom 을 따라감 (auto-tail). 사용자가 위로 스크롤하면 freeze, 명시적 키 (`Shift-G`) 로 다시 활성화. + +## Background + +`p9-fb-10` 의 `InputBuffer` 는 의도적으로 append-only — `cursor_col == display_width(content)` invariant 가 항상 성립. 좋은 결정이지만 mid-string 편집 (한글 한 글자 잘못 쳤을 때 backspace 로 다 지우지 않고 화살표로 그 자리만 고치기) 가 불가. + +`p9-3` 의 Ask 트랜스크립트는 `Paragraph::scroll((s.scroll, 0))` 의 offset 을 위에서부터 카운트. 새 답변 도착 시 `s.scroll = 0` 으로 리셋 — viewport 가 위쪽 고정. 트랜스크립트가 길어지면 새 응답이 시야 밖으로 밀림. 사용자는 매번 `j` 로 직접 스크롤해야 함. + +## Allowed dependencies + +- 기존 `kebab-tui` 의존성. +- `ratatui` 의 `unstable-rendered-line-info` feature — `Paragraph::line_count(width)` 사용을 위해 활성화. ratatui 0.28 에 pin 된 동안 안정. + +## Public surface + +신규 `InputBuffer` 메서드: `move_left`, `move_right`, `move_home`, `move_end`, `delete_after`. 기존 `push_char` / `pop_char` 는 cursor 위치에서 동작하도록 의미 변경 (cursor 가 끝에 있을 때 기존 동작과 동일). + +신규 `AskState` 필드: `follow_tail: bool` (default `true`). + +## Behavior contract + +### InputBuffer + +- `cursor_byte` 가 새 source of truth (UTF-8 char boundary). `cursor_col()` 는 prefix slice 의 `unicode-width` 합으로 derive. +- `push_char(ch)`: `content.insert(cursor_byte, ch)` 후 `cursor_byte += ch.len_utf8()`. cursor 가 끝에 있을 때 기존 append 동작과 동일. +- `pop_char()`: cursor 직전 char 제거. cursor 가 시작에 있을 때 `None` 반환. +- `delete_after()`: cursor 위치 char 제거 (cursor 그대로). 끝에서는 `None`. +- `move_left() / move_right()`: char-boundary 단위 이동. `bool` 반환 (이동 성공 여부). +- `move_home() / move_end()`: 양 끝점 점프. +- backwards-compat: cursor 가 끝일 때 모든 메서드 동작이 p9-fb-10 spec 과 동일. 30+ 기존 테스트 변경 없이 통과. + +### Ask follow-tail + +- `AskState::default()` 가 `follow_tail = true` 로 초기화 (수동 `Default` impl 추가 — `derive(Default)` 는 `false` 가 됨). +- `render_answer` 가 `follow_tail` 동안 매 프레임 `Paragraph::line_count(inner.width)` 로 wrapped row 수 계산, `scroll = line_count - inner_height` 로 pin. wrap-aware 이므로 viewport 너비 변경 시에도 정확히 bottom. +- `j` (scroll down): `follow_tail = false` 로 disengage. `s.scroll += 1`. +- `k` (scroll up): `follow_tail = false`. `s.scroll -= 1`. +- `Shift-G`: `follow_tail = true` + `s.scroll = 0`. Normal 모드에서만. +- 새 submission, `Ctrl-L` 도 `follow_tail = true` 재설정. + +### Pane key handler 추가 + +- Ask: `Left / Right / Home / End / Delete` mode 무관 (Mode::Insert / Normal 양쪽). `Shift-G` Normal 한정. +- Search: 동일 5 key. `Delete` 만 input_dirty_at reset (cursor 이동 ≠ 쿼리 변경 → debounce timer 유지). +- Library filter overlay: 동일 5 key, 활성 field (Tags / Lang) 의 buffer 에 적용. + +## Tests + +- 12 신규 InputBuffer unit (move_left/right ASCII/Hangul, home/end, mid-string insert, backspace at cursor, delete_after, mixed-width cursor invariant, take 후 cursor reset). +- 6 신규 Ask integration (Left/Right/Home/End/Delete on Ask input, Hangul left arrow, follow_tail default, k disengages, Shift-G re-engages, Ctrl-L resets, follow-tail rendering bottom of long transcript). +- 기존 30+ 테스트는 그대로 통과 (cursor 가 끝일 때 backwards-compat). + +## Risks / notes + +- `ratatui::Paragraph::line_count` 가 unstable feature flag 뒤에 있음 — ratatui 0.28 → 0.29 bump 시 stable surface 여부 재확인 필요. unstable surface 가 사라지면 manual estimator (per-Line `ceil(display_cols / inner_width)`) 로 fallback 가능. +- cheatsheet popup body 가 Search +3 row, Ask +4 row 늘어남. p9-fb-21 의 deferred 한계 (75% height 안에 Inspect section 잘림 가능) 가 더 빡빡해짐 — 후속 task 로 popup scroll 또는 multi-column layout 고려 필요. + +Live deviations 반영 위치: `tasks/HOTFIXES.md` `2026-05-04 — p9-fb-22` 항목.