feat(kebab-tui): P9-3 Ask pane #45

Merged
altair823 merged 2 commits from feat/p9-3-tui-ask into main 2026-05-02 15:31:59 +00:00
Owner

요약

P9-1 Library 의 ? 키 활성화. App.ask slot 채움. Worker thread 가 kebab-app::ask_with_config 호출하면서 AskOpts.stream_sink 로 token 을 mpsc 채널에 보내고, TUI 가 매 render frame 마다 drain → 답변 영역 token-by-token 업데이트.

핵심 결정

  • AskState: input / explain / streaming / partial / answer / thread JoinHandle / rx Receiver / scroll. 모두 pub 으로 cross-module (run.rs idle tick) 접근 가능.
  • Layout: 3 영역. input bar (top) / 답변 영역 (middle, scrollable) / status + citations or explain panel (bottom split).
  • Streaming: spawn_ask_worker 가 (tx, rx) = mpsc::channel() + thread::spawn(|| ask_with_config(opts: stream_sink: Some(tx))). run-loop idle tick drain_streamrx.try_iter()partial 누적, poll_workerhandle.is_finished()take() + join() → answer.
  • Best-effort cancel: Esc → s.rx = None; s.thread = None; s.streaming = false. Worker 는 계속 돌지만 결과 drop. JoinHandle 미사용 시 OS 가 reap.
  • Refusal rendering: grounded=false 면 답변 영역 yellow + status 의 refusal_reason 한 줄 (score_gate / llm_self_judge / no_chunks / no_index).

Spec deviation (HOTFIXES 2026-05-02 P9-3)

  1. render_ask<B: Backend> generic 제거 — P9-1/P9-2 와 동일.
  2. e / j / k 키 의 input-empty 분기 — input 비어 있을 때만 command, 있을 때는 typing. spec literal 단순 e=toggle 이 "explain" / "javascript" 같은 단어 입력 깨뜨림. vim "command vs insert" 변형 적용.

테스트 (13개, tests/ask.rs)

  • Esc → Library + streaming/rx/thread 클리어
  • typing 누적 / Backspace pop
  • e toggle (input empty) / e typed (input nonempty)
  • Enter empty input → Continue (no-op) / Enter while streaming → no new worker
  • render pre-submission hint
  • render streaming partial + ▍ cursor
  • render grounded answer with citation [1]
  • render refusal score_gate 패널 (citations 비었을 때 panic 없음)
  • explain panel title flip
  • no ask slot → SwitchPane(Library)

Docs (sync rule)

  • README: TUI 행 "Library + Search + Ask 패널, inspect 진행 중" + Quick start 코멘트.
  • HANDOFF: 한 줄 요약 + Phase status (P9 2/5 → 3/5) + deviation 한 줄.
  • HOTFIXES: P9-3 entry.
  • tasks/p9/p9-3: status completed.

검증

  • cargo test -p kebab-tui 38 passed (10 library + 15 search + 13 ask)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo build --release -p kebab-cli clean

Test plan

  • 단위 13건 통과
  • clippy -D warnings
  • release rebuild
  • HOTFIXES + README + HANDOFF + spec status sync
  • 사용자 manual smoke: kebab tui? → 질문 → Enter → 토큰 stream → e (input empty) explain → Esc Library 복귀

Out of scope (spec)

  • Persistent multi-turn chat memory
  • 진짜 LLM stream cancellation (provider-specific)
  • Voice input
## 요약 P9-1 Library 의 `?` 키 활성화. `App.ask` slot 채움. Worker thread 가 `kebab-app::ask_with_config` 호출하면서 `AskOpts.stream_sink` 로 token 을 mpsc 채널에 보내고, TUI 가 매 render frame 마다 drain → 답변 영역 token-by-token 업데이트. ## 핵심 결정 - **AskState**: input / explain / streaming / partial / answer / thread JoinHandle / rx Receiver / scroll. 모두 `pub` 으로 cross-module (run.rs idle tick) 접근 가능. - **Layout**: 3 영역. input bar (top) / 답변 영역 (middle, scrollable) / status + citations or explain panel (bottom split). - **Streaming**: spawn_ask_worker 가 `(tx, rx) = mpsc::channel()` + `thread::spawn(|| ask_with_config(opts: stream_sink: Some(tx)))`. run-loop idle tick `drain_stream` 가 `rx.try_iter()` → `partial` 누적, `poll_worker` 가 `handle.is_finished()` → `take()` + `join()` → answer. - **Best-effort cancel**: Esc → `s.rx = None; s.thread = None; s.streaming = false`. Worker 는 계속 돌지만 결과 drop. JoinHandle 미사용 시 OS 가 reap. - **Refusal rendering**: `grounded=false` 면 답변 영역 yellow + status 의 refusal_reason 한 줄 (`score_gate` / `llm_self_judge` / `no_chunks` / `no_index`). ## Spec deviation (HOTFIXES `2026-05-02 P9-3`) 1. `render_ask<B: Backend>` generic 제거 — P9-1/P9-2 와 동일. 2. `e` / `j` / `k` 키 의 **input-empty 분기** — input 비어 있을 때만 command, 있을 때는 typing. spec literal 단순 `e=toggle` 이 \"explain\" / \"javascript\" 같은 단어 입력 깨뜨림. vim \"command vs insert\" 변형 적용. ## 테스트 (13개, `tests/ask.rs`) - Esc → Library + streaming/rx/thread 클리어 - typing 누적 / Backspace pop - e toggle (input empty) / e typed (input nonempty) - Enter empty input → Continue (no-op) / Enter while streaming → no new worker - render pre-submission hint - render streaming partial + ▍ cursor - render grounded answer with citation `[1]` - render refusal `score_gate` 패널 (citations 비었을 때 panic 없음) - explain panel title flip - no ask slot → SwitchPane(Library) ## Docs (sync rule) - README: TUI 행 \"Library + Search + Ask 패널, inspect 진행 중\" + Quick start 코멘트. - HANDOFF: 한 줄 요약 + Phase status (P9 2/5 → 3/5) + deviation 한 줄. - HOTFIXES: P9-3 entry. - tasks/p9/p9-3: status `completed`. ## 검증 - `cargo test -p kebab-tui` 38 passed (10 library + 15 search + 13 ask) - `cargo clippy --workspace --all-targets -- -D warnings` clean - `cargo build --release -p kebab-cli` clean ## Test plan - [x] 단위 13건 통과 - [x] clippy `-D warnings` - [x] release rebuild - [x] HOTFIXES + README + HANDOFF + spec status sync - [ ] 사용자 manual smoke: `kebab tui` → `?` → 질문 → Enter → 토큰 stream → `e` (input empty) explain → Esc Library 복귀 ## Out of scope (spec) - Persistent multi-turn chat memory - 진짜 LLM stream cancellation (provider-specific) - Voice input
altair823 added 1 commit 2026-05-02 15:24:59 +00:00
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>
claude-reviewer-01 requested changes 2026-05-02 15:26:36 +00:00
claude-reviewer-01 left a comment
Member

