feat(kebab-tui): p9-fb-10 follow-up — InputBuffer + cursor + Korean FTS5 pin #88

Merged
altair823 merged 12 commits from feat/p9-fb-10-inputbuffer into main 2026-05-03 10:39:06 +00:00
16 changed files with 529 additions and 87 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-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 도그푸딩 후속 (p9-fb-13)** — TUI cheatsheet popup. `kebab-tui::cheatsheet::render_cheatsheet(f, area, app)` 신규 — 70%/60% centered modal, sections (Global / Library / Search / Ask / Inspect) + global toggle table + 현재 focused pane footer. `App.cheatsheet_visible: bool` 필드 + `pub fn cheatsheet_visible()` getter. run loop `cheatsheet_intercept(app, key)` 가 mode_intercept 보다 먼저 dispatch — `F1` 토글 (open/close), `Esc` 가 visible 일 때 닫기 (mode_intercept 를 우회해서 cheatsheet 닫기 가 mode flip 도 발동시키지 않도록), 그 외 키는 fall-through (popup 열린 채 navigation 가능). modifier-bearing F1 (Ctrl-F1 등) 은 무시. **HOTFIXES 기록**: spec 의 `?` trigger 가 Library 의 quick-Ask binding 과 충돌해서 `F1` 으로 rebind. spec 의 verb-form hint line 재구성은 별 후속 PR (기존 footer 가 동일 역할). spec status `planned``in_progress` (verb hint deferral 으로 partial). spec: `tasks/p9/p9-fb-13-tui-cheatsheet.md`.
## 다음 task 후보

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 (Library/Inspect 만), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/i/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. **`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). |
| `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 (Library/Inspect 만), `Esc` 로 Insert→Normal 어디서나. mode-authoritative dispatch — Search 의 `j/k/i/g`, Ask 의 `e/j/k` 는 NORMAL 모드에서만 명령으로 동작, INSERT 에서는 입력 문자로 typing. **`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 이 글자 옆에 정확히 놓임. |
| `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

