diff --git a/Cargo.lock b/Cargo.lock index a5f7f49..f48c334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3811,6 +3811,7 @@ dependencies = [ "kebab-config", "kebab-core", "ratatui", + "serde_json", "tempfile", "thiserror 2.0.18", "time", diff --git a/HANDOFF.md b/HANDOFF.md index c522200..a21f566 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ ## 한 줄 요약 -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. +P0–P5 + P6 + P7 + P9-1/2/3/4 (Library / Search / Ask / Inspect) 머지 완료. `kebab ingest` 가 markdown / image / PDF 모두 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page citation 반환. `kebab tui` 가 4 패널 (Library + Search + Ask + Inspect) 제공 — 사용자가 `?` 로 ask, `/` 로 search, Library Enter / Search `i` 로 inspect, Search `g` 로 editor jump. 다음 후보 = P9-5 (desktop tauri) 또는 보류 중인 P8 (audio) 의 시스템 dep brainstorm. ## Phase 로드맵 @@ -19,7 +19,7 @@ P0–P5 + P6 + P7 + P9-1 (Library) + P9-2 (Search) + P9-3 (Ask) 머지 완료. ` | **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 | 🟡 진행 (3/5 component — P9-1 Library + P9-2 Search + P9-3 Ask 완료, P9-4/5 예정) | +| **P9** | TUI + desktop app | `kebab-tui`, `kebab-desktop` | P5 | 🟡 진행 (4/5 component — P9-1/2/3/4 완료 [Library / Search / Ask / Inspect], P9-5 desktop 예정) | P0~P5 직렬. P6~P9 P5 이후 병렬 가능. @@ -39,6 +39,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **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\" 컨벤션 변형. 사용자가 텍스트 입력 시 모든 알파벳 정상 통과. +- **P9-4 enter_inspect helper + Search `i` 키** — spec 의 진입 경로 (Library Enter → Doc inspect, Search `i` → Chunk inspect) 를 한 helper 로 묶음. `InspectTarget` enum (`Doc(DocumentId) | Chunk(ChunkId)`), `return_to: Pane` 가 Esc 시 원래 pane 으로 복귀. `c` 키가 모든 section (metadata / provenance / blocks / spans / text / embeddings) 일괄 collapse/expand — spec 의 \"focus 기반 selective collapse\" 는 v1 단순화. ## 다음 task 후보 diff --git a/README.md b/README.md index 2dbe026..0e21075 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 패널, desktop 진행 중) 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 패널, desktop 진행 중) | | `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/Cargo.toml b/crates/kebab-tui/Cargo.toml index c73c02d..80c10d3 100644 --- a/crates/kebab-tui/Cargo.toml +++ b/crates/kebab-tui/Cargo.toml @@ -19,6 +19,7 @@ anyhow = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } time = { workspace = true } +serde_json = { workspace = true } # Korean / wide-char column width — Ratatui's `Span` truncates by chars, # not display width, so a list cell with `한` (width 2) followed by `a` # (width 1) overflows by one column without explicit width accounting. diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index c39580e..f7294d6 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -122,8 +122,49 @@ pub struct AskState { } -/// Forward-declared opaque sub-state. p9-4 fills the body. -pub struct InspectState; +/// What the Inspect pane is currently showing — owned by p9-4. +#[derive(Clone, Debug)] +pub enum InspectTarget { + Doc(kebab_core::DocumentId), + Chunk(kebab_core::ChunkId), +} + +/// Inspect pane state — owned by p9-4. +/// +/// Read-only view; data fetched on each target change via the +/// `kebab-app::inspect_*_with_config` facade (run-loop hook). +pub struct InspectState { + pub target: Option, + pub doc: Option, + pub chunk: Option, + /// Section names currently collapsed (e.g. "metadata", "provenance", + /// "blocks", "embeddings"). Toggled by `c`. + pub collapsed: std::collections::HashSet<&'static str>, + pub scroll: u16, + /// Pane the user came from — Library or Search. `Esc` returns + /// here. + pub return_to: Pane, + /// True when `target` differs from the last fetched result; the + /// run loop's idle tick services it. + pub needs_fetch: bool, + /// True while the inspect call is in flight (synchronous in v1). + pub loading: bool, +} + +impl Default for InspectState { + fn default() -> Self { + Self { + target: None, + doc: None, + chunk: None, + collapsed: std::collections::HashSet::new(), + scroll: 0, + return_to: Pane::Library, + needs_fetch: false, + loading: false, + } + } +} /// TUI application. The shell that p9-1 stands up; later p9-* tasks /// add panes by populating their `Option<*State>` slot. diff --git a/crates/kebab-tui/src/inspect.rs b/crates/kebab-tui/src/inspect.rs new file mode 100644 index 0000000..6015f19 --- /dev/null +++ b/crates/kebab-tui/src/inspect.rs @@ -0,0 +1,506 @@ +//! Inspect pane (P9-4). +//! +//! Read-only view of a `CanonicalDocument` (entered from Library +//! `Enter`) or a `Chunk` (entered from Search `i`). Sections +//! (metadata / provenance / blocks / embeddings) are collapsible +//! via `c`. `Esc` returns to the originating pane. +//! +//! Spec deviation (HOTFIXES `2026-05-02 P9-4`): +//! - `render_inspect` generic dropped (ratatui 0.28 Frame +//! is backend-agnostic — same as P9-1 / P9-2 / P9-3). +//! - Search pane now exposes `i` to enter chunk inspect (spec says +//! "from Search pressing `i`"); previously Search had no `i` — +//! added in p9-2's handler module since this PR can edit it. +//! +//! Per design §1 inspect output, §3.5 Chunk, §2.5 DocSummary, +//! §2.6 ChunkInspection. + +use crossterm::event::{KeyCode, KeyEvent}; +use kebab_core::{Block, CanonicalDocument, Chunk}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block as RBlock, Borders, Paragraph, Wrap}; + +use crate::app::{App, InspectState, InspectTarget, KeyOutcome, Pane}; + +const SECTION_METADATA: &str = "metadata"; +const SECTION_PROVENANCE: &str = "provenance"; +const SECTION_BLOCKS: &str = "blocks"; +const SECTION_EMBEDDINGS: &str = "embeddings"; +const SECTION_TEXT: &str = "text"; +const SECTION_SPANS: &str = "spans"; + +/// Render the Inspect pane. Doc target → `render_doc`, chunk target → +/// `render_chunk`. No target → empty hint. +pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) { + let Some(s) = state.inspect.as_ref() else { + f.render_widget( + RBlock::default().title("Inspect").borders(Borders::ALL), + area, + ); + return; + }; + if s.loading { + let block = RBlock::default().title("Inspect — loading…").borders(Borders::ALL); + f.render_widget(block, area); + return; + } + match (&s.target, &s.doc, &s.chunk) { + (Some(InspectTarget::Doc(_)), Some(doc), _) => render_doc(f, area, s, doc), + (Some(InspectTarget::Chunk(_)), _, Some(chunk)) => { + render_chunk(f, area, s, chunk) + } + _ => { + let block = RBlock::default() + .title("Inspect") + .borders(Borders::ALL); + let hint = Paragraph::new(Span::styled( + "(no target — return to Library and press Enter on a doc, \ + or to Search and press `i` on a hit)", + Style::default().add_modifier(Modifier::DIM), + )) + .wrap(Wrap { trim: false }); + f.render_widget(hint.block(block), area); + } + } +} + +fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocument) { + let lines = build_doc_lines(s, doc); + let block = RBlock::default() + .title(format!( + "Inspect Doc — {}", + short_id(&doc.doc_id.0) + )) + .borders(Borders::ALL); + let para = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((s.scroll, 0)); + f.render_widget(para.block(block), area); +} + +fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk) { + let lines = build_chunk_lines(s, chunk); + let block = RBlock::default() + .title(format!( + "Inspect Chunk — {}", + short_id(&chunk.chunk_id.0) + )) + .borders(Borders::ALL); + let para = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((s.scroll, 0)); + f.render_widget(para.block(block), area); +} + +/// Build the wrapped Lines for a doc inspect view. Pure function so +/// snapshot tests can compare a stable prefix of lines. +pub(crate) fn build_doc_lines<'a>( + s: &InspectState, + doc: &'a CanonicalDocument, +) -> Vec> { + let mut lines: Vec = Vec::new(); + // Header + lines.push(header_kv("title", &doc.title)); + lines.push(header_kv("doc_path", &doc.workspace_path.0)); + lines.push(header_kv("doc_id", &doc.doc_id.0)); + lines.push(header_kv("lang", &doc.lang.0)); + lines.push(header_kv( + "source_type", + &format!("{:?}", doc.metadata.source_type).to_lowercase(), + )); + lines.push(header_kv( + "trust_level", + &format!("{:?}", doc.metadata.trust_level).to_lowercase(), + )); + lines.push(header_kv("parser_version", &doc.parser_version.0)); + lines.push(blank()); + + // metadata + push_section_header(&mut lines, SECTION_METADATA, s); + if !s.collapsed.contains(SECTION_METADATA) { + lines.push(kv("aliases", &format!("{:?}", doc.metadata.aliases))); + lines.push(kv("tags", &format!("{:?}", doc.metadata.tags))); + lines.push(kv("created_at", &fmt_dt(&doc.metadata.created_at))); + lines.push(kv("updated_at", &fmt_dt(&doc.metadata.updated_at))); + // user metadata pretty-printed JSON + if let Ok(pretty) = + serde_json::to_string_pretty(&serde_json::Value::Object( + doc.metadata.user.clone(), + )) + { + for line in pretty.lines() { + lines.push(Line::from(format!(" {line}"))); + } + } + lines.push(blank()); + } + + // provenance + push_section_header(&mut lines, SECTION_PROVENANCE, s); + if !s.collapsed.contains(SECTION_PROVENANCE) { + if doc.provenance.events.is_empty() { + lines.push(Line::from(Span::styled( + " (no events)", + Style::default().add_modifier(Modifier::DIM), + ))); + } else { + for ev in &doc.provenance.events { + let kind = format!("{:?}", ev.kind).to_lowercase(); + let note = ev.note.as_deref().unwrap_or(""); + lines.push(Line::from(format!( + " [{}] {} — {}{}{}", + fmt_dt(&ev.at), + ev.agent, + kind, + if note.is_empty() { "" } else { ": " }, + note, + ))); + } + } + lines.push(blank()); + } + + // blocks + push_section_header(&mut lines, SECTION_BLOCKS, s); + lines.push(Line::from(format!( + " count = {}", + doc.blocks.len() + ))); + if !s.collapsed.contains(SECTION_BLOCKS) { + let preview_n = 16.min(doc.blocks.len()); + for (i, b) in doc.blocks.iter().take(preview_n).enumerate() { + lines.push(Line::from(format!( + " [{i}] {}", + describe_block(b) + ))); + } + if doc.blocks.len() > preview_n { + lines.push(Line::from(Span::styled( + format!(" … +{} more", doc.blocks.len() - preview_n), + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + lines +} + +pub(crate) fn build_chunk_lines<'a>( + s: &InspectState, + chunk: &'a Chunk, +) -> Vec> { + let mut lines: Vec = Vec::new(); + // Header + lines.push(header_kv("chunk_id", &chunk.chunk_id.0)); + lines.push(header_kv("doc_id", &chunk.doc_id.0)); + lines.push(header_kv( + "heading_path", + &if chunk.heading_path.is_empty() { + "-".to_string() + } else { + chunk.heading_path.join(" / ") + }, + )); + lines.push(header_kv("chunker_version", &chunk.chunker_version.0)); + lines.push(header_kv("policy_hash", &chunk.policy_hash)); + lines.push(header_kv( + "token_estimate", + &chunk.token_estimate.to_string(), + )); + lines.push(blank()); + + // source spans + push_section_header(&mut lines, SECTION_SPANS, s); + if !s.collapsed.contains(SECTION_SPANS) { + if chunk.source_spans.is_empty() { + lines.push(Line::from(Span::styled( + " (no spans)", + Style::default().add_modifier(Modifier::DIM), + ))); + } else { + for span in &chunk.source_spans { + lines.push(Line::from(format!(" {}", describe_span(span)))); + } + } + lines.push(blank()); + } + + // text + push_section_header(&mut lines, SECTION_TEXT, s); + if !s.collapsed.contains(SECTION_TEXT) { + for line in chunk.text.lines() { + lines.push(Line::from(format!(" {line}"))); + } + if chunk.text.is_empty() { + lines.push(Line::from(Span::styled( + " (empty)", + Style::default().add_modifier(Modifier::DIM), + ))); + } + lines.push(blank()); + } + + // embeddings (block_ids serve as the lightweight provenance — + // full embedding records require an extra facade call out of v1 + // scope; spec § Out of scope: \"Embedding inspection beyond + // listing model identity\") + push_section_header(&mut lines, SECTION_EMBEDDINGS, s); + if !s.collapsed.contains(SECTION_EMBEDDINGS) { + lines.push(Line::from(Span::styled( + " (embedding records not loaded — out of v1 scope)", + Style::default().add_modifier(Modifier::DIM), + ))); + lines.push(Line::from(format!( + " block_ids = {}", + chunk.block_ids.len() + ))); + for bid in &chunk.block_ids { + lines.push(Line::from(format!(" {}", bid.0))); + } + } + lines +} + +fn header_kv(k: &str, v: &str) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{k:>16}: "), + Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan), + ), + Span::raw(v.to_string()), + ]) +} + +fn kv(k: &str, v: &str) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!(" {k}: "), + Style::default().add_modifier(Modifier::DIM), + ), + Span::raw(v.to_string()), + ]) +} + +fn blank() -> Line<'static> { + Line::from("") +} + +fn push_section_header(lines: &mut Vec>, name: &'static str, s: &InspectState) { + let collapsed = s.collapsed.contains(name); + let marker = if collapsed { "▸" } else { "▾" }; + lines.push(Line::from(Span::styled( + format!("{marker} {name}"), + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Yellow), + ))); +} + +fn fmt_dt(dt: &time::OffsetDateTime) -> String { + dt.format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "?".into()) +} + +fn short_id(id: &str) -> String { + if id.len() > 12 { + format!("{}…", &id[..12]) + } else { + id.to_string() + } +} + +fn describe_block(b: &Block) -> String { + match b { + Block::Heading(h) => format!("Heading L{}: {:?}", h.level, h.text), + Block::Paragraph(p) => { + let snippet = p.text.lines().next().unwrap_or(""); + let trimmed = if snippet.chars().count() > 60 { + format!("{}…", snippet.chars().take(60).collect::()) + } else { + snippet.to_string() + }; + format!("Paragraph: {trimmed}") + } + Block::Quote(q) => format!("Quote: {} chars", q.text.len()), + Block::List(l) => format!( + "List {} ({} items)", + if l.ordered { "ordered" } else { "unordered" }, + l.items.len() + ), + Block::Code(c) => format!( + "Code [{}]: {} bytes", + c.lang.as_deref().unwrap_or("?"), + c.code.len() + ), + Block::Table(t) => format!( + "Table: {} cols × {} rows", + t.headers.len(), + t.rows.len() + ), + Block::ImageRef(i) => format!( + "ImageRef: src={} alt={:?} ocr={}", + i.src, + i.alt, + if i.ocr.is_some() { "Y" } else { "N" } + ), + Block::AudioRef(a) => format!( + "AudioRef: asset_id={} duration_ms={}", + a.asset_id.0, a.duration_ms + ), + } +} + +fn describe_span(span: &kebab_core::SourceSpan) -> String { + use kebab_core::SourceSpan; + match span { + SourceSpan::Line { start, end } => format!("Line {start}-{end}"), + SourceSpan::Byte { start, end } => format!("Byte {start}-{end}"), + SourceSpan::Page { + page, + char_start, + char_end, + } => match (char_start, char_end) { + (Some(s), Some(e)) => format!("Page {page} (chars {s}-{e})"), + _ => format!("Page {page}"), + }, + SourceSpan::Region { x, y, w, h } => { + format!("Region xywh={x},{y},{w},{h}") + } + SourceSpan::Time { start_ms, end_ms } => { + format!("Time {start_ms}-{end_ms} ms") + } + } +} + +/// Inspect pane key dispatch. +pub fn handle_key_inspect(state: &mut App, key: KeyEvent) -> KeyOutcome { + if state.error_overlay.is_some() { + state.error_overlay = None; + return KeyOutcome::Continue; + } + let Some(s) = state.inspect.as_mut() else { + return KeyOutcome::SwitchPane(Pane::Library); + }; + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => KeyOutcome::SwitchPane(s.return_to), + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { + s.scroll = s.scroll.saturating_add(1); + KeyOutcome::Continue + } + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { + s.scroll = s.scroll.saturating_sub(1); + KeyOutcome::Continue + } + (KeyCode::PageDown, _) => { + s.scroll = s.scroll.saturating_add(10); + KeyOutcome::Continue + } + (KeyCode::PageUp, _) => { + s.scroll = s.scroll.saturating_sub(10); + KeyOutcome::Continue + } + (KeyCode::Char('c'), _) => { + // Toggle all sections at once. v1 simplification per spec + // ("focus is implicit by current scroll position; v1 may + // simplify by toggling all sections"). + toggle_all_sections(s); + KeyOutcome::Continue + } + _ => KeyOutcome::Continue, + } +} + +fn toggle_all_sections(s: &mut InspectState) { + let candidates: &[&'static str] = &[ + SECTION_METADATA, + SECTION_PROVENANCE, + SECTION_BLOCKS, + SECTION_EMBEDDINGS, + SECTION_TEXT, + SECTION_SPANS, + ]; + let any_collapsed = candidates.iter().any(|n| s.collapsed.contains(*n)); + if any_collapsed { + // Some collapsed → expand all. + s.collapsed.clear(); + } else { + // None collapsed → collapse all. + for &name in candidates { + s.collapsed.insert(name); + } + } +} + +/// Run-loop hook: fetch doc / chunk for the current target if +/// `needs_fetch`. Synchronous (v1). +pub(crate) fn refresh_inspect(state: &mut App) -> anyhow::Result<()> { + let cfg = state.config.clone(); + let target = { + let s = state.inspect.as_ref().expect("inspect slot must exist"); + if !s.needs_fetch { + return Ok(()); + } + s.target.clone() + }; + let Some(target) = target else { + let s = state.inspect.as_mut().unwrap(); + s.needs_fetch = false; + return Ok(()); + }; + + { + let s = state.inspect.as_mut().unwrap(); + s.loading = true; + } + + match target { + InspectTarget::Doc(doc_id) => { + let result = kebab_app::inspect_doc_with_config(cfg, &doc_id); + let s = state.inspect.as_mut().unwrap(); + s.loading = false; + s.needs_fetch = false; + match result { + Ok(doc) => { + s.doc = Some(doc); + s.chunk = None; + s.scroll = 0; + } + Err(e) => return Err(e), + } + } + InspectTarget::Chunk(chunk_id) => { + let result = kebab_app::inspect_chunk_with_config(cfg, &chunk_id); + let s = state.inspect.as_mut().unwrap(); + s.loading = false; + s.needs_fetch = false; + match result { + Ok(chunk) => { + s.chunk = Some(chunk); + s.doc = None; + s.scroll = 0; + } + Err(e) => return Err(e), + } + } + } + Ok(()) +} + +/// Helper used by Library / Search panes to enter Inspect with a +/// specific target. Sets `needs_fetch` so the run-loop tick +/// services the `kebab-app::inspect_*` call. +pub fn enter_inspect(state: &mut App, target: InspectTarget, return_to: Pane) { + if state.inspect.is_none() { + state.inspect = Some(InspectState::default()); + } + let s = state.inspect.as_mut().unwrap(); + s.target = Some(target); + s.return_to = return_to; + s.needs_fetch = true; + s.doc = None; + s.chunk = None; + s.scroll = 0; + s.collapsed.clear(); +} diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs index 42492eb..e97c496 100644 --- a/crates/kebab-tui/src/lib.rs +++ b/crates/kebab-tui/src/lib.rs @@ -15,13 +15,17 @@ mod app; mod ask; mod error_popup; +mod inspect; mod library; mod run; mod search; mod terminal; -pub use app::{App, AskState, InspectState, KeyOutcome, LibraryState, Pane, SearchState}; +pub use app::{ + App, AskState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane, SearchState, +}; pub use ask::{handle_key_ask, render_ask}; pub use error_popup::{ErrorOverlay, render_error_overlay}; +pub use inspect::{enter_inspect, handle_key_inspect, render_inspect}; 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/library.rs b/crates/kebab-tui/src/library.rs index ea74534..1a33c58 100644 --- a/crates/kebab-tui/src/library.rs +++ b/crates/kebab-tui/src/library.rs @@ -279,6 +279,15 @@ pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome { if inner.docs.is_empty() { KeyOutcome::Continue } else { + let idx = inner.list_state.selected().unwrap_or(0); + // Capture doc_id and exit the `inner` borrow scope + // before re-borrowing `state` for `enter_inspect`. + let doc_id = inner.docs[idx].doc_id.clone(); + // NLL releases the `inner` borrow at last use above; + // we can re-borrow `state` mutably for the inspect-side + // mutation below. + let target = crate::app::InspectTarget::Doc(doc_id); + crate::inspect::enter_inspect(state, target, Pane::Library); KeyOutcome::SwitchPane(Pane::Inspect) } } diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index e788d88..91e5152 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -10,9 +10,10 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use std::time::Duration; -use crate::app::{App, AskState, KeyOutcome, Pane, SearchState}; +use crate::app::{App, AskState, InspectState, 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::inspect::{handle_key_inspect, refresh_inspect, render_inspect}; use crate::library::{handle_key_library, refresh_docs, render_library}; use crate::search::{ debounce_due, fire_search, handle_key_search, refresh_preview, render_search, @@ -69,6 +70,18 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { drain_stream(app); poll_worker(app); } + Pane::Inspect => { + let due = app + .inspect + .as_ref() + .map(|s| s.needs_fetch) + .unwrap_or(false); + if due { + if let Err(e) = refresh_inspect(app) { + app.error_overlay = Some(ErrorOverlay::from_anyhow(&e)); + } + } + } _ => {} } } @@ -82,12 +95,10 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { Pane::Library => handle_key_library(app, key), Pane::Search => handle_key_search(app, key), 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::Inspect | Pane::Jobs => { - handle_key_unimplemented_pane(app, key) - } + Pane::Inspect => handle_key_inspect(app, key), + // p9-5 (Jobs) plugs its handler here when it + // lands. Until then, accepts only `q` / `Esc`. + Pane::Jobs => handle_key_unimplemented_pane(app, key), }; match outcome { KeyOutcome::Quit => app.should_quit = true, @@ -100,6 +111,9 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> { if p == Pane::Ask && app.ask.is_none() { app.ask = Some(AskState::default()); } + if p == Pane::Inspect && app.inspect.is_none() { + app.inspect = Some(InspectState::default()); + } } KeyOutcome::Refresh => { // Library uses needs_refresh; Search uses @@ -148,10 +162,9 @@ fn render_root(f: &mut Frame, app: &App) { Pane::Library => render_library(f, outer[1], app), Pane::Search => render_search(f, outer[1], app), 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), + Pane::Inspect => render_inspect(f, outer[1], app), + // p9-5 Jobs not yet rendered; Library placeholder. + Pane::Jobs => render_library(f, outer[1], app), } render_footer(f, outer[2], app); if let Some(err) = &app.error_overlay { @@ -189,7 +202,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 => "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::Inspect => "j/k=scroll PgUp/PgDn=page scroll c=collapse/expand sections Esc/q=back", Pane::Jobs => "Jobs pane not yet implemented — q to return", }; let line = Line::from(Span::styled( diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index a78fd68..dde8510 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -181,6 +181,32 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { // workspace_root after dropping the `&mut state.search` borrow. // Handle it as a pre-pass so the rest of the function can use // `state.search.as_mut()` without scope juggling. + // `i` (chunk inspect) — pre-pass like `g`. Only fires on plain + // press, so typing 'i' in queries like "instance" still reaches + // the input buffer (P9-2 SHIFT/none convention). + if matches!( + (key.code, key.modifiers), + (KeyCode::Char('i'), KeyModifiers::NONE) + ) { + let chunk_id = { + let s = state.search.as_ref().unwrap(); + if s.hits.is_empty() { + None + } else { + Some(s.hits[s.selected_hit].chunk_id.clone()) + } + }; + if let Some(chunk_id) = chunk_id { + crate::inspect::enter_inspect( + state, + crate::app::InspectTarget::Chunk(chunk_id), + Pane::Search, + ); + return KeyOutcome::SwitchPane(Pane::Inspect); + } + return KeyOutcome::Continue; + } + // `g` only fires the editor jump on plain (no-modifier) press — // SHIFT-G in vim land is "go to bottom" (not implemented here), // and CTRL/ALT chords stay reserved. diff --git a/crates/kebab-tui/tests/inspect.rs b/crates/kebab-tui/tests/inspect.rs new file mode 100644 index 0000000..a033acb --- /dev/null +++ b/crates/kebab-tui/tests/inspect.rs @@ -0,0 +1,326 @@ +//! Unit + snapshot tests for the Inspect pane (P9-4). +//! +//! Tests bypass the facade fetch by hand-populating `InspectState.doc` +//! / `state.chunk`. The fetch path itself is exercised end-to-end by +//! manual smoke (TempDir KB). + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use kebab_config::Config; +use kebab_core::{ + AssetId, Block, BlockId, CanonicalDocument, Chunk, ChunkId, ChunkerVersion, CommonBlock, + DocumentId, HeadingBlock, Inline, Lang, Metadata, ParserVersion, Provenance, + ProvenanceEvent, ProvenanceKind, SourceSpan, SourceType, TextBlock, TrustLevel, + WorkspacePath, +}; +use kebab_tui::{ + App, InspectState, InspectTarget, KeyOutcome, Pane, handle_key_inspect, render_inspect, +}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::layout::Rect; +use std::path::PathBuf; +use time::OffsetDateTime; + +fn fresh_app() -> App { + let mut config = Config::defaults(); + config.storage.data_dir = "/tmp/kebab-tui-inspect-tests-noop".to_string(); + config.workspace.root = "/tmp/kebab-tui-inspect-tests-noop/workspace".to_string(); + let mut app = App::new(config).expect("App::new"); + app.focus = Pane::Inspect; + app.inspect = Some(InspectState::default()); + app +} + +fn make_doc() -> CanonicalDocument { + let doc_id = DocumentId("d".repeat(32)); + let asset_id = AssetId("a".repeat(32)); + let span1 = SourceSpan::Line { start: 1, end: 1 }; + let span2 = SourceSpan::Line { start: 2, end: 5 }; + let common1 = CommonBlock { + block_id: BlockId("b".repeat(32)), + heading_path: vec![], + source_span: span1, + }; + let common2 = CommonBlock { + block_id: BlockId("c".repeat(32)), + heading_path: vec!["Top".into()], + source_span: span2, + }; + let blocks = vec![ + Block::Heading(HeadingBlock { + common: common1, + level: 1, + text: "Top".into(), + }), + Block::Paragraph(TextBlock { + common: common2, + text: "first paragraph body line.".into(), + inlines: vec![Inline::Text { + text: "first paragraph body line.".into(), + }], + }), + ]; + let mut user = serde_json::Map::new(); + user.insert("custom_key".into(), serde_json::Value::String("custom_val".into())); + + CanonicalDocument { + doc_id, + source_asset_id: asset_id, + workspace_path: WorkspacePath::new("notes/test.md".into()).unwrap(), + title: "Test Doc".into(), + lang: Lang("en".into()), + blocks, + metadata: Metadata { + aliases: vec!["alias1".into()], + tags: vec!["tag-a".into(), "tag-b".into()], + created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), + updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_500).unwrap(), + source_type: SourceType::Note, + trust_level: TrustLevel::Primary, + user_id_alias: None, + user, + }, + provenance: Provenance { + events: vec![ProvenanceEvent { + at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), + agent: "kb-source-fs".into(), + kind: ProvenanceKind::Discovered, + note: None, + }], + }, + parser_version: ParserVersion("test-parser".into()), + schema_version: 1, + doc_version: 1, + } +} + +fn make_chunk() -> Chunk { + Chunk { + chunk_id: ChunkId("e".repeat(32)), + doc_id: DocumentId("d".repeat(32)), + block_ids: vec![BlockId("b".repeat(32)), BlockId("c".repeat(32))], + text: "chunk body line one.\nchunk body line two.".into(), + heading_path: vec!["Top".into(), "Sub".into()], + source_spans: vec![SourceSpan::Line { start: 1, end: 5 }], + token_estimate: 12, + chunker_version: ChunkerVersion("md-heading-v1".into()), + policy_hash: "deadbeefdeadbeef".into(), + } +} + +fn render_to_string(app: &App, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, w, h); + render_inspect(f, area, app); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn esc_returns_to_recorded_pane() { + let mut app = fresh_app(); + { + let s = app.inspect.as_mut().unwrap(); + s.return_to = Pane::Search; + } + let outcome = handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Search)); +} + +#[test] +fn q_also_returns() { + let mut app = fresh_app(); + let outcome = handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library)); +} + +#[test] +fn j_k_scroll_within_bounds_no_panic() { + let mut app = fresh_app(); + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!(app.inspect.as_ref().unwrap().scroll, 1); + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!(app.inspect.as_ref().unwrap().scroll, 0); + // Underflow saturates at 0 + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!(app.inspect.as_ref().unwrap().scroll, 0); +} + +#[test] +fn page_keys_scroll_by_ten() { + let mut app = fresh_app(); + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + ); + assert_eq!(app.inspect.as_ref().unwrap().scroll, 10); + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + ); + assert_eq!(app.inspect.as_ref().unwrap().scroll, 0); +} + +#[test] +fn c_toggles_collapse_state() { + let mut app = fresh_app(); + // First press: nothing collapsed → collapse all. + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ); + let s = app.inspect.as_ref().unwrap(); + assert!(!s.collapsed.is_empty(), "first c collapses all"); + // Second press: some collapsed → expand all. + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ); + let s = app.inspect.as_ref().unwrap(); + assert!(s.collapsed.is_empty(), "second c expands all"); +} + +#[test] +fn no_target_renders_hint_without_panic() { + let app = fresh_app(); + let rendered = render_to_string(&app, 80, 20); + assert!(rendered.contains("Inspect"), "header visible"); + assert!( + rendered.contains("no target") || rendered.contains("press Enter"), + "hint visible: {rendered}" + ); +} + +#[test] +fn loading_state_renders_loading_message() { + let mut app = fresh_app(); + { + let s = app.inspect.as_mut().unwrap(); + s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32)))); + s.loading = true; + } + let rendered = render_to_string(&app, 80, 10); + assert!(rendered.contains("loading"), "loading hint: {rendered}"); +} + +#[test] +fn doc_view_renders_header_and_metadata() { + let mut app = fresh_app(); + { + let s = app.inspect.as_mut().unwrap(); + s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32)))); + s.doc = Some(make_doc()); + } + let rendered = render_to_string(&app, 100, 40); + assert!(rendered.contains("Test Doc"), "title rendered"); + assert!(rendered.contains("notes/test.md"), "doc_path rendered"); + assert!(rendered.contains("test-parser"), "parser_version rendered"); + assert!(rendered.contains("metadata"), "metadata section visible"); + assert!(rendered.contains("tag-a"), "tags rendered"); + assert!( + rendered.contains("custom_key") || rendered.contains("custom_val"), + "user metadata pretty-printed" + ); + assert!(rendered.contains("provenance"), "provenance section visible"); + assert!(rendered.contains("kb-source-fs"), "agent rendered"); + assert!(rendered.contains("blocks"), "blocks section visible"); + assert!(rendered.contains("Heading L1"), "block describe rendered"); +} + +#[test] +fn doc_view_collapse_hides_section_body() { + let mut app = fresh_app(); + { + let s = app.inspect.as_mut().unwrap(); + s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32)))); + s.doc = Some(make_doc()); + } + let pre = render_to_string(&app, 100, 30); + assert!(pre.contains("kb-source-fs"), "before collapse"); + handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ); + let post = render_to_string(&app, 100, 30); + assert!(post.contains("metadata"), "section header still visible"); + assert!( + !post.contains("kb-source-fs"), + "provenance body hidden after collapse: {post}" + ); +} + +#[test] +fn chunk_view_renders_text_and_block_ids() { + let mut app = fresh_app(); + { + let s = app.inspect.as_mut().unwrap(); + s.target = Some(InspectTarget::Chunk(ChunkId("e".repeat(32)))); + s.chunk = Some(make_chunk()); + } + let rendered = render_to_string(&app, 100, 40); + assert!(rendered.contains("md-heading-v1"), "chunker_version rendered"); + assert!(rendered.contains("Top / Sub"), "heading_path joined"); + assert!(rendered.contains("Line 1-5"), "source span described"); + assert!(rendered.contains("chunk body line one"), "text body rendered"); + assert!( + rendered.contains("block_ids = 2"), + "block_id count rendered" + ); +} + +#[test] +fn no_inspect_state_returns_to_library() { + let mut config = Config::defaults(); + config.storage.data_dir = "/tmp/kebab-tui-inspect-tests-noop".into(); + let mut app = App::new(config).unwrap(); + app.focus = Pane::Inspect; + let outcome = handle_key_inspect( + &mut app, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library)); +} + +#[test] +fn enter_inspect_helper_sets_target_and_marks_fetch() { + let mut app = fresh_app(); + app.inspect = None; // simulate cold state + kebab_tui::enter_inspect( + &mut app, + InspectTarget::Doc(DocumentId("d".repeat(32))), + Pane::Library, + ); + let s = app.inspect.as_ref().unwrap(); + assert!(matches!(s.target, Some(InspectTarget::Doc(_)))); + assert_eq!(s.return_to, Pane::Library); + assert!(s.needs_fetch); + assert!(s.doc.is_none()); + let _ = PathBuf::from(""); // silence unused-import in some configs +} diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index c83d8ef..d30508f 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,29 @@ 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-4 TUI Inspect: render_inspect generic + Search `i` entry + collapse simplification + +**Discovered**: P9-4 implementation start. + +**Symptom 1 (cosmetic)**: Same shape as P9-1/2/3 — `tasks/p9/p9-4-tui-inspect.md` § Public surface declares `render_inspect(...)`. ratatui 0.28's `Frame` is backend-agnostic; the generic is unused. + +**Symptom 2 (load-bearing)**: Spec § Behavior contract names `Search pressing 'i' (new key on Search pane) passes Chunk(selected_hit.chunk_id)` — but P9-2 (already merged) didn't include `i`. The Inspect entry from Search has to be wired retroactively. + +**Symptom 3 (simplification)**: Spec § Behavior contract section on collapse: "focus is implicit by current scroll position; v1 may simplify by toggling all sections". Implementation takes the v1 path — `c` toggles all six sections (metadata / provenance / blocks / spans / text / embeddings) at once. Per-section focus is a P+ enhancement. + +**Fix**: +- `render_inspect(f: &mut Frame, area: Rect, state: &App)` — no generic. +- New helper `kebab_tui::enter_inspect(state, target, return_to)` lifted out of pane handlers so both Library `Enter` and Search `i` use the same code path. +- Search pane gains `i` keybinding (pre-pass like `g`, plain modifier only — typing `i` in queries still reaches input). Esc returns the user to the originating pane stored in `return_to`. +- `InspectState.collapsed: HashSet<&'static str>` records collapsed section names. `c` flips all-collapsed ↔ all-expanded based on whether any are currently collapsed. +- `q` joins `Esc` as the back key (Inspect is the only read-only terminal pane in v1, so `q` is unambiguous). + +**Trust note**: Embedding inspection is intentionally left as "(not loaded — out of v1 scope)" per spec § Out of scope. The full embedding-record fetch would require an extra facade method (`kebab-app::inspect_embedding`) that is not in the P5/P6/P7 facade surface. P+ task. + +**Amends**: +- tasks/p9/p9-4-tui-inspect.md (`render_inspect` non-generic; collapse simplification; entry helper). +- tasks/p9/p9-2-tui-search.md (Search pane gains `i` for chunk inspect — was not in original p9-2 spec). + ## 2026-05-02 — P9-3 TUI Ask: render_ask generic + command-vs-insert key disambiguation **Discovered**: P9-3 implementation start. diff --git a/tasks/p9/p9-4-tui-inspect.md b/tasks/p9/p9-4-tui-inspect.md index e92dad8..b56da8f 100644 --- a/tasks/p9/p9-4-tui-inspect.md +++ b/tasks/p9/p9-4-tui-inspect.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-tui (inspect pane) task_id: p9-4 title: "TUI Inspect pane: document & chunk detail render" -status: planned +status: completed depends_on: [p1-6, p9-1] unblocks: [] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md