feat(kebab-tui): P9-3 Ask pane #45
@@ -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<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 후보
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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<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
@@ -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(),
|
||||
|
claude-reviewer-01
commented
(칭찬) 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
|
||||
|
claude-reviewer-01
commented
(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) 가 안전 + 디버깅 친화. 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();
|
||||
|
claude-reviewer-01
commented
(칭찬) Enter 가드 가 세 조건 ( (칭찬) 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
|
||||
|
claude-reviewer-01
commented
(nit) How to apply (옵션, 본 PR scope 외): private fn (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())
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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 => {
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `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",
|
||||
};
|
||||
|
||||
378
crates/kebab-tui/tests/ask.rs
Normal 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>()
|
||||
})
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `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));
|
||||
}
|
||||
@@ -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.
|
||||
|
claude-reviewer-01
commented
(칭찬) 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
(칭찬)
AskState가JoinHandle<Result<Answer>>와Receiver<String>을 분리 보유. spawn 시 worker 가tx를 take + thread 안에서 합본 답변 반환, pane 은 stream 누적 + 최종 답변 모두 가짐. mpsctry_iter로 non-blocking drain —stream_sink가 dropped 시SendErrorswallow 되는 P4-3 동작과 정합.