fix(kebab-tui): p9-fb-22 — mid-string cursor editing + Ask follow-tail auto-scroll

도그푸딩 중 발견된 두 건 (Gitea #94, #95) 동시 수정.

#94 — `InputBuffer` 가 append-only 라 Ask/Search/Filter overlay 에서
타이핑한 텍스트의 중간을 편집할 수 없었음. 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 가 끝일 때 backwards-compatible). Ask / Search / Library filter
overlay 세 곳에 `← / → / Home / End / Delete` key handler 추가. Search 는
cursor 이동만으로는 input_dirty_at 을 reset 하지 않음 (커서 이동 ≠ 쿼리
변경 → debounce 타이머 유지).

#95 — Ask 트랜스크립트의 `Paragraph::scroll((s.scroll, 0))` 가 위에서
부터 카운트라, 새 답변 도착 시 `s.scroll = 0` 으로 리셋하면 viewport 가
위쪽 고정 → 트랜스크립트가 길어지면 새 응답이 시야 밖으로 밀림. `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 끄고
`Shift-G` 가 다시 켬. 새 submission, `Ctrl-L` 도 follow-tail 재활성화.

`kebab-tui` 의 ratatui dep 에 `unstable-rendered-line-info` feature
활성화 — `Paragraph::line_count` 가 ratatui 0.28 에서 unstable. 0.28 에
pin 되어있는 동안 안정. 향후 ratatui bump 시 본 feature 의 stable 여부
재확인 필요.

cheatsheet popup Search/Ask section 에 화살표 + Home/End + Delete row
추가, Ask 에 `Shift-G` row 추가. README + HANDOFF + HOTFIXES + INDEX 동기.

Tests: 12 신규 InputBuffer unit + 6 신규 Ask integration. 기존 699 워크
스페이스 테스트 모두 통과 (cursor 가 끝일 때 backwards-compat).

Spec: `tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md` (status `completed`).
Live deviation 기록: `tasks/HOTFIXES.md` `2026-05-04 — p9-fb-22`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 15:29:09 +00:00
parent af8c162e09
commit 294b1ed00c
13 changed files with 683 additions and 38 deletions

View File

@@ -59,6 +59,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 partial)** — TUI vim-style mode machine (절반 ship — heuristic 제거는 follow-up). `kebab_tui::Mode::{Normal, Insert}` enum + `Mode::auto_for(pane)` (Library/Inspect/Jobs → Normal, Search/Ask → Insert) + `Mode::label()` (`"-- NORMAL --"` / `"-- INSERT --"`) + `App.mode: Mode` field. run loop `mode_intercept(app, key)` 가 dispatch 전 intercept — Insert 에서 `Esc` → Normal (어디서나), Normal 에서 `i` → Insert (Library/Inspect/Jobs 만, Search/Ask 는 자동 Insert 라 `i` 가 typed char). 헤더 우측에 mode label colored (Insert = Role::Success green, Normal = Role::Heading cyan+bold). pane 전환 시 `app.mode = Mode::auto_for(p)` 자동 flip. **Deferred (HOTFIXES entry)**: `is_typing_mod` (search) + input-empty heuristic (ask) 는 후속 PR 에서 mode-authoritative 로 교체 — 현재는 user-visible signal (label + auto flip + i/Esc) 만 ship, 키 dispatch 는 heuristic 유지. spec status `in_progress` (not `completed`). spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-12 follow-up)** — heuristic 제거 (partial PR 의 deferred 부분 finalize). `search::is_typing_mod` (CTRL/ALT chord filter) 함수 삭제 + `ask::handle_key_ask` 의 input-empty heuristic 삭제. 새 dispatch: `search::handle_key_search``i` (chunk inspect) / `g` (editor jump) pre-pass 가 `state.mode == Mode::Normal` 일 때만 fire (Insert 에서는 typed char). main match 의 `j`/`k`/Char(c) 가 `state.mode` 로 분기 (Normal → 선택 이동, Insert → input.push). `ask::handle_key_ask``e`/`j`/`k` 도 동일 패턴 — Normal 에서 toggle/scroll, Insert 에서 input typing. 테스트 fixture (`tests/search.rs::fresh_app`, `tests/ask.rs::fresh_app`) 가 `app.mode = Mode::auto_for(focus)` 로 run-loop 동작 mirror. 기존 nav 테스트 (j_k_move, g_key_enqueues, e_toggles) 는 explicit `app.mode = Mode::Normal` 추가, 신규 4 테스트 (j_in_insert_types / arbitrary_char_in_normal_noop / e_types_in_insert / jk_scroll-in-normal-type-in-insert) 가 mode-authoritative 동작 pin. spec status `in_progress``completed`. spec: `tasks/p9/p9-fb-12-tui-mode-machine.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-10 partial)** — TUI CJK rendering helpers. `kebab-tui::input::{display_width, truncate_to_display_width}` 신규 — `unicode-width` 위에서 column-단위 width 계산 (ASCII=1, Hangul/CJK/fullwidth=2, combining=0) + char-boundary 안전 truncate (wide char 를 split 없이 keep-or-omit, ellipsis 1 col). library.rs 의 중복 `truncate_to_display_width` private fn 제거 — 단일 source. 9 unit tests (ASCII / Hangul / Japanese / mixed / truncate fits·overflow·zero-cols·wide-char-boundary / `String::pop` char-aware sanity) + 1 integration render test (Korean + Japanese fixture, TestBackend 80×20, 한글/일본어 글자가 frame 에 살아남음 확인). spec 의 `InputBuffer` struct (cursor 가 column 단위 wide-char width 추적) 도입은 follow-up — Ask/Search/Editor pane 의 String + cursor 일괄 마이그레이션이 회귀 표면이 커서 helper 만 먼저 머지. backspace 는 모든 pane 이 이미 `String::pop()` 사용 (char-aware) → byte-boundary 안전성 helper 없이도 확보. crossterm 0.28 이 native IME composing 미노출 — preedit handling out of scope. spec status `planned``in_progress`. spec: `tasks/p9/p9-fb-10-tui-cjk-input.md`.
- **2026-05-04 P9 post-도그푸딩 (p9-fb-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 미진행이라 🟡 유지.

View File

@@ -76,7 +76,7 @@ kebab doctor
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab ask "<query>" [--show-citations / --hide-citations] [--session <id>]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요. `--session <id>` 로 multi-turn — 첫 호출에서 SQLite `chat_sessions` 에 자동 생성, 이후 호출은 prior turns 를 history 로 받아 follow-up. session id 는 사용자 지정 (e.g. `kb-rust-async-2026-05`) — `kebab reset --data-only` 로 모든 session wipe |
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). vim-style mode (header 우측 `-- NORMAL --` / `-- INSERT --`) — Library/Inspect 는 자동 NORMAL, Search/Ask 는 자동 INSERT. `i` 로 Normal→Insert (모든 pane — p9-fb-21), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/o/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. (Search 의 chunk inspect 키는 `i``o` 로 rebind — `i` 가 universal Insert toggle.) **`F1` 로 cheatsheet popup** (현재 pane 의 키 매핑 + global 토글 표) — `Esc` / `F1` 로 닫기. Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해). Library 의 doc-list 가 한글 / 일본어 / 중국어 (CJK) 제목을 wide-char 정확한 column width 로 truncate — 한글 제목이 한 줄을 넘기지 않음 (CJK 1 자 = 2 col). Search/Ask/Filter 입력의 cursor 가 wide char 위에서 column 단위로 정렬 — 한글 입력 시 caret 이 글자 옆에 정확히 놓임. 화면 하단 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 회귀 측정 |

