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
9 changed files with 826 additions and 14 deletions

View File

@@ -4,7 +4,7 @@
## 한 줄 요약
P0P5 + P6 + P7 + P9-1 (Library) + P9-2 (Search) 머지 완료. `kebab ingest` 가 markdown / image / PDF 모두 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page citation 반환. `kebab tui` 가 Library + Search 패널 제공 (ask/inspect/desktop 진행 예정). 다음 후보 = P9-3 (TUI ask) / P9-4 (TUI inspect) / P9-5 (desktop tauri), 또는 보류 중인 P8 (audio) 의 시스템 dep brainstorm.
P0P5 + P6 + P7 + P9-1 (Library) + P9-2 (Search) + P9-3 (Ask) 머지 완료. `kebab ingest` 가 markdown / image / PDF 모두 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page citation 반환. `kebab tui` 가 Library + Search + Ask 패널 제공 (inspect/desktop 진행 예정). 다음 후보 = P9-4 (TUI inspect) / P9-5 (desktop tauri), 또는 보류 중인 P8 (audio) 의 시스템 dep brainstorm.
## Phase 로드맵
@@ -19,7 +19,7 @@ P0P5 + P6 + P7 + P9-1 (Library) + P9-2 (Search) 머지 완료. `kebab ingest`
| **P6** | 이미지 ingestion (OCR + caption) | `kebab-parse-image` | P5 | ✅ 완료 (4/4 component, OCR/caption Ollama-vision) |
| **P7** | PDF text + page citation | `kebab-parse-pdf` | P5 | ✅ 완료 (3/3 component, page-level chunker + ingest wiring) |
| **P8** | 음성 transcription + timestamp citation | `kebab-parse-audio` | P5 | ⏸ 보류 (whisper-rs 시스템 dep brainstorm 필요) |
| **P9** | TUI + desktop app | `kebab-tui`, `kebab-desktop` | P5 | 🟡 진행 (2/5 component — P9-1 Library + P9-2 Search 완료, P9-3/4/5 예정) |
| **P9** | TUI + desktop app | `kebab-tui`, `kebab-desktop` | P5 | 🟡 진행 (3/5 component — P9-1 Library + P9-2 Search + P9-3 Ask 완료, P9-4/5 예정) |
P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
@@ -38,6 +38,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **P7-3 storage UNIQUE bug** — `assets.workspace_path` UNIQUE + `upsert_asset_row``ON CONFLICT(asset_id)` gap 으로 byte 변경 re-ingest 실패. `purge_orphan_at_workspace_path` helper 추가, follow-up PR 으로 vector store orphan cleanup 까지 닫음 (`VectorStore::delete_by_chunk_ids`).
- **P9-1 ratatui 0.28** — spec literal 의 `render_library<B: Backend>` generic 이 ratatui 0.28 의 backend-agnostic Frame 과 어긋나 있어 제거. 테스트 seam `App::populate_library_for_testing` (`#[doc(hidden)]`) 추가.
- **P9-2 jump_to_citation workspace_root** — spec literal 의 `jump_to_citation(citation, editor_env)` 가 workspace_root 인자 누락. citation.path 가 workspace 상대라 editor 호출 시 절대 경로 필요 → `workspace_root: &Path` 인자 추가. 동일하게 `render_search<B: Backend>` generic 도 P9-1 과 같은 사유로 제거.
- **P9-3 e/j/k 키 의 \"input empty\" 분기** — spec 의 `e=toggle explain` / `j=k=scroll` 이 typing 과 충돌 (\"explain\" / \"javascript\" 같은 단어 입력 깨짐). input 이 비어 있을 때만 command 키로 동작 — vim \"command vs insert\" 컨벤션 변형. 사용자가 텍스트 입력 시 모든 알파벳 정상 통과.
## 다음 task 후보

View File

