feat(kebab-tui): p9-fb-10 partial — CJK width helpers + render audit #87
@@ -58,6 +58,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-18)** — CLI `kebab ask --session <id>` (multi-turn). p9-fb-17 의 ChatSessionRepo 위에 `kebab-app::App::ask_with_session(session_id, query, opts) -> Answer` 메서드. 첫 호출 시 자동으로 `chat_sessions` row 생성 (title = 첫 question NFC trim 40 chars), 이후 호출은 `list_turns` 로 prior history 받아 `RagPipeline::ask_with_history` 호출 + 새 turn append. `App` 의 helper: `first_question_title(question)` (NFC + trim + 40 char cap, fallback `"untitled"`) + `blake3_truncate(input)` (32-hex `turn_id` 생성). facade `kebab_app::ask_with_session_with_config` + CLI `--session <id>` flag 추가. `--repl` 은 spec 명시 사항이지만 stdin loop fixture 부담 으로 후속 task 로 deferral (out of scope per HANDOFF). spec: `tasks/p9/p9-fb-18-cli-ask-session-repl.md`.
|
||||
- **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-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 후보
|
||||
|
||||
@@ -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 호환성 위해) |
|
||||
| `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 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 회귀 측정 |
|
||||
|
||||
|
||||
161
crates/kebab-tui/src/input.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! p9-fb-10: CJK / wide-char width helpers.
|
||||
//!
|
||||
//! TUI rendering needs **column width**, not char count. ASCII = 1
|
||||
//! column, Hangul / CJK / fullwidth Latin = 2 columns, combining
|
||||
//! diacriticals = 0. Naive `s.chars().count()` overflows boxes when
|
||||
//! the user types `한글` (5 chars × 2 cols = 10 columns — twice
|
||||
//! what a 5-char ASCII string would be).
|
||||
//!
|
||||
//! These helpers wrap `unicode-width` (already a workspace dep used
|
||||
//! by `library.rs` for the doc-list title column). Centralizing
|
||||
//! avoids drift between panes that all need the same calculation.
|
||||
//!
|
||||
//! ## What this crate does NOT do
|
||||
//!
|
||||
//! * **IME composing**: crossterm doesn't surface IME composition
|
||||
//! events on any platform (raw `KeyCode::Char(c)` per finalized
|
||||
//! jamo). Users on macOS / Windows IME stacks see one char per
|
||||
//! commit; on Linux ibus / fcitx similar. The TUI sees the
|
||||
//! already-composed character — no preedit handling needed.
|
||||
//! * **Grapheme clusters** beyond what `unicode-width` covers (e.g.
|
||||
//! emoji + skin-tone modifier rendering as 1 visual but 2 chars).
|
||||
//! The dominant CJK use case is single-char-per-glyph; emoji
|
||||
//! fallback is best-effort via `unicode_width::UnicodeWidthStr`.
|
||||
//!
|
||||
//! ## Backspace + boundary safety
|
||||
//!
|
||||
//! `String::pop()` is char-aware (returns `Option<char>`, removes
|
||||
//! one Unicode scalar value, never splits a UTF-8 sequence
|
||||
//! mid-byte). Every existing pane's `Backspace` handler uses
|
||||
//! `pop()`, so byte-slicing bugs are out of scope. The helpers
|
||||
//! below are purely for **rendering width**.
|
||||
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
/// 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
|
||||
/// internally, exposed here so callers can pre-compute layout.
|
||||
pub fn display_width(s: &str) -> usize {
|
||||
s.width()
|
||||
}
|
||||
|
||||
/// Truncate `s` to fit within `max_cols` terminal columns,
|
||||
/// appending `…` when truncated. The `…` itself counts as 1
|
||||
/// column. Returns `s` unchanged when it already fits.
|
||||
///
|
||||
/// Boundary contract: never splits a multi-byte UTF-8 sequence
|
||||
/// (`for ch in s.chars()` walks code points). Wide chars are
|
||||
/// either kept whole or fully omitted — never half-rendered.
|
||||
pub fn truncate_to_display_width(s: &str, max_cols: usize) -> String {
|
||||
if s.width() <= max_cols {
|
||||
return s.to_string();
|
||||
}
|
||||
if max_cols == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let cap = max_cols.saturating_sub(1);
|
||||
let mut out = String::new();
|
||||
let mut cols = 0usize;
|
||||
for ch in s.chars() {
|
||||
let w = ch.width().unwrap_or(0);
|
||||
if cols + w > cap {
|
||||
out.push('…');
|
||||
return out;
|
||||
}
|
||||
cols += w;
|
||||
out.push(ch);
|
||||
}
|
||||
// Loop ended without exceeding cap — but we know s.width() >
|
||||
// max_cols (early-return covered the easy case), so the only
|
||||
// way to land here is zero-width tail (combining marks). Add
|
||||
// the ellipsis and stop.
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// p9-fb-10: ASCII = 1 col per char.
|
||||
#[test]
|
||||
fn ascii_width_is_one_per_char() {
|
||||
assert_eq!(display_width(""), 0);
|
||||
assert_eq!(display_width("hello"), 5);
|
||||
assert_eq!(display_width("kebab"), 5);
|
||||
}
|
||||
|
||||
/// p9-fb-10: Hangul = 2 cols per char (single composed syllable).
|
||||
#[test]
|
||||
fn hangul_width_is_two_per_char() {
|
||||
assert_eq!(display_width("가"), 2);
|
||||
assert_eq!(display_width("한글"), 4);
|
||||
assert_eq!(display_width("러스트"), 6);
|
||||
}
|
||||
|
||||
/// p9-fb-10: mixed ASCII + Hangul sums correctly.
|
||||
#[test]
|
||||
fn mixed_ascii_hangul_width() {
|
||||
// "kb-한글" = k(1) + b(1) + -(1) + 한(2) + 글(2) = 7
|
||||
|
|
||||
assert_eq!(display_width("kb-한글"), 7);
|
||||
// "Hello, 세계" = "Hello"(5) + ","(1) + " "(1) + "세"(2) + "계"(2) = 11
|
||||
assert_eq!(display_width("Hello, 세계"), 11);
|
||||
}
|
||||
|
||||
/// p9-fb-10: Japanese kana / kanji also wide.
|
||||
#[test]
|
||||
fn japanese_width_is_two_per_char() {
|
||||
assert_eq!(display_width("こんにちは"), 10);
|
||||
assert_eq!(display_width("漢字"), 4);
|
||||
}
|
||||
|
||||
/// p9-fb-10: truncate fits when possible, no allocation.
|
||||
#[test]
|
||||
fn truncate_returns_same_when_already_fits() {
|
||||
assert_eq!(truncate_to_display_width("hello", 5), "hello");
|
||||
assert_eq!(truncate_to_display_width("hello", 100), "hello");
|
||||
assert_eq!(truncate_to_display_width("한글", 4), "한글");
|
||||
}
|
||||
|
||||
/// p9-fb-10: truncate emits ellipsis when overflow.
|
||||
#[test]
|
||||
fn truncate_emits_ellipsis_on_overflow() {
|
||||
assert_eq!(truncate_to_display_width("hello", 4), "hel…");
|
||||
assert_eq!(truncate_to_display_width("hello world", 8), "hello w…");
|
||||
}
|
||||
|
||||
/// p9-fb-10: truncate respects wide-char boundary — never splits
|
||||
/// a Hangul syllable to fit one column.
|
||||
#[test]
|
||||
fn truncate_does_not_split_wide_char() {
|
||||
// "한글테스트" = 10 cols. max_cols=5 → fits "한글" (4) + "…" (1).
|
||||
// Cannot include "테" because that would push to 4+2 > 4 (cap).
|
||||
let out = truncate_to_display_width("한글테스트", 5);
|
||||
assert_eq!(out, "한글…");
|
||||
assert_eq!(display_width(&out), 5);
|
||||
}
|
||||
|
||||
/// p9-fb-10: max_cols=0 returns empty (degenerate; no room
|
||||
/// even for the ellipsis).
|
||||
#[test]
|
||||
fn truncate_zero_cols_is_empty() {
|
||||
assert_eq!(truncate_to_display_width("hello", 0), "");
|
||||
assert_eq!(truncate_to_display_width("한글", 0), "");
|
||||
}
|
||||
|
||||
/// p9-fb-10: backspace via String::pop is char-aware (sanity
|
||||
/// pin — exercises the contract these helpers depend on).
|
||||
#[test]
|
||||
fn string_pop_handles_hangul_boundary_safely() {
|
||||
let mut s = String::from("러스트");
|
||||
let popped = s.pop();
|
||||
assert_eq!(popped, Some('트'));
|
||||
assert_eq!(s, "러스");
|
||||
assert_eq!(display_width(&s), 4);
|
||||
// Pop again — still char-aware.
|
||||
s.pop();
|
||||
assert_eq!(s, "러");
|
||||
assert_eq!(display_width(&s), 2);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod cheatsheet;
|
||||
mod editor;
|
||||
mod error_popup;
|
||||
mod ingest_progress;
|
||||
mod input;
|
||||
mod inspect;
|
||||
mod library;
|
||||
mod markdown;
|
||||
@@ -26,6 +27,7 @@ mod search;
|
||||
mod terminal;
|
||||
mod theme;
|
||||
|
||||
pub use input::{display_width, truncate_to_display_width};
|
||||
pub use theme::{Palette, Role, Theme};
|
||||
pub use app::{
|
||||
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Mode,
|
||||
|
||||
@@ -13,9 +13,14 @@ use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app::{App, KeyOutcome, Pane};
|
||||
use crate::input::{display_width, truncate_to_display_width};
|
||||
|
||||
/// Width (in display columns) of the `tags` column in the doc-list
|
||||
/// row. Used twice — truncate input + pad calculation — so a const
|
||||
/// keeps them in sync.
|
||||
const TAGS_COL_W: usize = 12;
|
||||
|
||||
/// Internal state owned by `LibraryState`. Public-by-crate so
|
||||
/// `handle_key_library` can mutate it without crossing the
|
||||
@@ -185,7 +190,7 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) {
|
||||
}
|
||||
|
||||
/// Format a `DocSummary` row using display-width-aware truncation
|
||||
/// (Korean / wide chars contribute 2 columns each).
|
||||
/// and padding. Korean / wide chars contribute 2 columns each.
|
||||
pub(crate) fn format_doc_row(d: &DocSummary, title_w: usize) -> String {
|
||||
let title = truncate_to_display_width(&d.title, title_w);
|
||||
let tags = if d.tags.is_empty() {
|
||||
|
claude-reviewer-01
commented
nit: tags column width **nit**: tags column width `12` 가 두 군데 반복 (line 196 truncate_to_display_width 인자 + line 209 `12usize.saturating_sub`). 한 곳에서 바꾸고 다른 곳을 잊으면 column drift 재발. `const TAGS_COL_W: usize = 12;` 으로 빼면 maintainability ↑. title_w 는 area.width 에서 동적 계산되니 그대로 두고, tags 만.
|
||||
@@ -193,43 +198,33 @@ pub(crate) fn format_doc_row(d: &DocSummary, title_w: usize) -> String {
|
||||
} else {
|
||||
d.tags.join(",")
|
||||
};
|
||||
let tags = truncate_to_display_width(&tags, TAGS_COL_W);
|
||||
let updated = d
|
||||
.updated_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "?".to_string());
|
||||
let updated_short = updated.split('T').next().unwrap_or("?");
|
||||
// `<width$>` is std::fmt's named-arg width form (`title_w` is the
|
||||
// named arg below; `$` says "use it as the padding width"). See
|
||||
// https://doc.rust-lang.org/std/fmt/#width §"Width via named
|
||||
// parameters".
|
||||
// std::fmt's `<width$>` form pads by **char count**, which
|
||||
// overshoots when the value contains wide chars (each Hangul
|
||||
// adds 2 cols but counts as 1 char → padding is half-short and
|
||||
|
claude-reviewer-01
commented
Padding bug — pre-existing, surfaced by 본 PR 의 CJK 머지: **Padding bug — pre-existing, surfaced by 본 PR 의 CJK 머지**: `format!("{title:<title_w$}", ...)` 는 std::fmt 의 width 가 **char count** 기반이라 wide char 가 들어가면 padding 이 모자라. 예: title=`러스트로 만드는 지식 베이스`(13 chars / 25 cols), title_w=30 → fmt 는 30-13=17 spaces 추가 → 총 13+17=30 chars 지만 display 는 25+17=42 cols 가 되어 columns 가 밀림. 본 PR 의 truncate_to_display_width 로 title.width() <= title_w 는 보장되지만 padding 후의 over-width 는 별개. 수정안: pad 를 명시적으로 계산 — `let pad = title_w.saturating_sub(crate::input::display_width(&title)); let title = format!("{title}{:width$}", "", width = pad);` 후 format string 에서 `{title}` 만 (no `<title_w$>`). 같은 pane 의 `tags:<12` / `updated_short:<10` 도 동일 — tags 에 한글이 들어가면 (이번 PR test fixture `한글` tag) column drift. 본 PR scope 가 helper + 단일 source 통합이므로 padding 수정은 follow-up commit (또는 별 PR) 으로 splitting 할 만하지만, 기왕 CJK 머지하는 김에 같은 PR 에 끼우는 게 사용자 입장 일관.
claude-reviewer-01
commented
nit (regression hardening): format_doc_row 의 padding 수정 자체에 대한 직접 unit test 가 없음 — render 테스트 ( **nit (regression hardening)**: format_doc_row 의 padding 수정 자체에 대한 **직접 unit test** 가 없음 — render 테스트 (`library_renders_korean_titles_without_overflow`) 가 글자 presence 만 확인하지 column alignment 는 안 확인. 즉 누군가 `<title_w$>` 로 되돌려도 render test 는 통과. 제안: `format_doc_row(&doc_with_hangul_title, 20)` 직접 호출 → `display_width(&row) == expected_total_cols` assert. 그러면 한글 padding 회귀가 unit-level 에서 잡힘. 본 PR 의 `tests/library.rs` 끝에 추가하거나 새 `format_doc_row_*` 그룹으로 묶기.
|
||||
// downstream columns drift). Compute the pad spaces ourselves
|
||||
// from `display_width`, then concatenate — the truncate above
|
||||
// already guarantees `display_width(title) <= title_w`.
|
||||
let title_pad = title_w.saturating_sub(display_width(&title));
|
||||
let tags_pad = TAGS_COL_W.saturating_sub(display_width(&tags));
|
||||
format!(
|
||||
"{title:<title_w$} {tags:<12} {updated_short:<10} {chunk_count}",
|
||||
"{title}{:title_pad$} {tags}{:tags_pad$} {updated_short:<10} {chunk_count}",
|
||||
"",
|
||||
"",
|
||||
title = title,
|
||||
tags = truncate_to_display_width(&tags, 12),
|
||||
tags = tags,
|
||||
updated_short = updated_short,
|
||||
chunk_count = d.chunk_count,
|
||||
title_w = title_w,
|
||||
title_pad = title_pad,
|
||||
tags_pad = tags_pad,
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_to_display_width(s: &str, max_cols: usize) -> String {
|
||||
if s.width() <= max_cols {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut cols = 0;
|
||||
for ch in s.chars() {
|
||||
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if cols + w > max_cols.saturating_sub(1) {
|
||||
out.push('…');
|
||||
return out;
|
||||
}
|
||||
cols += w;
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Library pane key dispatch. Mutates `App.library.inner`; never
|
||||
/// touches another pane's state (parallel-safety contract).
|
||||
pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
||||
@@ -414,3 +409,56 @@ pub(crate) fn refresh_docs(state: &mut App) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kebab_core::{
|
||||
ChunkerVersion, DocSummary, DocumentId, Lang, ParserVersion, SourceType, TrustLevel,
|
||||
WorkspacePath,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn doc(title: &str, tags: &[&str]) -> DocSummary {
|
||||
DocSummary {
|
||||
doc_id: DocumentId("a".repeat(32)),
|
||||
doc_path: WorkspacePath::new("x.md".into()).unwrap(),
|
||||
title: title.into(),
|
||||
lang: Lang("en".into()),
|
||||
tags: tags.iter().map(|s| (*s).into()).collect(),
|
||||
trust_level: TrustLevel::Primary,
|
||||
source_type: SourceType::Note,
|
||||
byte_len: 1,
|
||||
chunk_count: 1,
|
||||
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
parser_version: ParserVersion("p".into()),
|
||||
chunker_version: ChunkerVersion("c".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-10: format_doc_row pads by display width (not char
|
||||
/// count) so wide-char titles don't shift downstream columns.
|
||||
/// Regression pin — `<title_w$>` (std::fmt char-count form)
|
||||
/// would fail this for any Hangul title.
|
||||
#[test]
|
||||
fn format_doc_row_pads_by_display_width_for_hangul_title() {
|
||||
let row = format_doc_row(&doc("러스트로 만드는 KB", &["rust"]), 30);
|
||||
// Expected layout (display cols):
|
||||
// title 30 + " "(2) + tags 12 + " "(2) + date 10 + " "(2) + chunk
|
||||
// chunk = "1" → 1 col. Total = 30+2+12+2+10+2+1 = 59.
|
||||
assert_eq!(
|
||||
display_width(&row),
|
||||
59,
|
||||
"row must align to display columns, not char count: {row:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// p9-fb-10: Hangul tag also pads by display width.
|
||||
#[test]
|
||||
fn format_doc_row_pads_by_display_width_for_hangul_tag() {
|
||||
let row = format_doc_row(&doc("ascii", &["한글"]), 20);
|
||||
// title 20 + " " + tags 12 + " " + date 10 + " " + "1" = 49
|
||||
assert_eq!(display_width(&row), 49, "row: {row:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,3 +215,45 @@ fn handle_key_library_f_opens_filter_overlay_then_enter_refreshes() {
|
||||
);
|
||||
assert_eq!(o2, KeyOutcome::Refresh);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn library_renders_korean_titles_without_overflow() {
|
||||
let docs = vec![
|
||||
make_doc("ko/한글-노트.md", "러스트로 만드는 지식 베이스", vec!["rust", "한글"]),
|
||||
make_doc("jp/漢字メモ.md", "日本語のテストドキュメント", vec!["jp"]),
|
||||
make_doc("mix/hello-세계.md", "Hello, 세계 mixed title", vec!["mix"]),
|
||||
];
|
||||
let app = app_with_docs(docs);
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 80, 20);
|
||||
render_library(f, area, &app);
|
||||
})
|
||||
.expect("render must not panic on CJK titles");
|
||||
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");
|
||||
// At least one Hangul / Kanji glyph survives the render path.
|
||||
// TestBackend renders wide chars one-per-cell with the trailing
|
||||
// cell empty, so the joined string has spaces between adjacent
|
||||
// wide chars — assert single glyphs, not multi-char substrings.
|
||||
assert!(
|
||||
rendered.contains('러') || rendered.contains('한'),
|
||||
"expected a Hangul glyph in rendered frame: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains('日') || rendered.contains('漢'),
|
||||
"expected a Kanji glyph in rendered frame: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,39 @@ 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-03 — p9-fb-10 partial: helpers shipped, InputBuffer struct deferred
|
||||
|
||||
**Spec amended**: `tasks/p9/p9-fb-10-tui-cjk-input.md` (status flipped
|
||||
planned → in_progress).
|
||||
|
||||
**Live state**: 본 PR 은 `kebab-tui::input::{display_width,
|
||||
truncate_to_display_width}` helper 모듈 + Korean / Japanese fixture
|
||||
render audit + 9 unit tests + library.rs 의 중복 truncate 제거 (단일
|
||||
source) 만 머지. spec 의 `InputBuffer` struct (cursor 가 column 단위
|
||||
wide-char width 를 추적) 도입은 follow-up.
|
||||
|
||||
**Why split**: Ask / Search / Editor pane 의 String + cursor 를
|
||||
일괄 마이그레이션하면 회귀 표면이 커서 위 helper 만 먼저 머지. 백스페이스
|
||||
경로는 모든 pane 이 이미 `String::pop()` 사용 — pop 은 `Option<char>`
|
||||
반환 + UTF-8 sequence mid-byte split 안 함 (Rust std 가 char-aware).
|
||||
즉 byte-boundary 안전성은 helper 없이도 이미 확보된 상태였고, 본 PR 의
|
||||
helper 는 **rendering width** 만 정정.
|
||||
|
||||
**IME composing**: crossterm 0.28 이 native IME composing surface 를
|
||||
노출 안 함 — finalized jamo / composed glyph 가 `KeyCode::Char(c)`
|
||||
로만 도달. macOS / Windows / Linux (ibus/fcitx) 모두 동일. preedit
|
||||
handling 은 out-of-scope (spec 도 "not in scope" 로 명시).
|
||||
|
||||
**후속 PR 체크리스트** (별 PR 에서 cover, 본 HOTFIXES 항목이 owner —
|
||||
새 spec 파일을 만들지 않고 기존 `tasks/p9/p9-fb-10-tui-cjk-input.md`
|
||||
|
claude-reviewer-01
commented
nit: **nit**: `**후속 spec issue**: ...` 절이 actionable owner 없이 떠 있음. (a)/(b)/(c) 가 InputBuffer follow-up PR 에 들어갈 항목이라는 건 명확하지만, 어디에 적힐지 (별 spec issue 파일? 본 spec 의 DoD 갱신?) 가 모호. 제안: `tasks/p9/p9-fb-10-tui-cjk-input.md` 의 `## Notes` 절 안에 `### Follow-up checklist` 로 옮기거나, 같은 HOTFIXES 항목 내에 `**후속 PR 체크리스트**:` 로 명시. 현재 상태로는 사람이 spec 만 봐도 다음 작업 파악 가능해야 한다는 frozen-spec 원칙에 살짝 어긋남.
|
||||
의 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`
|
||||
|
||||
## 2026-05-03 — p9-fb-13 cheatsheet: `?` → `F1` rebind
|
||||
|
||||
**Spec amended**: `tasks/p9/p9-fb-13-tui-cheatsheet.md` (frozen —
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-tui
|
||||
task_id: p9-fb-10
|
||||
title: "CJK input + wide-char rendering audit"
|
||||
status: planned
|
||||
status: in_progress
|
||||
depends_on: [p9-fb-12]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
@@ -51,3 +51,7 @@ source_feedback: p9-dogfooding-feedback.md item 8
|
||||
|
||||
- macOS IME (Korean composing 시 system level) 회피 — fallback 안내 (외부 editor 사용 권장)
|
||||
- emoji surrogate pair (현재 pulldown-cmark 가 처리)
|
||||
|
||||
## 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 참조.
|
||||
|
||||
nit (cosmetic): 테스트 코멘트에 디버깅 흔적 남음 —
// "kb-한글" = 2 ASCII + 1 dash + 2 Hangul × 2 = 5 + 4 = wait의= wait가 thinking-out-loud 잔재. 의도는 다음 줄의 정정 ("k" 1 + "b" 1 + ...= 7) 이지만 코멘트 한 줄로 충분:// "kb-한글" = k(1) + b(1) + -(1) + 한(2) + 글(2) = 7.