Files
kebab/tasks/p9/p9-3-tui-ask.md
altair823 f08fefec1d feat(kebab-tui): P9-3 Ask pane — streaming answer + citation panel + explain toggle
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>
2026-05-02 15:24:26 +00:00

129 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: P9
component: kebab-tui (ask pane)
task_id: p9-3
title: "TUI Ask pane: streaming answer + citation links + --explain toggle"
status: completed
depends_on: [p4-3, p9-1]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
contract_sections: [§1.11.4 ask scenes, §2.3 Answer wire, §3.8 Answer]
---
# p9-3 — TUI Ask pane
## Goal
Add an Ask pane that calls `kebab-app::ask`, streams tokens into the answer area in real time, renders citation footnotes (default mode A), and toggles to `--explain` (mode B + retrieval trace) with a key.
## Why now / why this size
Streaming UI is the only TUI piece that meaningfully differs from search/inspect. Confining it here keeps the change set focused.
## Allowed dependencies
- `kebab-core`
- `kebab-config`
- `kebab-app`
- `kebab-tui` (extends p9-1)
- `ratatui`, `crossterm`
- `tracing`
- `thiserror`
## Forbidden dependencies
- `kebab-source-fs`, `kebab-parse-*`, `kebab-normalize`, `kebab-chunk`, `kebab-store-*`, `kebab-embed*`, `kebab-search`, `kebab-llm*`, `kebab-rag` (only via `kebab-app`), `kebab-desktop`
## Inputs
| input | type | source |
|-------|------|--------|
| `kebab-app::ask(query, AskOpts)` | facade | runtime |
| keyboard events | `crossterm` | terminal |
## Outputs
| output | type | downstream |
|--------|------|------------|
| Ratatui Ask pane render | terminal | user |
| `kebab-app::ask` invocation with streaming closure | facade | RAG pipeline |
## Public surface (signatures only — no new types)
```rust
pub fn render_ask<B: ratatui::backend::Backend>(f: &mut ratatui::Frame, area: ratatui::layout::Rect, state: &App);
pub fn handle_key_ask(state: &mut App, key: crossterm::event::KeyEvent) -> KeyOutcome;
```
This task fills the body of `kebab_tui::AskState` (forward-declared in p9-1). `App` is NOT edited — only `AskState` gets fields:
```rust
pub struct AskState {
pub input: String,
pub explain: bool,
pub streaming: bool,
pub partial: String,
pub answer: Option<kebab_core::Answer>,
pub thread: Option<std::thread::JoinHandle<anyhow::Result<kebab_core::Answer>>>,
pub rx: Option<std::sync::mpsc::Receiver<String>>,
}
```
`render_ask`/`handle_key_ask` read `app.ask.as_mut()` exclusively. Parallel-safety contract from p9-1 holds.
## Behavior contract
- Layout: top input bar (`?` prompt, query text), middle answer area (rendered Markdown-light: paragraphs + inline `[N]` markers), bottom-right citations panel (numbered list of citations with `path#fragment` and section label), bottom-left status (`grounded ✓/✗ model prompt_v k chunks`).
- Submission: `Enter` triggers a worker thread that calls `kebab-app::ask` with `AskOpts.stream_sink: Some(tx)` (`tx: mpsc::Sender<String>`). The thread holds the `tx`, the TUI holds the matching `rx` (set on `AskState.rx`). On each render frame the TUI drains `rx.try_iter()` into `state.partial`, no blocking.
- Streaming: while `ask_streaming = true`, the Answer area shows `ask_partial` and a small "▍" cursor. When the worker finishes, `ask_answer` is populated and the citations panel switches to the final list.
- Refusal rendering:
- `grounded = false` and `refusal_reason = ScoreGate` → render the answer (which is the human-friendly "근거 부족…" message), citations show "가까운 후보".
- `grounded = false` and `refusal_reason = LlmSelfJudge` → same layout but status shows `grounded ✗ … 3 chunks searched, 0 grounded`.
- Key bindings (Ask pane):
- typing → updates `ask_input`
- `Enter` → submit (only when not currently streaming)
- `e` → toggle `ask_explain`; resubmit on next `Enter`. While explain ON, citations panel is replaced by the per-claim breakdown (mode B in design §1.2) and a footer shows the retrieval trace summary.
- `Esc` → switch back to Library pane (cancellation of an in-flight ask is best-effort: the worker thread continues but its final answer is dropped).
- `j` / `k` → scroll the answer area when oversized.
- All facade calls stay within `kebab-app::ask` — never reach into `kebab-rag` directly.
- Errors render as a popup overlay; do not crash the pane.
## Storage / wire effects
- Reads/writes via `kebab-app::ask` which itself writes the `answers` row in `kebab.sqlite`. The pane has no direct DB access.
## Test plan
| kind | description | fixture / data |
|------|-------------|----------------|
| unit | submission spawns worker exactly once per `Enter` | inline mock |
| unit | streaming receiver accumulates tokens into `ask_partial` | inline mock with 5 tokens |
| unit | toggle `e` flips `ask_explain` and re-submits on `Enter` | inline |
| unit | refusal answer renders without citations panel index errors | inline |
| snapshot | rendered Ask pane mid-stream is stable | TestBackend |
| snapshot | rendered Ask pane after finished grounded answer is stable | TestBackend |
| integration | mocked `kebab-app::ask` returning a canned `Answer` populates final state correctly | inline |
All tests under `cargo test -p kebab-tui ask`.
## Definition of Done
- [ ] `cargo check -p kebab-tui` passes
- [ ] `cargo test -p kebab-tui ask` passes
- [ ] No imports outside Allowed dependencies
- [ ] Manual smoke: stream tokens visible character-by-character against a real Ollama (or `MockLanguageModel`)
- [ ] PR links design §1.11.4, §2.3
## Out of scope
- Persistent multi-turn chat memory.
- Conversational follow-ups.
- Voice input.
- Token-by-token highlighting per claim (the per-claim mode renders after completion).
## Risks / notes
- `mpsc::Receiver::try_recv` polled in the render loop; missing polls = stuttery streaming. Throttle the render at 30 fps and drain the channel each frame.
- Worker thread join on quit must not block forever; use `join_timeout` or detach if quit signaled.
- Cancellation: real cancellation of the LLM stream is provider-specific and out of scope. We accept "fire and forget" with discarded result on `Esc`.