회차 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 정신모델).

회차 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 {
}

(칭찬) AskStateJoinHandle<Result<Answer>>Receiver<String> 을 분리 보유. spawn 시 worker 가 tx 를 take + thread 안에서 합본 답변 반환, pane 은 stream 누적 + 최종 답변 모두 가짐. mpsc try_iter 로 non-blocking drain — stream_sink 가 dropped 시 SendError swallow 되는 P4-3 동작과 정합.

(칭찬) `AskState` 가 `JoinHandle<Result<Answer>>` 와 `Receiver<String>` 을 분리 보유. spawn 시 worker 가 `tx` 를 take + thread 안에서 합본 답변 반환, pane 은 stream 누적 + 최종 답변 모두 가짐. mpsc `try_iter` 로 non-blocking drain — `stream_sink` 가 dropped 시 `SendError` swallow 되는 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 (둘 중 택일):

  • (A, 추천) spawn_ask_worker 진입 시 if s.thread.is_some() { return; } 추가 — 이전 thread 가 아직 살아있으면 새 spawn 보류. 사용자에게는 "이전 답변 종료 대기" 라고 input bar 에 한 줄 hint.
  • (B) input bar 에 " awaiting prior answer" 같은 표시 만 추가 (현재 streaming 표시와 별개). spawn 자체는 허용.

(A) 가 안전 + 디버깅 친화. 1 줄 + hint 텍스트 한 줄.

(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 (둘 중 택일): - (A, 추천) `spawn_ask_worker` 진입 시 `if s.thread.is_some() { return; }` 추가 — 이전 thread 가 아직 살아있으면 새 spawn 보류. 사용자에게는 "이전 답변 종료 대기" 라고 input bar 에 한 줄 hint. - (B) input bar 에 "⏳ awaiting prior answer" 같은 표시 만 추가 (현재 streaming 표시와 별개). spawn 자체는 허용. (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 진입 시 추출 가능.

(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 한 줄로 답.

(칭찬) `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::Ask idle tick 의 drain_stream + poll_worker 두 호출 분리. drain_stream 매 frame token 누적, poll_worker 가 thread 종료 시점만 처리 — 두 책임 깔끔히 분리. P9-2 의 debounce_due / fire_search / refresh_preview 와 같은 "per-pane idle hook" 패턴 유지.

(칭찬) `Pane::Ask` idle 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 에게 정신 모델 즉시 제공.

(칭찬) 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 에게 정신 모델 즉시 제공.
altair823 added 1 commit 2026-05-02 15:27:42 +00:00
Esc 후 재질문 시 detached prior worker + 새 worker 동시 in-flight 가능
했음. Ollama endpoint 에 두 요청 동시 발사 → 응답 시간 두 배 + stream
혼동. spawn_ask_worker 진입 시 `s.thread.is_some()` 검사 추가, 이전
worker 가 still alive 면 Enter 무시. input bar 의 busy 텍스트 가 세
상태 (streaming / awaiting prior / idle) 분리 표시 — 사용자가 Enter
가 왜 안 먹히는지 즉시 확인.

회귀 테스트 `enter_with_detached_prior_thread_is_blocked` 추가 — never-
ending 더미 thread 를 hand-install 후 Enter no-op 검증, 종료 시 thread
take() 로 leak 명시 (test process 종료 시 OS 가 reap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-02 15:28:04 +00:00
claude-reviewer-01 left a comment
Member

회차 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 완성.

회차 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 줄임.

(칭찬) 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 동시 발사 차단.

(칭찬) 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 가드를 "단순화" 시도하면 즉시 빨개짐.

(칭찬) `enter_with_detached_prior_thread_is_blocked` 가 never-ending 더미 thread 를 hand-install 해 invariant 직접 잠금. 테스트 종료 시 `take()` 로 leak 명시 — OS reap 의존 명시도 코멘트로 honest. 미래에 누군가 Enter 가드를 "단순화" 시도하면 즉시 빨개짐.
altair823 merged commit 8396e22ad3 into main 2026-05-02 15:31:59 +00:00
altair823 deleted branch feat/p9-3-tui-ask 2026-05-02 15:32:00 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#45