feat(kebab-tui): P9-3 Ask pane #45
Reference in New Issue
Block a user
Delete Branch "feat/p9-3-tui-ask"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
P9-1 Library 의
?키 활성화.App.askslot 채움. Worker thread 가kebab-app::ask_with_config호출하면서AskOpts.stream_sink로 token 을 mpsc 채널에 보내고, TUI 가 매 render frame 마다 drain → 답변 영역 token-by-token 업데이트.핵심 결정
pub으로 cross-module (run.rs idle tick) 접근 가능.(tx, rx) = mpsc::channel()+thread::spawn(|| ask_with_config(opts: stream_sink: Some(tx))). run-loop idle tickdrain_stream가rx.try_iter()→partial누적,poll_worker가handle.is_finished()→take()+join()→ answer.s.rx = None; s.thread = None; s.streaming = false. Worker 는 계속 돌지만 결과 drop. JoinHandle 미사용 시 OS 가 reap.grounded=false면 답변 영역 yellow + status 의 refusal_reason 한 줄 (score_gate/llm_self_judge/no_chunks/no_index).Spec deviation (HOTFIXES
2026-05-02 P9-3)render_ask<B: Backend>generic 제거 — P9-1/P9-2 와 동일.e/j/k키 의 input-empty 분기 — input 비어 있을 때만 command, 있을 때는 typing. spec literal 단순e=toggle이 "explain" / "javascript" 같은 단어 입력 깨뜨림. vim "command vs insert" 변형 적용.테스트 (13개,
tests/ask.rs)[1]score_gate패널 (citations 비었을 때 panic 없음)Docs (sync rule)
completed.검증
cargo test -p kebab-tui38 passed (10 library + 15 search + 13 ask)cargo clippy --workspace --all-targets -- -D warningscleancargo build --release -p kebab-clicleanTest plan
-D warningskebab tui→?→ 질문 → Enter → 토큰 stream →e(input empty) explain → Esc Library 복귀Out of scope (spec)
P9-1 Library 의 ? 키 활성화. App.ask slot 채움 (parallel-safety contract 그대로). Worker thread 가 kebab-app::ask_with_config 호출하면서 AskOpts.stream_sink 로 token 을 mpsc 채널 에 보냄, 메인 스레드 (TUI) 는 매 render frame 마다 drain 으로 문자열 누적 → 답변 영역 이 token-by-token 업데이트. 핵심: - AskState 본체 (`app.rs`) — input / explain / streaming / partial / answer / thread JoinHandle / rx Receiver / scroll / last_error. - `src/ask.rs`: - `render_ask` — input bar / 답변 영역 (streaming 시 ▍ cursor) / bottom split (status: grounded/model/prompt/k/refusal · citations or explain panel). - `handle_key_ask`: typing → input. Enter → spawn_ask_worker (input 있음 + not streaming). e (input empty 시) → toggle explain. j/k (input empty 시) → scroll. Esc → SwitchPane(Library) + streaming/rx/thread 클리어 (best-effort cancel). - `spawn_ask_worker` — mpsc::channel + thread::spawn(|| ask_with_config). - run-loop hooks: `drain_stream` (try_iter → partial), `poll_worker` (handle.is_finished → take + join → answer 채움 또는 ErrorOverlay). - run.rs: Pane::Ask arm 이 handle_key_ask + render_ask. Idle tick 마다 drain_stream + poll_worker. SwitchPane(Ask) 시 lazy init. 테스트 13개 (`tests/ask.rs`) — Esc/typing/backspace/e toggle (input empty)/e typed (input nonempty)/Enter empty/Enter while streaming no-op/render pre-submission hint/streaming partial+cursor/grounded answer + citation [1]/refusal score_gate 패널 panic 없음/explain panel title flip/no slot. Spec deviation (HOTFIXES `2026-05-02 P9-3`): - `render_ask<B: Backend>` generic 제거 — ratatui 0.28 Frame backend-agnostic (P9-1/P9-2 와 동일). - e/j/k 가 input 빈 상태 일 때만 command 키, 입력 있으면 typing — vim "command vs insert" 변형. spec literal 의 단순 \"e=toggle\" 은 \"explain\" / \"javascript\" 같은 단어 입력 깨뜨림. Docs (sync rule): - README: TUI 행 \"Library + Search + Ask 패널\" + Quick start 코멘트. - HANDOFF: 한 줄 요약 + Phase status (P9 2/5 → 3/5) + deviation 한 줄. - HOTFIXES: P9-3 entry. - tasks/p9/p9-3 status: completed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — 한 actionable: Esc 후 재질문 시 detached prior worker + 새 worker 가 동시 in-flight (Ollama 응답 두 배 + stream 혼동). spawn_ask_worker 진입에 thread.is_some 검사 추가 (옵션 A). 그 외 nit 1 (command_or_type helper 추출 — follow-up scope) + 칭찬 (panic 처리, idle hook 분리, command-vs-insert 정신모델).
@@ -92,8 +92,35 @@ impl Default for SearchState {}(칭찬)
AskState가JoinHandle<Result<Answer>>와Receiver<String>을 분리 보유. spawn 시 worker 가tx를 take + thread 안에서 합본 답변 반환, pane 은 stream 누적 + 최종 답변 모두 가짐. mpsctry_iter로 non-blocking drain —stream_sink가 dropped 시SendErrorswallow 되는 P4-3 동작과 정합.@@ -0,0 +317,4 @@match result {Ok(Ok(answer)) => {// Final partial is the full answer text; replace partial// with the canonical answer.answer so post-stream rendering(suggestion / 운영 함정) Esc 의 best-effort cancel 후 즉시 재질문 시 이전 worker thread (LLM call) 가 OS 에서 detached 상태로 계속 실행. 두 번째 Enter 가 새 worker 를 spawn 하면 동일 Ollama endpoint 에 두 요청이 동시 in-flight — 응답 시간 두 배 + 사용자 input 시 stream 이 어느 답변에서 오는지 모호.
Why: spec § Risks 가 "fire and forget" 명시했지만 "두 worker 동시 실행 시 stream 가 섞일 수 있음" 이 사용자에게 보이지 않음. 디버깅 시 "왜 stream 이 이상하지" 의 근원이 detached prior worker.
How to apply (둘 중 택일):
spawn_ask_worker진입 시if s.thread.is_some() { return; }추가 — 이전 thread 가 아직 살아있으면 새 spawn 보류. 사용자에게는 "이전 답변 종료 대기" 라고 input bar 에 한 줄 hint.(A) 가 안전 + 디버깅 친화. 1 줄 + hint 텍스트 한 줄.
@@ -0,0 +347,4 @@/// thread which would touch SQLite + LanceDB + Ollama. Tests bypass it/// by hand-populating `AskState` and asserting render / key handler/// behavior directly.#[cfg(any(test, doc))](nit)
e/j/k의 input-empty 분기 가 셋 모두 같은 패턴 (if s.input.is_empty() { command } else { typing }). HOTFIXES entry 에 "vim command vs insert mode" 라고 표현됐는데, 미래에 누군가 같은 패턴을 다른 키 (예: P9-4 inspect 의 's' 등) 에 적용하려 할 때 helper 가 있으면 쉬움.How to apply (옵션, 본 PR scope 외): private fn
command_or_type(s: &mut AskState, ch: char, command: impl FnOnce(&mut AskState))하나로 묶기. 본 PR 에서는 그대로 두고 follow-up task 가 P9-4 진입 시 추출 가능.(칭찬)
poll_worker가 thread panic 도 처리 —Err(panic_payload)분기에서downcast_ref::<&str>()/downcast_ref::<String>()두 path 시도. spec 명시 안 됐지만 worker 안 panic 이 silent 사라지지 않게 ErrorOverlay 로 표시. 운영자가 "왜 답변이 안 오지" 의문을 popup 한 줄로 답.@@ -61,6 +62,13 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {}}}Pane::Ask => {(칭찬)
Pane::Askidle tick 의drain_stream+poll_worker두 호출 분리.drain_stream매 frame token 누적,poll_worker가 thread 종료 시점만 처리 — 두 책임 깔끔히 분리. P9-2 의 debounce_due / fire_search / refresh_preview 와 같은 "per-pane idle hook" 패턴 유지.@@ -17,0 +20,4 @@**Symptom 1 (cosmetic)**: Same shape as P9-1 / P9-2 — `tasks/p9/p9-3-tui-ask.md` § Public surface declares `render_ask<B: ratatui::backend::Backend>(...)`. ratatui 0.28's `Frame` is backend-agnostic; the generic is unused and clippy `-D warnings` rejects it.**Symptom 2 (load-bearing)**: Spec key bindings list `e` (toggle explain), `j` / `k` (scroll). All three collide with typing — a user asking "explain javascript" would have the leading `e` toggle explain mode, then `j` scroll, etc. The Library / Search panes don't hit this because their input is either filter-overlay-gated (Library) or the whole pane *is* an input (Search). Ask has both an always-visible input bar AND scrollable answer area.(칭찬) HOTFIXES entry 가 "e/j/k 가 typing 과 충돌" 의 root cause (Library 는 filter overlay gated, Search 는 전체가 input — Ask 만 항상 input bar + 항상 scrollable answer) 정확히 짚음. "vim command vs insert mode at keystroke level" 표현이 미래 reader 에게 정신 모델 즉시 제공.
회차 2 — 회차 1 지적 1건 (Esc 후 재질문 시 두 worker 동시) 반영. spawn_ask_worker 진입 thread.is_some 가드 + input bar 의 "awaiting prior" hint + 회귀 테스트. 39 tests pass + clippy clean. 머지 가능. P9 phase 3/5 완성.
@@ -0,0 +181,4 @@Span::raw(c.citation.to_uri()),])}).collect(),(칭찬) input bar 가 세 async 상태 (streaming / awaiting prior / idle) 명시 분리. 사용자가 Enter 누르고 반응 없을 때 "awaiting prior answer (Enter blocked)" hint 가 즉시 답 — debugging session 줄임.
@@ -0,0 +330,4 @@return;}let handle = s.thread.take().expect("just confirmed Some");let result = handle.join();(칭찬) Enter 가드 가 세 조건 (
streaming || thread.is_some() || input empty) 모두 한 expr 로 묶임. detached prior thread 케이스가 정확히 covered — Esc 후 즉시 재질문 시 두 worker 동시 발사 차단.@@ -0,0 +319,4 @@(0..buffer.area.width).map(|x| buffer[(x, y)].symbol()).collect::<String>()})(칭찬)
enter_with_detached_prior_thread_is_blocked가 never-ending 더미 thread 를 hand-install 해 invariant 직접 잠금. 테스트 종료 시take()로 leak 명시 — OS reap 의존 명시도 코멘트로 honest. 미래에 누군가 Enter 가드를 "단순화" 시도하면 즉시 빨개짐.