View File

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

View File

@@ -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<std::sync::mpsc::Receiver<String>>,
/// 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<String>,
/// p9-fb-16: completed turns of the current conversation. Each
@@ -242,6 +249,28 @@ pub struct AskState {
pub last_answer: Option<kebab_core::Answer>,
}
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)]

View File

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

View File

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

View File

@@ -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<char> {
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<char> {
if self.cursor_byte >= self.content.len() {
return None;
}
Some(self.content.remove(self.cursor_byte))
}
/// Move the cursor one char to the left (toward index 0).
/// Returns true when the cursor moved.
pub fn move_left(&mut self) -> bool {
if self.cursor_byte == 0 {
return false;
}
let prev = self.content[..self.cursor_byte]
.chars()
.next_back()
.expect("cursor_byte > 0 implies at least one prior char");
self.cursor_byte -= prev.len_utf8();
true
}
/// Move the cursor one char to the right (toward end of content).
/// Returns true when the cursor moved.
pub fn move_right(&mut self) -> bool {
if self.cursor_byte >= self.content.len() {
return false;
}
let next = self.content[self.cursor_byte..]
.chars()
.next()
.expect("cursor_byte < len implies at least one trailing char");
self.cursor_byte += next.len_utf8();
true
}
/// Move the cursor to the start of the buffer.
pub fn move_home(&mut self) {
self.cursor_byte = 0;
}
/// Move the cursor to the end of the buffer.
pub fn move_end(&mut self) {
self.cursor_byte = self.content.len();
}
/// Reset to empty.
pub fn clear(&mut self) {
self.content.clear();
self.cursor_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);
}
}

View File

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

View File

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

View File

@@ -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::<String>()
})
.collect::<Vec<_>>()
.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}"
);
}

View File

@@ -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 돌려보며 발견:

View File

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

View File

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