From f08fefec1d66de63a929df89fc32ba8c376daab6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 15:24:26 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(kebab-tui):=20P9-3=20Ask=20pane=20?= =?UTF-8?q?=E2=80=94=20streaming=20answer=20+=20citation=20panel=20+=20exp?= =?UTF-8?q?lain=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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) --- HANDOFF.md | 5 +- README.md | 4 +- crates/kebab-tui/src/app.rs | 31 ++- crates/kebab-tui/src/ask.rs | 354 ++++++++++++++++++++++++++++++++++ crates/kebab-tui/src/lib.rs | 2 + crates/kebab-tui/src/run.rs | 27 ++- crates/kebab-tui/tests/ask.rs | 343 ++++++++++++++++++++++++++++++++ tasks/HOTFIXES.md | 18 ++ tasks/p9/p9-3-tui-ask.md | 2 +- 9 files changed, 772 insertions(+), 14 deletions(-) create mode 100644 crates/kebab-tui/src/ask.rs create mode 100644 crates/kebab-tui/tests/ask.rs diff --git a/HANDOFF.md b/HANDOFF.md index 163f042..c522200 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ ## 한 줄 요약 -P0–P5 + 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. +P0–P5 + 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 @@ P0–P5 + 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` 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` 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 후보 diff --git a/README.md b/README.md index cfc678e..2dbe026 100644 --- a/README.md +++ b/README.md @@ -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 ` / `kebab inspect chunk ` | raw record 보기 | | `kebab ask ""` | 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`). diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index 264a0c4..c39580e 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -92,8 +92,35 @@ impl Default for SearchState { } } -/// 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` 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, + /// In-flight worker; `take()`n when it finishes. + pub thread: Option>>, + /// Token receiver paired with the worker's `Sender`. Drained + /// every render frame. + pub rx: Option>, + /// 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, +} + /// Forward-declared opaque sub-state. p9-4 fills the body. pub struct InspectState; diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs new file mode 100644 index 0000000..e3c62bb --- /dev/null +++ b/crates/kebab-tui/src/ask.rs @@ -0,0 +1,354 @@ +//! 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` 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 { "" }; + let busy = if s.streaming { " streaming…" } 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 = 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 = 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(), + }; + 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, _) => { + if state + .ask + .as_ref() + .map(|s| s.streaming || 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::(); + 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 +/// 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(); + 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 + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| panic_payload.downcast_ref::().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()) +} diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index 5b2685a..42492eb 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -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}; diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index 8ae5043..e788d88 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -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 => { + // 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", }; diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs new file mode 100644 index 0000000..3f17082 --- /dev/null +++ b/crates/kebab-tui/tests/ask.rs @@ -0,0 +1,343 @@ +//! 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, 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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .join("\n"); + assert!( + rendered.contains("explain (per-claim)"), + "explain mode panel title" + ); +} + +#[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)); +} diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index f232d49..c83d8ef 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -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(...)`. 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. + +**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`; 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. diff --git a/tasks/p9/p9-3-tui-ask.md b/tasks/p9/p9-3-tui-ask.md index bea3e29..baae260 100644 --- a/tasks/p9/p9-3-tui-ask.md +++ b/tasks/p9/p9-3-tui-ask.md @@ -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 -- 2.49.1 From ad7bd7d309064eebf8070a6655f69354f2673ac5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 15:27:39 +0000 Subject: [PATCH 2/2] =?UTF-8?q?review(p9-3):=20=ED=9A=8C=EC=B0=A8=201=20?= =?UTF-8?q?=EC=A7=80=EC=A0=81=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/kebab-tui/src/ask.rs | 23 +++++++++++++++++++++-- crates/kebab-tui/tests/ask.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs index e3c62bb..ccc034e 100644 --- a/crates/kebab-tui/src/ask.rs +++ b/crates/kebab-tui/src/ask.rs @@ -51,7 +51,18 @@ pub fn render_ask(f: &mut Frame, area: Rect, state: &App) { fn render_input(f: &mut Frame, area: Rect, s: &AskState) { let mode_badge = if s.explain { " explain" } else { "" }; - let busy = if s.streaming { " streaming…" } 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()), @@ -200,10 +211,18 @@ pub fn handle_key_ask(state: &mut App, key: KeyEvent) -> KeyOutcome { 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.input.trim().is_empty()) + .map(|s| s.streaming || s.thread.is_some() || s.input.trim().is_empty()) .unwrap_or(true) { return KeyOutcome::Continue; diff --git a/crates/kebab-tui/tests/ask.rs b/crates/kebab-tui/tests/ask.rs index 3f17082..e2d6c44 100644 --- a/crates/kebab-tui/tests/ask.rs +++ b/crates/kebab-tui/tests/ask.rs @@ -328,6 +328,41 @@ fn explain_toggle_changes_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(); -- 2.49.1