@@ -54,7 +54,7 @@ kebab search "Markdown chunking 규칙" --mode hybrid
# 질문 (Ollama 필요, PDF 인용 시 page 번호 surface)
kebab ask "내 KB 설계에서 저장소 전략은?"
# Ratatui 셸 (Library + Search 패널, ask/inspect 패널 진행 중)
# Ratatui 셸 (Library + Search + Ask 패널, inspect 패널 진행 중)
kebab tui
# 헬스 체크 (config 경로 / 데이터 디렉토리 쓰기 가능 여부)
@@ -76,7 +76,7 @@ kebab doctor
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab ask "<query>"` | RAG 답변 + 근거 인용. 근거 부족 시 거절. Ollama 필요 |
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
| `kebab tui` | Ratatui 셸 (Library + Search 패널, ask/inspect 패널 진행 중) |
| `kebab tui` | Ratatui 셸 (Library + Search + Ask 패널, inspect 패널 진행 중) |
| `kebab eval run / compare` | golden query 회귀 측정 |
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`).

View File

@@ -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 동작과 정합.
}
/// Forward-declared opaque sub-state. p9-3 fills the body.
pub struct AskState;
/// Ask pane state — owned by p9-3.
///
/// The worker thread (`thread`) owns the `mpsc::Sender<String>` that
/// `kebab-app::ask` writes tokens into. The pane keeps the matching
/// `rx` and drains it once per render frame (no blocking).
#[derive(Default)]
pub struct AskState {
pub input: String,
/// Toggled by the `e` key. Re-applied on the next `Enter`.
pub explain: bool,
/// True between `Enter` press and worker thread completion.
pub streaming: bool,
/// Tokens accumulated from the worker so far. Cleared on each
/// new submission.
pub partial: String,
/// Final `Answer` once the worker thread finishes.
pub answer: Option<kebab_core::Answer>,
/// In-flight worker; `take()`n when it finishes.
pub thread: Option<std::thread::JoinHandle<anyhow::Result<kebab_core::Answer>>>,
/// Token receiver paired with the worker's `Sender`. Drained
/// every render frame.
pub rx: Option<std::sync::mpsc::Receiver<String>>,
/// Vertical scroll offset for the answer area when content
/// exceeds the viewport.
pub scroll: u16,
/// Last error from the worker thread (rendered in popup if Some).
pub last_error: Option<String>,
}
/// Forward-declared opaque sub-state. p9-4 fills the body.
pub struct InspectState;

373
crates/kebab-tui/src/ask.rs Normal file
View File

