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:
@@ -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 미진행이라 🟡 유지.
|
||||
|
||||
@@ -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 회귀 측정 |
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 돌려보며 발견:
|
||||
|
||||
@@ -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 핫픽스
|
||||
|
||||
|
||||
76
tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md
Normal file
76
tasks/p9/p9-fb-22-tui-cursor-and-autoscroll.md
Normal 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` 항목.
|
||||
Reference in New Issue
Block a user