feat(kebab-tui): P9-4 Inspect pane #46
1
Cargo.lock
generated
@@ -3811,6 +3811,7 @@ dependencies = [
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"ratatui",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
|
||||
@@ -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<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\" 컨벤션 변형. 사용자가 텍스트 입력 시 모든 알파벳 정상 통과.
|
||||
- **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 후보
|
||||
|
||||
|
||||
@@ -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 <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 패널, desktop 진행 중) |
|
||||
| `kebab eval run / compare` | golden query 회귀 측정 |
|
||||
|
||||
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<InspectTarget>,
|
||||
pub doc: Option<kebab_core::CanonicalDocument>,
|
||||
pub chunk: Option<kebab_core::Chunk>,
|
||||
/// 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.
|
||||
|
||||
524
crates/kebab-tui/src/inspect.rs
Normal file
@@ -0,0 +1,524 @@
|
||||
//! 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<B: Backend>` 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<Line<'a>> {
|
||||
let mut lines: Vec<Line> = 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 — section header carries the count inline so a
|
||||
// collapsed view still reports "how many" without leaking
|
||||
// body lines (R1 review: count must collapse with the rest).
|
||||
push_section_header_with_count(
|
||||
&mut lines,
|
||||
SECTION_BLOCKS,
|
||||
s,
|
||||
Some(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<Line<'a>> {
|
||||
let mut lines: Vec<Line> = 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 — section header carries the block_id count inline
|
||||
// (spec § Out of scope: full embedding records lookup is P+).
|
||||
push_section_header_with_count(
|
||||
&mut lines,
|
||||
SECTION_EMBEDDINGS,
|
||||
s,
|
||||
Some(chunk.block_ids.len()),
|
||||
);
|
||||
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),
|
||||
)));
|
||||
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<Line<'static>>, name: &'static str, s: &InspectState) {
|
||||
push_section_header_with_count(lines, name, s, None);
|
||||
}
|
||||
|
||||
/// Section header + optional inline count. Inline-count form is used
|
||||
/// where a collapsed section should still report \"how many\" — see
|
||||
/// blocks / embeddings.
|
||||
fn push_section_header_with_count(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
name: &'static str,
|
||||
s: &InspectState,
|
||||
count: Option<usize>,
|
||||
) {
|
||||
let collapsed = s.collapsed.contains(name);
|
||||
let marker = if collapsed { "▸" } else { "▾" };
|
||||
let title = match count {
|
||||
Some(n) => format!("{marker} {name} ({n})"),
|
||||
None => format!("{marker} {name}"),
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
title,
|
||||
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 {
|
||||
|
claude-reviewer-01
commented
(칭찬) blocks 섹션 의 count 가 section header 에 inline. 사용자가 collapsed 상태에서 "이 doc 가 몇 개 block 이 있나" 답을 헤더에서 즉시 얻음 — collapse 의 의도 (스캔 없이 overview) 정확히 충족. (칭찬) blocks 섹션 의 count 가 section header 에 inline. 사용자가 collapsed 상태에서 "이 doc 가 몇 개 block 이 있나" 답을 헤더에서 즉시 얻음 — collapse 의 의도 (스캔 없이 overview) 정확히 충족.
|
||||
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::<String>())
|
||||
} 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, _) => {
|
||||
|
claude-reviewer-01
commented
(issue / collapse 작동 안 함) 같은 패턴 — How to apply: (issue / collapse 작동 안 함) 같은 패턴 — `block_ids = N` 라인이 collapse 검사 *안* 에 있지만 `(embedding records not loaded — out of v1 scope)` 라인 자체가 항상 collapse 안에서만 보이고 헤더는 별개. 그런데 위 issue 와 일관성 위해 한번에 정리: SECTION_EMBEDDINGS 도 같은 패턴 (header 에 카운트 inline) 이면 모든 섹션이 동일 모양.
How to apply: `▾ embeddings (block_ids=2)` 같은 inline count. body 는 "(records not loaded)" + each block_id list 만.
|
||||
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;
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `push_section_header_with_count` helper 가 두 use case (count 있음 / 없음) 를 한 함수로 통합. metadata / provenance / spans / text 는 count 없이 (None), blocks / embeddings 는 count inline. 호출 site 모두 readable + 일관.
|
||||
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();
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
claude-reviewer-01
commented
(칭찬) Library Enter 에서 (칭찬) Library Enter 에서 `enter_inspect` 호출 + SwitchPane(Inspect). NLL 코멘트 가 "왜 inner 별도 drop 없이 state 재차용 가능한지" 명시 — 미래 reader 가 borrow checker 마술이라고 오해할 위험 차단. 한 줄짜리 코멘트 가 큰 가치.
|
||||
KeyOutcome::SwitchPane(Pane::Inspect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `i` 키 가 `g` 키 와 같은 "plain modifier only" 컨벤션 적용. 사용자가 "instance" / "interface" 같은 검색어 입력 시 첫 'i' 가 input 으로 가게 — P9-2 의 SHIFT/none 분리 패턴 일관 유지.
|
||||
}
|
||||
};
|
||||
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.
|
||||
|
||||
335
crates/kebab-tui/tests/inspect.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
//! 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::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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");
|
||||
|
claude-reviewer-01
commented
(칭찬) collapse 회귀 테스트가 (1) "blocks (2)" inline count 표시 (2) Heading L1 body 숨김 (3) provenance kb-source-fs 숨김 세 invariant 모두 검증. 미래에 누군가 count 라인을 다시 collapse 검사 밖으로 옮기면 즉시 빨개짐. (칭찬) collapse 회귀 테스트가 (1) "blocks (2)" inline count 표시 (2) Heading L1 body 숨김 (3) provenance kb-source-fs 숨김 세 invariant 모두 검증. 미래에 누군가 count 라인을 다시 collapse 검사 밖으로 옮기면 즉시 빨개짐.
|
||||
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");
|
||||
assert!(pre.contains("Heading L1"), "blocks body 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("blocks (2)"),
|
||||
"blocks count visible inline on collapsed header: {post}"
|
||||
);
|
||||
assert!(
|
||||
!post.contains("kb-source-fs"),
|
||||
"provenance body hidden after collapse: {post}"
|
||||
);
|
||||
assert!(
|
||||
!post.contains("Heading L1"),
|
||||
"blocks body hidden after collapse (count must collapse with body): {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("embeddings (2)"),
|
||||
"block_id count rendered inline on embeddings header"
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
@@ -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<B: ratatui::backend::Backend>(...)`. ratatui 0.28's `Frame` is backend-agnostic; the generic is unused.
|
||||
|
||||
|
claude-reviewer-01
commented
(칭찬) HOTFIXES entry 가 spec 의 3 deviation 을 분리 명시 — generic / Search (칭찬) HOTFIXES entry 가 spec 의 3 deviation 을 분리 명시 — generic / Search `i` 누락 / collapse simplification. 특히 Symptom 2 (Search 가 P9-2 머지 시점에 `i` 미포함) 는 이전 task 의 spec 누락이 retroactively 잡힌 케이스 — "누가 빠뜨렸는지" 가 아니라 "누가 채워 넣었는지" 로 표현 정직.
|
||||
**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.
|
||||
|
||||
@@ -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
|
||||
|
||||
(issue / collapse 작동 안 함)
blocks섹션에서count = N표시가 collapse 검사 전 에 무조건 push 됨 — collapsed 상태에서 count 가 그대로 보임. 다른 섹션 (metadata / provenance) 은 collapse 검사 안에 모든 line 이 들어가 있어 일관성 깨짐.Why: 사용자 입장에서
c누르면 6 섹션 일괄 collapse 되는데 blocks 의 count 라인만 사라지지 않음. snapshot 테스트가 collapse 후 "section header still visible" 만 검증하고 "count line hidden" 은 검증 안 해서 issue 가 회귀 grid 에 안 잡힘.How to apply:
count = N라인을 collapse 검사 안으로 옮기거나, count 를 section header 옆에 붙임 (예:▾ blocks (N)). 후자가 정보 dense 하면서 collapse 의도 그대로.회귀 테스트도 "collapsed 상태에서 block describe 가 사라짐" 검증 추가.