@@ -0,0 +1,373 @@
//! Ask pane (P9-3).
//!
//! Streaming RAG answers in the TUI. Worker thread calls
//! `kebab-app::ask_with_config` with `AskOpts.stream_sink: Some(tx)`;
//! the pane keeps the matching `rx` and drains it once per render
//! frame so the answer area updates token-by-token without
//! blocking the event loop.
//!
//! Spec deviation (HOTFIXES `2026-05-02 P9-3`):
//! - `render_ask<B: Backend>` generic dropped (ratatui 0.28 Frame is
//! backend-agnostic — same as P9-1 / P9-2).
//!
//! Per design §1.1§1.4 (ask scenes), §2.3 (Answer wire), §3.8
//! (`Answer`).
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{RefusalReason, SearchMode};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use std::sync::mpsc;
use std::thread;
use crate::app::{App, AskState, KeyOutcome, Pane};
/// Render the Ask pane. Layout:
/// - top input bar
/// - middle answer area (scrollable when content overflows)
/// - bottom split: status (left) + citations / explain panel (right)
pub fn render_ask(f: &mut Frame, area: Rect, state: &App) {
let Some(s) = state.ask.as_ref() else {
f.render_widget(Block::default().title("Ask").borders(Borders::ALL), area);
return;
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(7),
])
.split(area);
render_input(f, layout[0], s);
render_answer(f, layout[1], s);
render_bottom(f, layout[2], s);
}
fn render_input(f: &mut Frame, area: Rect, s: &AskState) {
let mode_badge = if s.explain { " explain" } else { "" };
// Distinguish three async states for the operator:
// - currently streaming (worker still emitting tokens)
// - prior worker detached (Esc-cancelled, no rx attached but
// thread has not finished yet — Enter is blocked until it ends)
// - idle
let busy = if s.streaming {
" streaming…"
} else if s.thread.is_some() {
" awaiting prior answer (Enter blocked)"
} else {
""
};
let line = Line::from(vec![
Span::styled("? ", Style::default().fg(Color::Cyan)),
Span::raw(s.input.as_str()),
Span::styled(mode_badge, Style::default().fg(Color::Yellow)),
Span::styled(busy, Style::default().add_modifier(Modifier::DIM)),
]);
let block = Block::default()
.title("ask (Enter=submit e=explain Esc=back)")
.borders(Borders::ALL);
f.render_widget(Paragraph::new(line).block(block), area);
}
fn render_answer(f: &mut Frame, area: Rect, s: &AskState) {
let block = Block::default().title("answer").borders(Borders::ALL);
if s.streaming {
// Mid-stream: show partial + cursor block.
let mut content = s.partial.clone();
content.push('▍');
let para = Paragraph::new(content)
.wrap(Wrap { trim: false })
.scroll((s.scroll, 0));
f.render_widget(para.block(block), area);
return;
}
if let Some(answer) = &s.answer {
// Style refusal answers (grounded=false) in yellow so the user
// immediately spots they're not getting a sourced answer.
let style = if answer.grounded {
Style::default()
} else {
Style::default().fg(Color::Yellow)
};
let para = Paragraph::new(Span::styled(answer.answer.as_str(), style))
.wrap(Wrap { trim: false })
.scroll((s.scroll, 0));
f.render_widget(para.block(block), area);
return;
}
// No question yet.
let hint = Paragraph::new(Span::styled(
"(type a question and press Enter to ask. RAG answers stream token-by-token.)",
Style::default().add_modifier(Modifier::DIM),
))
.wrap(Wrap { trim: false });
f.render_widget(hint.block(block), area);
}
fn render_bottom(f: &mut Frame, area: Rect, s: &AskState) {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
render_status(f, split[0], s);
render_citations_or_explain(f, split[1], s);
}
fn render_status(f: &mut Frame, area: Rect, s: &AskState) {
let block = Block::default().title("status").borders(Borders::ALL);
let lines: Vec<Line> = match &s.answer {
None => vec![Line::from(Span::styled(
"(no answer yet)",
Style::default().add_modifier(Modifier::DIM),
))],
Some(a) => {
let grounded = if a.grounded { "" } else { "" };
let mode = match a.retrieval.mode {
SearchMode::Lexical => "lexical",
SearchMode::Vector => "vector",
SearchMode::Hybrid => "hybrid",
};
let refusal = match a.refusal_reason {
Some(RefusalReason::ScoreGate) => " refusal=score_gate",
Some(RefusalReason::LlmSelfJudge) => " refusal=llm_self_judge",
Some(RefusalReason::NoIndex) => " refusal=no_index",
Some(RefusalReason::NoChunks) => " refusal=no_chunks",
None => "",
};
vec![
Line::from(format!("grounded {grounded} model {}", a.model.id)),
Line::from(format!("prompt {} mode {mode}", a.prompt_template_version.0)),
Line::from(format!(
"k={} used={}/{}{refusal}",
a.retrieval.k, a.retrieval.chunks_used, a.retrieval.chunks_returned
)),
]
}
};
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState) {
let title = if s.explain { "explain (per-claim)" } else { "citations" };
let block = Block::default().title(title).borders(Borders::ALL);
let lines: Vec<Line> = match &s.answer {
None => vec![Line::from(Span::styled(
"(submit a question to see citations)",
Style::default().add_modifier(Modifier::DIM),
))],
Some(a) if a.citations.is_empty() => vec![Line::from(Span::styled(
if a.grounded { "(no citations)" } else { "(가까운 후보 없음)" },
Style::default().add_modifier(Modifier::DIM),
))],
Some(a) => a
.citations
.iter()
.map(|c| {
let marker = c.marker.as_deref().unwrap_or("?");
Line::from(vec![
Span::styled(
format!("[{marker}] "),
Style::default().fg(Color::Cyan),
),
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 줄임.
};
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(para.block(block), area);
}
/// Ask pane key dispatch. Submission spawns a worker thread that
/// drives `kebab-app::ask_with_config` with `stream_sink: Some(tx)`.
pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome {
if state.error_overlay.is_some() {
state.error_overlay = None;
return KeyOutcome::Continue;
}
if state.ask.is_none() {
return KeyOutcome::SwitchPane(Pane::Library);
}
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
// Best-effort cancellation per spec — worker keeps running
// but its result is dropped. Detach by clearing rx /
// thread; the JoinHandle Drop on later replacement will
// not block (we never `join` from this path).
let s = state.ask.as_mut().unwrap();
s.rx = None;
s.thread = None;
s.streaming = false;
KeyOutcome::SwitchPane(Pane::Library)
}
(KeyCode::Enter, _) => {
// Submission gates:
// - empty input → no-op
// - already streaming → no-op (same worker is in flight)
// - prior worker still attached (e.g. user pressed Esc
// then re-entered Ask before that thread finished) →
// no-op. Otherwise the new worker would race the
// detached one against the same Ollama endpoint and
// the stream output would interleave.
if state
.ask
.as_ref()
.map(|s| s.streaming || s.thread.is_some() || s.input.trim().is_empty())
.unwrap_or(true)
{
return KeyOutcome::Continue;
}
spawn_ask_worker(state);
KeyOutcome::Continue
}
// `e` only as a plain (no-modifier) press — typing 'e' in a
// word like "explain" must still reach the input buffer.
// The spec lists `e` as the explain-toggle; we apply the same
// SHIFT-aware convention as P9-2's `g` jump.
(KeyCode::Char('e'), KeyModifiers::NONE) => {
// Ambiguity with typing — distinguish via empty input as
// a heuristic: when input is empty, `e` toggles; while
// typing, `e` reaches the buffer. Vim users will recognise
// this "command vs insert" split applied at the keystroke
// level.
let s = state.ask.as_mut().unwrap();
if s.input.is_empty() {
s.explain = !s.explain;
KeyOutcome::Continue
} else {
s.input.push('e');
KeyOutcome::Continue
}
}
(KeyCode::Char('j'), KeyModifiers::NONE) => {
let s = state.ask.as_mut().unwrap();
if s.input.is_empty() {
s.scroll = s.scroll.saturating_add(1);
} else {
s.input.push('j');
}
KeyOutcome::Continue
}
(KeyCode::Char('k'), KeyModifiers::NONE) => {
let s = state.ask.as_mut().unwrap();
if s.input.is_empty() {
s.scroll = s.scroll.saturating_sub(1);
} else {
s.input.push('k');
}
KeyOutcome::Continue
}
(KeyCode::Backspace, _) => {
let s = state.ask.as_mut().unwrap();
s.input.pop();
KeyOutcome::Continue
}
(KeyCode::Char(c), _) => {
let s = state.ask.as_mut().unwrap();
s.input.push(c);
KeyOutcome::Continue
}
_ => KeyOutcome::Continue,
}
}
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();
let explain = s.explain;
s.partial.clear();
s.answer = None;
s.streaming = true;
s.scroll = 0;
s.rx = Some(rx);
let opts = kebab_app::AskOpts {
k: 0, // facade clamps to config.search.default_k floor
explain,
mode: kebab_core::SearchMode::Hybrid,
temperature: None,
seed: None,
stream_sink: Some(tx),
};
let handle =
thread::spawn(move || kebab_app::ask_with_config(cfg, &query, opts));
s.thread = Some(handle);
}
/// Run-loop hook: drain the streaming channel into `partial`. Called
/// on every render frame so the answer area updates as tokens arrive.
pub(crate) fn drain_stream(state: &mut App) {
let Some(s) = state.ask.as_mut() else { return };
if let Some(rx) = &s.rx {
for tok in rx.try_iter() {
s.partial.push_str(&tok);
}
}
}
/// Run-loop hook: poll the worker thread for completion. When the

(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 텍스트 한 줄.
/// thread finishes, populate `answer` and clear `streaming`.
pub(crate) fn poll_worker(state: &mut App) {
let Some(s) = state.ask.as_mut() else { return };
let finished = s
.thread
.as_ref()
.map(|h| h.is_finished())
.unwrap_or(false);
if !finished {
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 동시 발사 차단.
s.streaming = false;
s.rx = None;
match result {
Ok(Ok(answer)) => {
// Final partial is the full answer text; replace partial
// with the canonical answer.answer so post-stream rendering
// is identical regardless of stream pacing.
s.partial.clear();
s.answer = Some(answer);
}
Ok(Err(e)) => {
s.last_error = Some(format!("{e:#}"));
state.error_overlay =
Some(crate::error_popup::ErrorOverlay::from_anyhow(&e));
}
Err(panic_payload) => {
let msg = panic_payload

(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 진입 시 추출 가능.
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic_payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "ask worker panicked".to_string());
s.last_error = Some(msg.clone());
state.error_overlay =
Some(crate::error_popup::ErrorOverlay::from_message(
"ask worker panic",
msg,
));
}
}
}
/// Test-only helper. The pane's worker spawns a real `ask_with_config`
/// 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))]
#[allow(dead_code)]
pub(crate) fn debug_partial(state: &App) -> Option<&str> {
state.ask.as_ref().map(|s| s.partial.as_str())
}

View File

@@ -13,6 +13,7 @@
//! (`SearchHit` / `DocSummary`).
mod app;
mod ask;
mod error_popup;
mod library;
mod run;
@@ -20,6 +21,7 @@ mod search;
mod terminal;
pub use app::{App, AskState, InspectState, KeyOutcome, LibraryState, Pane, SearchState};
pub use ask::{handle_key_ask, render_ask};
pub use error_popup::{ErrorOverlay, render_error_overlay};
pub use library::{handle_key_library, render_library};
pub use search::{build_jump_command, handle_key_search, jump_to_citation, render_search};

View File

@@ -10,7 +10,8 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use std::time::Duration;
use crate::app::{App, KeyOutcome, Pane, SearchState};
use crate::app::{App, AskState, KeyOutcome, Pane, SearchState};
use crate::ask::{drain_stream, handle_key_ask, poll_worker, render_ask};
use crate::error_popup::{ErrorOverlay, render_error_overlay};
use crate::library::{handle_key_library, refresh_docs, render_library};
use crate::search::{
@@ -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" 패턴 유지.
// Token stream + worker completion polled every
// tick so the answer area updates without
// blocking the event loop.
drain_stream(app);
poll_worker(app);
}
_ => {}
}
}
@@ -73,10 +81,11 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
let outcome = match app.focus {
Pane::Library => handle_key_library(app, key),
Pane::Search => handle_key_search(app, key),
// p9-3/4/5 plug their handlers here as their
Pane::Ask => handle_key_ask(app, key),
// p9-4/5 plug their handlers here as their
// crates land. Until then, those panes accept
// only `q` / `Esc` to return.
Pane::Ask | Pane::Inspect | Pane::Jobs => {
Pane::Inspect | Pane::Jobs => {
handle_key_unimplemented_pane(app, key)
}
};
@@ -88,6 +97,9 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
if p == Pane::Search && app.search.is_none() {
app.search = Some(SearchState::default());
}
if p == Pane::Ask && app.ask.is_none() {
app.ask = Some(AskState::default());
}
}
KeyOutcome::Refresh => {
// Library uses needs_refresh; Search uses
@@ -135,9 +147,10 @@ fn render_root(f: &mut Frame, app: &App) {
match app.focus {
Pane::Library => render_library(f, outer[1], app),
Pane::Search => render_search(f, outer[1], app),
// p9-3/4/5 panes are not yet rendered; placeholder is the
// Library frame — focus state already reads "Search" /
// "Ask" / etc. in the header so the user is not misled.
Pane::Ask => render_ask(f, outer[1], app),
// p9-4/5 panes (Inspect / Jobs) not yet rendered; placeholder
// is the Library frame — focus state header still reads
// "Inspect" / "Jobs" so the user is not misled.
_ => render_library(f, outer[1], app),
}
render_footer(f, outer[2], app);
@@ -175,7 +188,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
}
}
Pane::Search => "type=query Tab=mode Enter=search j/k=move g=open in $EDITOR Esc=back",
Pane::Ask => "Ask pane not yet implemented (lands with p9-3) — q to return",
Pane::Ask => "type=question Enter=submit e=explain (when input empty) j/k=scroll (when input empty) Esc=back",
Pane::Inspect => "Inspect pane not yet implemented (lands with p9-4) — q to return",
Pane::Jobs => "Jobs pane not yet implemented — q to return",
};