@@ -81,6 +81,19 @@ impl TestEnv {
}
}
/// Test helper: build a `SearchQuery` for lexical mode at k=10. Used
/// by every kebab-app integration test that calls
/// `kebab_app::search_with_config`. Centralized here so a future
/// `SearchQuery` field bump only edits one site.
pub fn lexical_query(text: &str) -> kebab_core::SearchQuery {
kebab_core::SearchQuery {
text: text.to_string(),
mode: kebab_core::SearchMode::Lexical,
k: 10,
filters: kebab_core::SearchFilters::default(),
}
}
fn copy_fixture_workspace(dest: &Path) {
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")

View File

@@ -0,0 +1,48 @@
//! p9-fb-10: smoke pin that a Korean query reaches FTS5 and returns
//! the matching Hangul document. NFC normalization happens upstream
//! in `kebab-normalize`; this test only exercises the end-to-end
//! facade — ingest a Korean .md → lexical search → at least one hit.
mod common;
use common::TestEnv;
/// p9-fb-10 — A Korean token present in a Hangul document must survive
/// the ingest → FTS5 → search round-trip. NFC normalization is wired
/// upstream in `kebab-normalize`; this test just verifies the facade
/// doesn't drop or corrupt CJK text along the way.
#[test]
fn korean_lexical_query_returns_korean_document() {
let env = TestEnv::lexical_only();
// Write a Korean Markdown document into the temp workspace.
let doc_path = env.workspace_root.join("러스트-비동기.md");
std::fs::write(
&doc_path,
"# 러스트 비동기 프로그래밍\n\n토큰: 러스트, 비동기, async, await\n",
)
.expect("write Korean fixture doc");
// Ingest — lexical_only() disables fastembed so no AVX required.
kebab_app::ingest_with_config(env.config.clone(), env.scope(), true)
.expect("ingest must succeed");
// Lexical search for "러스트" — must return the Korean document.
let hits = kebab_app::search_with_config(env.config.clone(), common::lexical_query("러스트"))
.expect("search must succeed");
assert!(
!hits.is_empty(),
"expected at least one hit for Korean lexical query '러스트'"
);
// At least one hit must reference our Korean document.
// "러스트-비동기" is the exact filename stem — a single combined
// check is unambiguous and avoids false positives from other docs.
let any_korean = hits.iter().any(|h| h.doc_path.0.contains("러스트-비동기"));
assert!(
any_korean,
"expected at least one hit on the Korean fixture doc, got: {:?}",
hits.iter().map(|h| &h.doc_path.0).collect::<Vec<_>>()
);
}

View File

@@ -5,15 +5,6 @@ mod common;
use common::TestEnv;
fn lexical_query(text: &str) -> kebab_core::SearchQuery {
kebab_core::SearchQuery {
text: text.to_string(),
mode: kebab_core::SearchMode::Lexical,
k: 10,
filters: kebab_core::SearchFilters::default(),
}
}
#[test]
fn lexical_search_returns_hits_after_ingest() {
let env = TestEnv::lexical_only();
@@ -22,7 +13,7 @@ fn lexical_search_returns_hits_after_ingest() {
// "Ownership" appears as a heading + paragraph in intro.md and
// matches FTS5 default tokenizer easily.
let hits =
kebab_app::search_with_config(env.config.clone(), lexical_query("ownership"))
kebab_app::search_with_config(env.config.clone(), common::lexical_query("ownership"))
.unwrap();
assert!(!hits.is_empty(), "expected ≥1 hit for 'ownership'");
@@ -44,7 +35,7 @@ fn lexical_search_returns_hits_after_ingest() {
fn lexical_search_empty_query_returns_empty() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap();
let hits = kebab_app::search_with_config(env.config.clone(), lexical_query(" "))
let hits = kebab_app::search_with_config(env.config.clone(), common::lexical_query(" "))
.unwrap();
assert!(hits.is_empty(), "blank query must short-circuit empty");
}
@@ -57,9 +48,9 @@ fn cached_search_returns_same_hits_on_repeat() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap();
let app = kebab_app::App::open_with_config(env.config.clone()).unwrap();
let first = app.search(lexical_query("ownership")).unwrap();
let first = app.search(common::lexical_query("ownership")).unwrap();
assert!(!first.is_empty(), "first call must return ≥1 hit");
let second = app.search(lexical_query("ownership")).unwrap();
let second = app.search(common::lexical_query("ownership")).unwrap();
assert_eq!(
first.len(),
second.len(),
@@ -79,9 +70,9 @@ fn cache_key_normalization_treats_case_and_whitespace_as_equivalent() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap();
let app = kebab_app::App::open_with_config(env.config.clone()).unwrap();
let plain = app.search(lexical_query("ownership")).unwrap();
let upper = app.search(lexical_query("OWNERSHIP")).unwrap();
let padded = app.search(lexical_query(" Ownership ")).unwrap();
let plain = app.search(common::lexical_query("ownership")).unwrap();
let upper = app.search(common::lexical_query("OWNERSHIP")).unwrap();
let padded = app.search(common::lexical_query(" Ownership ")).unwrap();
assert_eq!(plain.len(), upper.len());
assert_eq!(plain.len(), padded.len());
// chunk_ids are deterministic — same query class, same set.
@@ -97,11 +88,11 @@ fn search_uncached_returns_same_hits_as_cached() {
let env = TestEnv::lexical_only();
kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap();
let cached =
kebab_app::search_with_config(env.config.clone(), lexical_query("ownership"))
kebab_app::search_with_config(env.config.clone(), common::lexical_query("ownership"))
.unwrap();
let uncached = kebab_app::search_uncached_with_config(
env.config.clone(),
lexical_query("ownership"),
common::lexical_query("ownership"),
)
.unwrap();
assert_eq!(cached.len(), uncached.len());

View File

@@ -114,7 +114,10 @@ impl Default for LibraryState {
/// re-exporting field accessors. The pane behavior + render live in
/// `crate::search`.
pub struct SearchState {
pub input: String,
/// p9-fb-10: `InputBuffer` tracks display-column cursor position
/// alongside content so wide chars (Hangul, CJK) place the
/// terminal cursor in the correct column.
pub input: crate::input::InputBuffer,
pub mode: kebab_core::SearchMode,
pub hits: Vec<kebab_core::SearchHit>,
pub selected_hit: usize,
@@ -166,7 +169,7 @@ pub enum SearchWorkerMessage {
impl Default for SearchState {
fn default() -> Self {
Self {
input: String::new(),
input: crate::input::InputBuffer::new(),
mode: kebab_core::SearchMode::Hybrid,
hits: Vec::new(),
selected_hit: 0,
@@ -196,7 +199,10 @@ impl Default for SearchState {
/// start a fresh conversation.
#[derive(Default)]
pub struct AskState {
pub input: String,
/// p9-fb-10: `InputBuffer` tracks display-column cursor position
/// alongside content so wide chars (Hangul, CJK) place the
/// terminal cursor in the correct column.
pub input: crate::input::InputBuffer,
/// Toggled by the `e` key. Re-applied on the next `Enter`.
pub explain: bool,
/// True between `Enter` press and worker thread completion.
@@ -469,6 +475,17 @@ impl App {
self.library.inner.list_state.select(Some(0));
}
}
/// Test-only: read back the current Library doc filter so tests
/// can assert on what `FilterEdit::commit_into` produced after a
/// simulated Enter key. Never call this in the render path.
///
/// Marked `#[doc(hidden)]` because it is a test seam, not part
/// of the official UI API.
#[doc(hidden)]
pub fn library_filter_for_testing(&self) -> &kebab_core::DocFilter {
&self.library.inner.filter
}
}
#[cfg(test)]

View File

@@ -50,6 +50,8 @@ pub fn render_ask(f: &mut Frame, area: Rect, state: &App) {
}
fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
const PROMPT: &str = "? ";
let mode_badge = if s.explain { " explain" } else { "" };
// Distinguish three async states for the operator:
// - currently streaming (worker still emitting tokens)
@@ -64,7 +66,7 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::T
""
};
let line = Line::from(vec![
Span::styled("? ", theme.style(crate::theme::Role::Heading)),
Span::styled(PROMPT, theme.style(crate::theme::Role::Heading)),
Span::raw(s.input.as_str()),
Span::styled(mode_badge, theme.style(crate::theme::Role::Warning)),
Span::styled(busy, theme.style(crate::theme::Role::Hint)),
@@ -72,7 +74,20 @@ fn render_input(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::T
let block = Block::default()
.title("ask (Enter=submit e=explain Ctrl-L=new conversation Esc=back)")
.borders(Borders::ALL);
f.render_widget(Paragraph::new(line).block(block), area);
let inner = block.inner(area);
let paragraph = Paragraph::new(line).block(block);
f.render_widget(paragraph, area);
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
// cursor_position is Some (our case here). When a render fn
// omits set_cursor_position (Library/Inspect), ratatui calls
// hide_cursor instead. So this single call both positions and
// unhides the caret for the Ask input column.
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
// the right edge of the inner area.
let prompt_w = crate::input::display_width(PROMPT);
let cursor_x = crate::input::place_cursor_x(inner.x, inner.width, prompt_w, s.input.cursor_col());
f.set_cursor_position((cursor_x, inner.y));
}
fn render_answer(f: &mut Frame, area: Rect, s: &AskState, theme: &crate::theme::Theme) {
@@ -333,7 +348,7 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
if state
.ask
.as_ref()
.map(|s| s.streaming || s.thread.is_some() || s.input.trim().is_empty())
.map(|s| s.streaming || s.thread.is_some() || s.input.as_str().trim().is_empty())
.unwrap_or(true)
{
return KeyOutcome::Continue;
@@ -363,7 +378,7 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
}
(KeyCode::Backspace, _) => {
let s = state.ask.as_mut().unwrap();
s.input.pop();
s.input.pop_char();
KeyOutcome::Continue
}
// Insert mode: every non-chord Char (incl. e/j/k) types into
@@ -374,7 +389,7 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
&& !m.contains(KeyModifiers::ALT) =>
{
let s = state.ask.as_mut().unwrap();
s.input.push(c);
s.input.push_char(c);
KeyOutcome::Continue
}
// Normal mode + un-handled Char → no-op (no typing in Normal).
@@ -386,7 +401,9 @@ fn spawn_ask_worker(state: &mut App) {
let (tx, rx) = mpsc::channel::<String>();
let cfg = state.config.clone();
let s = state.ask.as_mut().unwrap();
let query = s.input.clone();
// p9-fb-10: take() consumes the input in one step (no clone +
// clear). The buffer is left empty with cursor at 0.
let query = s.input.take();
let explain = s.explain;
s.partial.clear();
s.last_answer = None;
@@ -397,7 +414,6 @@ fn spawn_ask_worker(state: &mut App) {
// clear the input box, ensure conversation_id exists, snapshot
// history for the worker.
s.current_question = Some(query.clone());
s.input.clear();
if s.conversation_id.is_none() {
s.conversation_id = Some(make_conversation_id());
}

View File

@@ -32,6 +32,27 @@
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Compute the cursor column for a text-input pane: prompt width +
/// content cursor, summed in `usize` to avoid `u16` overflow, then
/// clamped to fit within `inner_width` columns from `inner_x`.
///
/// Use as:
/// ```ignore
/// f.set_cursor_position((place_cursor_x(inner.x, inner.width, prompt_w, buf.cursor_col()), inner.y));
/// ```
///
/// If a fourth input pane is added, use this helper rather than
/// open-coding the arithmetic — one place to fix if the clamping
/// policy ever changes.
pub fn place_cursor_x(inner_x: u16, inner_width: u16, prompt_w: usize, cursor_col: usize) -> u16 {
let raw = (inner_x as usize)
.saturating_add(prompt_w)
.saturating_add(cursor_col);
let max = (inner_x as usize)
.saturating_add(inner_width.saturating_sub(1) as usize);
raw.min(max).try_into().unwrap_or(u16::MAX)
}
/// Display width of `s` in terminal columns. CJK / fullwidth = 2
/// per char, ASCII = 1, combining marks = 0. Sums every char's
/// `unicode-width` reading — same calculation Ratatui uses
@@ -74,6 +95,84 @@ 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.
///
/// 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.
#[derive(Debug, Default, Clone)]
pub struct InputBuffer {
content: String,
cursor_col: usize,
}
impl InputBuffer {
/// Create an empty buffer.
pub fn new() -> Self {
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`.
pub fn push_char(&mut self, ch: char) {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
self.content.push(ch);
self.cursor_col += w;
}
/// Append a `&str` char-by-char. 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.
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)
}
/// Reset to empty.
pub fn clear(&mut self) {
self.content.clear();
self.cursor_col = 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;
std::mem::take(&mut self.content)
}
/// Borrow the typed text.
pub fn as_str(&self) -> &str {
&self.content
}
/// Cursor column (display-width units). Matches
/// `display_width(self.as_str())` by construction.
pub fn cursor_col(&self) -> usize {
self.cursor_col
}
/// True when no chars have been typed.
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -158,4 +257,99 @@ mod tests {
assert_eq!(s, "");
assert_eq!(display_width(&s), 2);
}
/// p9-fb-10: ASCII typing advances cursor by 1 per char.
#[test]
fn input_buffer_ascii_cursor_advances_by_one() {
let mut b = InputBuffer::new();
for ch in "hello".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), 5);
assert_eq!(b.as_str(), "hello");
}
/// p9-fb-10: Hangul typing advances cursor by 2 per char.
#[test]
fn input_buffer_hangul_cursor_advances_by_two() {
let mut b = InputBuffer::new();
for ch in "한글".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), 4);
assert_eq!(b.as_str(), "한글");
}
/// p9-fb-10: Backspace rewinds cursor by the popped char's
/// width — Hangul rewinds by 2, ASCII by 1.
#[test]
fn input_buffer_pop_char_rewinds_cursor_by_width() {
let mut b = InputBuffer::new();
b.push_str("러스트");
assert_eq!(b.cursor_col(), 6);
let popped = b.pop_char();
assert_eq!(popped, Some('트'));
assert_eq!(b.cursor_col(), 4);
assert_eq!(b.as_str(), "러스");
// Invariant must still hold after pop, not just after push.
assert_eq!(b.cursor_col(), display_width(b.as_str()));
b.push_char('a');
assert_eq!(b.cursor_col(), 5);
assert_eq!(b.as_str(), "러스a");
}
/// p9-fb-10: cursor invariant — cursor_col always equals
/// display_width(content).
#[test]
fn input_buffer_cursor_matches_display_width() {
let mut b = InputBuffer::new();
for ch in "Hello, 세계 mixed".chars() {
b.push_char(ch);
}
assert_eq!(b.cursor_col(), display_width(b.as_str()));
}
/// p9-fb-10: clear resets both content and cursor.
#[test]
fn input_buffer_clear_resets_state() {
let mut b = InputBuffer::new();
b.push_str("한글");
b.clear();
assert_eq!(b.cursor_col(), 0);
assert!(b.is_empty());
}
/// p9-fb-10: pop_char on empty input returns None and leaves
/// cursor at 0 (no underflow).
#[test]
fn input_buffer_pop_on_empty_is_noop() {
let mut b = InputBuffer::new();
assert!(b.pop_char().is_none());
assert_eq!(b.cursor_col(), 0);
}
/// p9-fb-10: take() returns the content and resets state.
#[test]
fn input_buffer_take_returns_content_and_resets() {
let mut b = InputBuffer::new();
b.push_str("러스트");
let s = b.take();
assert_eq!(s, "러스트");
assert!(b.is_empty());
assert_eq!(b.cursor_col(), 0);
}
/// p9-fb-10: place_cursor_x clamps within the inner area.
#[test]
fn place_cursor_x_clamps_to_inner_right_edge() {
// inner.x=10, width=20, so the rightmost column is 10+20-1 = 29.
// prompt_w=2, cursor_col=100 (overflow) → clamped to 29.
assert_eq!(place_cursor_x(10, 20, 2, 100), 29);
}
/// p9-fb-10: place_cursor_x preserves position when within bounds.
#[test]
fn place_cursor_x_keeps_position_when_within_bounds() {
assert_eq!(place_cursor_x(10, 20, 2, 5), 17); // 10 + 2 + 5
}
}

View File

@@ -27,7 +27,7 @@ mod search;
mod terminal;
mod theme;
pub use input::{display_width, truncate_to_display_width};
pub use input::{InputBuffer, display_width, place_cursor_x, truncate_to_display_width};
pub use theme::{Palette, Role, Theme};
pub use app::{
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Mode,

View File

@@ -63,8 +63,8 @@ impl Default for LibraryStateInner {
/// while editing, `tab` cycles between fields and `Enter` commits.
pub(crate) struct FilterEdit {
pub field: FilterField,
pub tags_buf: String,
pub lang_buf: String,
pub tags_buf: crate::input::InputBuffer,
pub lang_buf: crate::input::InputBuffer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -75,26 +75,25 @@ pub(crate) enum FilterField {
impl FilterEdit {
pub fn from_filter(filter: &DocFilter) -> Self {
Self {
field: FilterField::Tags,
tags_buf: filter.tags_any.join(","),
lang_buf: filter
.lang
.as_ref()
.map(|l| l.0.clone())
.unwrap_or_default(),
let mut tags_buf = crate::input::InputBuffer::new();
tags_buf.push_str(&filter.tags_any.join(","));
let mut lang_buf = crate::input::InputBuffer::new();
if let Some(lang) = filter.lang.as_ref() {
lang_buf.push_str(&lang.0);
}
Self { field: FilterField::Tags, tags_buf, lang_buf }
}
pub fn commit_into(&self, filter: &mut DocFilter) {
filter.tags_any = self
.tags_buf
.as_str()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let trimmed = self.lang_buf.trim();
let trimmed = self.lang_buf.as_str().trim();
filter.lang = if trimmed.is_empty() {
None
} else {
@@ -128,6 +127,13 @@ fn filter_overlay_height(state: &App) -> u16 {
}
}
/// Single source of truth for the filter overlay row labels — used
/// both by `line_with_focus` (display) and the cursor-placement
/// `display_width(...)` math below. Editing one without the other
/// would silently miscolumn the caret.
const LABEL_TAGS: &str = "tags_any (csv): ";
const LABEL_LANG: &str = "lang: ";
fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit, theme: &crate::theme::Theme) {
let block = Block::default()
.title("Filter (Tab=cycle field, Enter=apply, Esc=cancel)")
@@ -136,11 +142,26 @@ fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit, theme: &c
f.render_widget(block, area);
let lines = vec![
line_with_focus("tags_any (csv): ", &edit.tags_buf, edit.field == FilterField::Tags, theme),
line_with_focus("lang: ", &edit.lang_buf, edit.field == FilterField::Lang, theme),
line_with_focus(LABEL_TAGS, edit.tags_buf.as_str(), edit.field == FilterField::Tags, theme),
line_with_focus(LABEL_LANG, edit.lang_buf.as_str(), edit.field == FilterField::Lang, theme),
];
let para = Paragraph::new(lines);
f.render_widget(para, inner);
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
// cursor_position is Some (our case here). When a render fn
// omits set_cursor_position (Library/Inspect main view), ratatui
// calls hide_cursor instead. So this single call positions the
// caret on the focused field of the filter overlay.
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
// the right edge of the inner area.
let (label, focused_buf, row_offset) = match edit.field {
FilterField::Tags => (LABEL_TAGS, &edit.tags_buf, 0u16),
FilterField::Lang => (LABEL_LANG, &edit.lang_buf, 1u16),
};
let label_w = display_width(label);
let cursor_x = crate::input::place_cursor_x(inner.x, inner.width, label_w, focused_buf.cursor_col());
f.set_cursor_position((cursor_x, inner.y + row_offset));
}
fn line_with_focus<'a>(
@@ -345,7 +366,7 @@ fn handle_filter_edit_key(state: &mut App, key: KeyEvent) -> KeyOutcome {
FilterField::Tags => &mut edit.tags_buf,
FilterField::Lang => &mut edit.lang_buf,
};
buf.pop();
buf.pop_char();
KeyOutcome::Continue
}
KeyCode::Char(c) => {
@@ -353,7 +374,7 @@ fn handle_filter_edit_key(state: &mut App, key: KeyEvent) -> KeyOutcome {
FilterField::Tags => &mut edit.tags_buf,
FilterField::Lang => &mut edit.lang_buf,
};
buf.push(c);
buf.push_char(c);
KeyOutcome::Continue
}
_ => KeyOutcome::Continue,

View File

@@ -72,15 +72,29 @@ fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate::t
SearchMode::Hybrid => crate::theme::Role::ModeHybrid,
};
let searching_hint = if s.searching { " searching…" } else { "" };
// p9-fb-10: compute prompt display width before moving the String
// into the Span so we can place the cursor without a second alloc.
let prompt = format!("[{mode_label}] ");
let prompt_w = crate::input::display_width(&prompt);
let line = Line::from(vec![
Span::styled(format!("[{mode_label}] "), theme.style(mode_role)),
Span::styled(prompt, theme.style(mode_role)),
Span::raw(s.input.as_str()),
Span::styled(searching_hint, theme.style(crate::theme::Role::Hint)),
]);
let block = Block::default()
.title("query (Tab=mode Enter=search Esc=back)")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(Paragraph::new(line).block(block), area);
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
// cursor_position is Some (our case here). When a render fn
// omits set_cursor_position (Library/Inspect), ratatui calls
// hide_cursor instead. So this single call both positions and
// unhides the caret for the Search input column.
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
// the right edge of the inner area.
let cursor_x = crate::input::place_cursor_x(inner.x, inner.width, prompt_w, s.input.cursor_col());
f.set_cursor_position((cursor_x, inner.y));
}
fn mode_label(m: SearchMode) -> &'static str {
@@ -256,14 +270,14 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
(KeyCode::Tab, _) => {
s.mode = cycle_mode(s.mode);
// Force re-search at the new mode if there's a query.
if !s.input.trim().is_empty() {
if !s.input.as_str().trim().is_empty() {
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
}
KeyOutcome::Continue
}
(KeyCode::Enter, _) => {
// Skip debounce; refresh now if there's anything to query.
if s.input.trim().is_empty() {
if s.input.as_str().trim().is_empty() {
KeyOutcome::Continue
} else {
s.input_dirty_at = None;
@@ -283,7 +297,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
}
(KeyCode::Backspace, _) => {
if !s.input.is_empty() {
s.input.pop();
s.input.pop_char();
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
}
KeyOutcome::Continue
@@ -297,7 +311,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
move_selection(s, 1);
s.preview = None;
} else {
s.input.push('j');
s.input.push_char('j');
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
}
KeyOutcome::Continue
@@ -307,7 +321,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
move_selection(s, -1);
s.preview = None;
} else {
s.input.push('k');
s.input.push_char('k');
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
}
KeyOutcome::Continue
@@ -321,7 +335,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
// input. CTRL/ALT chords stay reserved for future
// bindings (and don't currently match any Search
// command, so they're a safe fall-through to Continue).
s.input.push(c);
s.input.push_char(c);
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
KeyOutcome::Continue
}
@@ -455,7 +469,7 @@ pub fn debounce_due(s: &SearchState) -> bool {
if elapsed < SEARCH_DEBOUNCE {
return false;
}
let q = s.input.trim();
let q = s.input.as_str().trim();
if q.is_empty() {
return false;
}
@@ -464,7 +478,7 @@ pub fn debounce_due(s: &SearchState) -> bool {
// existing result will land via `poll_worker`.
if s.searching {
if let Some((prev_input, prev_mode)) = &s.last_query {
if prev_input == &s.input && *prev_mode == s.mode {
if prev_input.as_str() == s.input.as_str() && *prev_mode == s.mode {
return false;
}
}
@@ -472,7 +486,7 @@ pub fn debounce_due(s: &SearchState) -> bool {
!matches!(
&s.last_query,
Some((prev_input, prev_mode))
if prev_input == &s.input && *prev_mode == s.mode
if prev_input.as_str() == s.input.as_str() && *prev_mode == s.mode
)
}
@@ -499,8 +513,9 @@ pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> {
s.generation = s.generation.wrapping_add(1);
s.searching = true;
s.input_dirty_at = None;
s.last_query = Some((s.input.clone(), s.mode));
(s.input.clone(), s.mode, s.generation)
let q_text = s.input.as_str().to_string();
s.last_query = Some((q_text.clone(), s.mode));
(q_text, s.mode, s.generation)
};
let (tx, rx) = std::sync::mpsc::channel();

View File

@@ -104,20 +104,20 @@ fn typing_appends_to_input() {
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
assert_eq!(app.ask.as_ref().unwrap().input, "hello");
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "hello");
}
#[test]
fn backspace_pops_input() {
let mut app = fresh_app();
{
app.ask.as_mut().unwrap().input = "abcd".into();
app.ask.as_mut().unwrap().input.push_str("abcd");
}
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(app.ask.as_ref().unwrap().input, "abc");
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "abc");
}
/// p9-fb-12 follow-up: `e` types into input in Insert mode (does
@@ -135,7 +135,7 @@ fn e_types_in_insert_mode_does_not_toggle_explain() {
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let s = app.ask.as_ref().unwrap();
assert_eq!(s.input, "e", "e must type in Insert mode");
assert_eq!(s.input.as_str(), "e", "e must type in Insert mode");
assert!(!s.explain, "explain must NOT toggle in Insert mode");
}
@@ -165,7 +165,7 @@ fn jk_scroll_in_normal_mode_type_in_insert() {
&mut app,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(app.ask.as_ref().unwrap().input, "jk");
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "jk");
assert_eq!(app.ask.as_ref().unwrap().scroll, 0, "no scroll in Insert");
}
@@ -193,14 +193,14 @@ fn e_toggles_explain_in_normal_mode() {
fn e_typed_into_input_when_input_nonempty() {
let mut app = fresh_app();
{
app.ask.as_mut().unwrap().input = "qu".into();
app.ask.as_mut().unwrap().input.push_str("qu");
}
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let s = app.ask.as_ref().unwrap();
assert_eq!(s.input, "que");
assert_eq!(s.input.as_str(), "que");
assert!(!s.explain, "explain must NOT toggle while typing a word");
}
@@ -220,7 +220,7 @@ fn enter_while_streaming_is_noop() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.input = "anything".into();
s.input.push_str("anything");
s.streaming = true;
}
handle_key_ask(
@@ -265,7 +265,7 @@ fn render_streaming_shows_partial_with_cursor() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.input = "what is RRF fusion?".into();
s.input.push_str("what is RRF fusion?");
s.streaming = true;
s.partial = "RRF는 reciprocal rank fusion".into();
}
@@ -299,7 +299,7 @@ fn render_grounded_answer_with_citation() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.input = "test".into();
s.input.push_str("test");
let ans = make_answer(true, None, "test answer body [1].");
// p9-fb-16: transcript renders completed turns; populate one
// alongside last_answer so the right-panel status + body
@@ -413,7 +413,7 @@ fn enter_with_detached_prior_thread_is_blocked() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.input = "another question".into();
s.input.push_str("another question");
s.streaming = false;
// Simulate a detached prior worker by hand-installing a
// never-ending JoinHandle. (We can't easily make a sleeping
@@ -630,3 +630,27 @@ fn render_streaming_inflight_turn_appears_below_completed_turns() {
"completed turn before in-flight; got: {answered_pos} vs {partial_pos}"
);
}
/// p9-fb-10: typing Hangul into Ask input advances cursor by 2
/// per char and round-trips through the buffer correctly.
#[test]
fn hangul_typing_in_ask_input_advances_cursor_by_two_per_char() {
let mut app = fresh_app();
// Switch to ask + INSERT mode so chars type as input.
app.focus = Pane::Ask;
app.mode = kebab_tui::Mode::auto_for(Pane::Ask);
for ch in "한글".chars() {
kebab_tui::handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한글");
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 4);
kebab_tui::handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "");
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 2);
}

View File

@@ -216,6 +216,76 @@ fn handle_key_library_f_opens_filter_overlay_then_enter_refreshes() {
assert_eq!(o2, KeyOutcome::Refresh);
}
/// p9-fb-10: filter overlay accepts Hangul tags via key events
/// and commits them to the doc filter.
#[test]
fn filter_overlay_accepts_hangul_tags() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter overlay.
let o1 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
assert_eq!(o1, KeyOutcome::Continue);
// Type Hangul into the tags buffer.
for ch in "한글".chars() {
kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
// Enter commits.
let o2 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(o2, KeyOutcome::Refresh);
// The library filter should now contain "한글" as a tag.
let filter = app.library_filter_for_testing();
assert!(
filter.tags_any.iter().any(|t| t == "한글"),
"expected '한글' in tags filter: {:?}",
filter.tags_any,
);
}
/// p9-fb-10: filter overlay calls f.set_cursor_position so ratatui
/// shows the caret on the focused field. Pin: after opening the
/// overlay, render → terminal cursor is set + has non-zero x
/// (the label offset > 0).
#[test]
fn filter_overlay_render_places_cursor_on_focused_field() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
// Open filter.
let _ = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
);
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 20);
render_library(f, area, &app);
})
.expect("render must not panic");
// After draw, ratatui calls backend.set_cursor_position when the
// frame's cursor_position is Some. The terminal's
// get_cursor_position proxies to the backend.
let pos = terminal.get_cursor_position().expect(
"filter overlay must call set_cursor_position, so cursor pos must be readable",
);
// The Tags label ("tags_any (csv): ") has display_width 16; inner.x
// is 1 (inside border). With empty input cursor_col=0, expected x=17.
// We assert x>0 to avoid hardcoding the exact layout geometry while
// still confirming set_cursor_position was called with a meaningful
// offset (not stuck at origin).
assert!(
pos.x > 0,
"cursor x should be positive (label offset > 0): {pos:?}"
);
}
/// p9-fb-10: Library renders Hangul / CJK titles without overflowing
/// the title column. Smoke pin — render with a mixed Korean fixture
/// and confirm no panic + the truncated width fits the column.

View File

@@ -83,7 +83,7 @@ fn typing_appends_to_input_and_marks_dirty() {
);
}
let s = app.search.as_ref().unwrap();
assert_eq!(s.input, "hello");
assert_eq!(s.input.as_str(), "hello");
assert!(s.input_dirty_at.is_some());
}
@@ -92,13 +92,14 @@ fn backspace_removes_last_char() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.input = "abc".into();
s.input.push_str("abc");
}
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().input, "ab");
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "ab");
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 2);
}
#[test]
@@ -122,7 +123,10 @@ fn tab_cycles_mode_lex_vec_hybrid() {
#[test]
fn enter_with_query_emits_refresh() {
let mut app = fresh_app();
app.search.as_mut().unwrap().input = "rust".into();
{
let s = app.search.as_mut().unwrap();
s.input.push_str("rust");
}
let outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
@@ -213,7 +217,7 @@ fn render_search_with_hits_shows_input_and_path() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.input = "rust traits".into();
s.input.push_str("rust traits");
s.mode = SearchMode::Hybrid;
s.hits = vec![
make_hit(1, "notes/rust.md", "trait dispatch\nis dynamic", line_citation("notes/rust.md", 12)),
@@ -278,7 +282,7 @@ fn j_in_insert_types_does_not_move_selection() {
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
let s = app.search.as_ref().unwrap();
assert_eq!(s.input, "j", "j must type in Insert mode");
assert_eq!(s.input.as_str(), "j", "j must type in Insert mode");
assert_eq!(s.selected_hit, 0, "selection must NOT move in Insert");
}
@@ -294,7 +298,7 @@ fn arbitrary_char_in_normal_mode_is_noop() {
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
);
let s = app.search.as_ref().unwrap();
assert_eq!(s.input, "", "Normal-mode Char must NOT type");
assert_eq!(s.input.as_str(), "", "Normal-mode Char must NOT type");
}
#[test]
@@ -317,7 +321,7 @@ fn shift_j_stays_in_input_does_not_move_selection() {
);
let s = app.search.as_ref().unwrap();
assert_eq!(s.selected_hit, 0, "selection must NOT move on SHIFT-J");
assert_eq!(s.input, "J", "SHIFT-J must reach the input buffer");
assert_eq!(s.input.as_str(), "J", "SHIFT-J must reach the input buffer");
}
#[test]
@@ -334,7 +338,7 @@ fn shift_g_does_not_trigger_editor_jump() {
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert_eq!(outcome, KeyOutcome::Continue);
assert_eq!(app.search.as_ref().unwrap().input, "G");
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "G");
}
/// p9-fb-09 — `g` on a hit enqueues an `EditorRequest` on `App.pending_editor`
@@ -467,7 +471,7 @@ fn poll_worker_noop_when_no_rx() {
#[allow(clippy::field_reassign_with_default)]
fn search_state_with(input: &str, mode: SearchMode, searching: bool, last_query: Option<(String, SearchMode)>) -> SearchState {
let mut s = SearchState::default();
s.input = input.into();
s.input.push_str(input);
s.mode = mode;
s.searching = searching;
s.last_query = last_query;
@@ -543,3 +547,28 @@ fn no_search_state_returns_to_library() {
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}
/// p9-fb-10: typing Hangul into Search input advances cursor by 2
/// per char and round-trips through the buffer correctly.
#[test]
fn hangul_typing_in_search_input_advances_cursor_by_two_per_char() {
let mut app = fresh_app();
// Switch to search and ensure Insert mode so chars type.
app.focus = Pane::Search;
app.mode = kebab_tui::Mode::auto_for(Pane::Search);
for ch in "한글".chars() {
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "한글");
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 4);
// Backspace pops the trailing Hangul char and rewinds 2 cols.
handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(app.search.as_ref().unwrap().input.as_str(), "");
assert_eq!(app.search.as_ref().unwrap().input.cursor_col(), 2);
}

View File

@@ -37,15 +37,17 @@ helper 는 **rendering width** 만 정정.
로만 도달. macOS / Windows / Linux (ibus/fcitx) 모두 동일. preedit
handling 은 out-of-scope (spec 도 "not in scope" 로 명시).
**Follow-up shipped 2026-05-03 in PR #?? — InputBuffer struct + Search/Ask/FilterEdit pane migrations + display-column-aware cursor placement + Korean FTS5 smoke pin. spec status flipped `in_progress` → `completed`.**
**후속 PR 체크리스트** (별 PR 에서 cover, 본 HOTFIXES 항목이 owner —
새 spec 파일을 만들지 않고 기존 `tasks/p9/p9-fb-10-tui-cjk-input.md`
의 status `in_progress` 가 유지되는 동안 본 체크리스트를 참조):
- [ ] `kebab-tui::input::InputBuffer { content: String, cursor_col: usize }` struct
- [ ] Ask / Search / Editor pane 의 String + cursor 를 InputBuffer 로 교체
- [ ] cursor render 가 wide-char 위에서 column 단위로 정렬 (현재 char-count 기반)
- [ ] 한글 query → SQLite FTS5 검색 fixture 추가 (이미 NFC 정규화 됨, 단순 smoke pin)
- [ ] DoD 체크박스 3 개 모두 채우고 spec status `in_progress``completed`
- [x] `kebab-tui::input::InputBuffer { content: String, cursor_col: usize }` struct
- [x] Ask / Search / Editor pane 의 String + cursor 를 InputBuffer 로 교체
- [x] cursor render 가 wide-char 위에서 column 단위로 정렬 (현재 char-count 기반)
- [x] 한글 query → SQLite FTS5 검색 fixture 추가 (이미 NFC 정규화 됨, 단순 smoke pin)
- [x] DoD 체크박스 3 개 모두 채우고 spec status `in_progress``completed`
## 2026-05-03 — p9-fb-13 cheatsheet: `?` → `F1` rebind

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-tui
task_id: p9-fb-10
title: "CJK input + wide-char rendering audit"
status: in_progress
status: completed
depends_on: [p9-fb-12]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
@@ -43,9 +43,9 @@ source_feedback: p9-dogfooding-feedback.md item 8
## DoD
- [ ] `cargo test -p kebab-tui` 통과
- [ ] 한글 fixture 추가
- [ ] README — CJK 입력 동작 정상 명시
- [x] `cargo test -p kebab-tui` 통과
- [x] 한글 fixture 추가
- [x] README — CJK 입력 동작 정상 명시
## Out of scope
@@ -55,3 +55,4 @@ source_feedback: p9-dogfooding-feedback.md item 8
## Notes
- 2026-05-03 partial: `kebab-tui::input::{display_width, truncate_to_display_width}` helper 모듈 + Korean/Japanese fixture render audit + 9 unit tests + library.rs 의 중복 truncate 제거 (단일 source). `InputBuffer` struct 도입은 follow-up — Ask/Search/Editor pane 의 String + cursor 를 일괄 마이그레이션하면 회귀 표면이 커서 위 helper 만 먼저 머지. 백스페이스는 `String::pop()` 이 char-aware 라 byte-boundary 안전성은 이미 확보된 상태. 후속 spec issue 는 HOTFIXES.md 참조.
- 2026-05-03 follow-up: `InputBuffer { content, cursor_col }` struct landed; Search/Ask/FilterEdit migrated; cursor column-aligned via `f.set_cursor_position` in each render fn (block.inner-derived coordinates, right-edge clamp). Korean FTS5 end-to-end smoke pin in `crates/kebab-app/tests/search_korean.rs`. `lexical_query` test helper 가 `crates/kebab-app/tests/common/mod.rs` 로 promotion. spec status `in_progress``completed`.