View File

@@ -0,0 +1,378 @@
//! Unit + snapshot tests for the Ask pane (P9-3).
//!
//! Worker thread / streaming path is NOT exercised here — that would
//! require a real Ollama + SQLite KB. Tests drive the pane via
//! hand-populated `AskState`.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, ModelRef,
PromptTemplateVersion, RefusalReason, SearchMode, TokenUsage, TraceId, WorkspacePath,
};
use kebab_tui::{App, AskState, KeyOutcome, Pane, handle_key_ask, render_ask};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use time::OffsetDateTime;
fn fresh_app() -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-ask-tests-noop".to_string();
config.workspace.root = "/tmp/kebab-tui-ask-tests-noop/workspace".to_string();
let mut app = App::new(config).expect("App::new");
app.focus = Pane::Ask;
app.ask = Some(AskState::default());
app
}
fn make_answer(grounded: bool, refusal: Option<RefusalReason>, body: &str) -> Answer {
Answer {
answer: body.to_string(),
citations: vec![AnswerCitation {
marker: Some("1".to_string()),
citation: Citation::Line {
path: WorkspacePath::new("notes/foo.md".into()).unwrap(),
start: 12,
end: 14,
section: Some("Section A".into()),
},
}],
grounded,
refusal_reason: refusal,
model: ModelRef {
id: "qwen2.5:7b-instruct".into(),
provider: "ollama".into(),
dimensions: None,
},
embedding: Some(ModelRef {
id: "multilingual-e5-small".into(),
provider: "fastembed".into(),
dimensions: Some(384),
}),
prompt_template_version: PromptTemplateVersion("rag-v1".into()),
retrieval: AnswerRetrievalSummary {
trace_id: TraceId("test-trace".into()),
mode: SearchMode::Hybrid,
k: 10,
score_gate: 0.05,
top_score: 0.8,
chunks_returned: 7,
chunks_used: 3,
},
usage: TokenUsage {
prompt_tokens: 100,
completion_tokens: 50,
latency_ms: 1200,
},
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
}
}
#[test]
fn esc_returns_to_library_and_clears_streaming() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.streaming = true;
s.partial = "partial answer…".into();
}
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
let s = app.ask.as_ref().unwrap();
assert!(!s.streaming);
assert!(s.rx.is_none());
assert!(s.thread.is_none());
}
#[test]
fn typing_appends_to_input() {
let mut app = fresh_app();
for ch in "hello".chars() {
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
assert_eq!(app.ask.as_ref().unwrap().input, "hello");
}
#[test]
fn backspace_pops_input() {
let mut app = fresh_app();
{
app.ask.as_mut().unwrap().input = "abcd".into();
}
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(app.ask.as_ref().unwrap().input, "abc");
}
#[test]
fn e_toggles_explain_when_input_empty() {
let mut app = fresh_app();
assert!(!app.ask.as_ref().unwrap().explain);
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(app.ask.as_ref().unwrap().explain);
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(!app.ask.as_ref().unwrap().explain);
}
#[test]
fn e_typed_into_input_when_input_nonempty() {
let mut app = fresh_app();
{
app.ask.as_mut().unwrap().input = "qu".into();
}
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!(!s.explain, "explain must NOT toggle while typing a word");
}
#[test]
fn enter_with_empty_input_is_continue() {
let mut app = fresh_app();
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::Continue);
assert!(!app.ask.as_ref().unwrap().streaming);
}
#[test]
fn enter_while_streaming_is_noop() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.input = "anything".into();
s.streaming = true;
}
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
// streaming flag remains true (no new worker spawned)
assert!(app.ask.as_ref().unwrap().streaming);
// No thread spawned because enter was a no-op.
assert!(app.ask.as_ref().unwrap().thread.is_none());
}
#[test]
fn render_pre_submission_shows_hint() {
let app = fresh_app();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, 24);
render_ask(f, area, &app);
})
.unwrap();
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");
assert!(rendered.contains("ask"), "input bar visible");
assert!(
rendered.contains("type a question") || rendered.contains("Enter"),
"pre-submission hint visible"
);
}
#[test]
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.streaming = true;
s.partial = "RRF는 reciprocal rank fusion".into();
}
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_ask(f, area, &app);
})
.unwrap();
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");
assert!(rendered.contains("streaming"), "streaming hint visible");
assert!(
rendered.contains("reciprocal rank fusion"),
"partial body rendered"
);
assert!(rendered.contains(""), "cursor block rendered mid-stream");
}
#[test]
fn render_grounded_answer_with_citation() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.input = "test".into();
s.answer = Some(make_answer(true, None, "test answer body [1]."));
}
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 100, 24);
render_ask(f, area, &app);
})
.unwrap();
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");
assert!(rendered.contains("test answer body"), "answer body rendered");
assert!(rendered.contains("grounded ✓"), "grounded status visible");
assert!(rendered.contains("notes/foo.md"), "citation path rendered");
assert!(rendered.contains("[1]"), "citation marker rendered");
}
#[test]
fn render_refusal_score_gate_shows_status_without_citation_index_panic() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
let mut ans = make_answer(false, Some(RefusalReason::ScoreGate), "insufficient grounding to answer.");
ans.citations.clear(); // refusal often has no citations
s.answer = Some(ans);
}
let backend = TestBackend::new(120, 20);
let mut terminal = Terminal::new(backend).unwrap();
// Test passes if render does not panic on empty citations.
terminal
.draw(|f| {
let area = Rect::new(0, 0, 120, 20);
render_ask(f, area, &app);
})
.unwrap();
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");
assert!(rendered.contains("insufficient grounding"), "refusal body rendered");
assert!(rendered.contains("grounded ✗"), "ungrounded status visible");
assert!(rendered.contains("score_gate"), "refusal reason surfaced");
}
#[test]
fn explain_toggle_changes_panel_title() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.answer = Some(make_answer(true, None, "answer body."));
s.explain = true;
}
let backend = TestBackend::new(100, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 100, 24);
render_ask(f, area, &app);
})
.unwrap();
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>()
})

(칭찬) 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 가드를 "단순화" 시도하면 즉시 빨개짐.
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("explain (per-claim)"),
"explain mode panel title"
);
}
#[test]
fn enter_with_detached_prior_thread_is_blocked() {
// R1 fix: after Esc, the prior worker is detached (thread still
// running, rx cleared, streaming=false). A new Enter must NOT
// spawn a second worker against the same Ollama endpoint until
// the prior thread finishes.
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
s.input = "another question".into();
s.streaming = false;
// Simulate a detached prior worker by hand-installing a
// never-ending JoinHandle. (We can't easily make a sleeping
// thread without timing flakiness; an empty-loop shim works.)
s.thread = Some(std::thread::spawn(|| {
// Loop until the test drops the JoinHandle's owner via
// App going out of scope. is_finished() will report
// false until then.
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}));
}
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
// Enter is a no-op while a prior thread is attached.
assert_eq!(outcome, KeyOutcome::Continue);
let s = app.ask.as_ref().unwrap();
assert!(!s.streaming, "no second worker spawned");
// Detach so the never-ending thread can be reaped on test exit.
let _leaked = app.ask.as_mut().unwrap().thread.take();
}
#[test]
fn no_ask_state_returns_to_library() {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-ask-tests-noop".into();
let mut app = App::new(config).unwrap();
app.focus = Pane::Ask;
// ask slot intentionally None
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}

View File

@@ -14,6 +14,24 @@ 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-02 — P9-3 TUI Ask: render_ask generic + command-vs-insert key disambiguation
**Discovered**: P9-3 implementation start.
**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 에게 정신 모델 즉시 제공.
**Fix**:
- `render_ask(f: &mut Frame, area: Rect, state: &App)` — no generic.
- `e` / `j` / `k` use the **input-empty heuristic**: when `state.ask.input.is_empty()`, they act as command keys (toggle explain / scroll up/down). When the input has content, they reach the input buffer as ordinary characters. Vim's "command vs insert mode" applied at the keystroke level — the user starts typing, the keys behave as text; clears the input (Backspace to empty), the keys behave as commands again.
- `Enter` always submits (when input non-empty AND not already streaming). `Esc` always returns to Library + clears `streaming/rx/thread` (best-effort cancel — worker keeps running but its result is dropped, per spec § Risks "fire and forget").
**Trust note**: The worker thread holds the `mpsc::Sender<String>`; the pane keeps `rx` and drains via `try_iter` once per render frame (no blocking). On Esc we `take()` the `JoinHandle` without `join` so quit is instant; the kernel reaps the orphan when its `ask_with_config` returns.
**Amends**:
- tasks/p9/p9-3-tui-ask.md (`render_ask` non-generic; `e`/`j`/`k` empty-input gating).
## 2026-05-02 — P9-2 TUI Search: render_search generic + jump_to_citation workspace_root
**Discovered**: P9-2 implementation start.

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-tui (ask pane)
task_id: p9-3
title: "TUI Ask pane: streaming answer + citation links + --explain toggle"
status: planned
status: completed
depends_on: [p4-3, p9-1]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md