feat(cli): kebab ingest progress display (p9-fb-02) + p9-fb-01 status flip

`kebab ingest` 가 진행 상황을 사용자에게 보여주는 두 surface 추가:

- **사람 모드 (TTY)**: indicatif `ProgressBar` on stderr — scan 중에는
  spinner, ScanCompleted 후 bar 로 전환, 매 asset 마다 message 갱신.
- **사람 모드 (non-TTY, CI/pipe)**: indicatif draw target 을 hidden
  으로 두고 stderr 에 한 줄씩 (`ingest: scanning`, `ingest: 1/N path`,
  `ingest: complete (...)`).
- **`--json` 모드**: stderr 비우고 stdout 에 line-delimited
  `ingest_progress.v1` JSON 을 emit. 마지막 줄은 기존
  `ingest_report.v1` 그대로 (외부 wrapper backward-compat).

구현:

- 신규 `crates/kebab-cli/src/progress.rs` — `ProgressMode::{Json,
  Human { tty }}`, `ProgressDisplay` (background thread 가 channel
  drain + 모드별 render), `now_rfc3339` helper. mode 가 무엇이든 ts
  는 wire emit 시점에 stamp.
- `crates/kebab-cli/src/wire.rs` 에 `wire_ingest_progress` 추가.
  serde tag (`kind`) 위에 `schema_version` + `ts` 두 필드 더해 spec
  §2.4a wire shape 완성.
- `Cmd::Ingest` 핸들러: mpsc channel 만들고 background thread 가
  display 돌리는 동안 main 이 `ingest_with_config_progress` 호출.
  ingest 반환 시 Sender drop → display thread 정상 종료. join 후
  최종 ingest_report 출력.
- 새 dep: `indicatif` 0.17 (TTY 전용 진행 바, non-TTY/--json 에서는
  hidden draw target).

Test:

- 3 lib unit (mode resolution + RFC 3339 round-trip).
- 3 integration (--json line-delimited / non-TTY stderr text /
  ts+kind 검증). 16 PASS 전체 회귀 0.

Plan 갱신:

- p9-fb-01: status `in_progress` → `completed` (PR #52 머지 후속).
- p9-fb-02: status `planned` → `in_progress`. 머지 후 별도 한 줄
  commit 으로 `completed` flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 19:57:02 +00:00
parent 7c6009f7e7
commit e613236d60
10 changed files with 453 additions and 5 deletions

2
Cargo.lock generated
View File

@@ -3517,6 +3517,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"indicatif",
"kebab-app",
"kebab-config",
"kebab-core",
@@ -3524,6 +3525,7 @@ dependencies = [
"kebab-tui",
"serde_json",
"tempfile",
"time",
]
[[package]]

View File

@@ -41,6 +41,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **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 단순화.
- **2026-05-02 P9 도그푸딩 후속 (p9-fb-06)** — `kebab reset --all|--data-only|--vector-only|--config-only [--yes]` 추가. TTY 가 아니면 `--yes` 필수 (silent destruction 금지). `--vector-only` 가 SQLite `embedding_records` 도 함께 truncate (off-disk Lance dir 만 wipe 시 orphan 방지). 도그푸딩 막힘 강도 1위 (수동 4 경로 `rm -rf` 부담) 해소. spec: `tasks/p9/p9-fb-06-data-reset-command.md`, plan: `docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md`.
- **2026-05-02 P9 도그푸딩 후속 (spec PR #51 + p9-fb-01 + p9-fb-02)** — `kebab ingest` 진행 표시 도입. frozen design §2.4a 신설 (wire schema `ingest_progress.v1` line-delimited streaming) + §10 의 long-running 작업 절 추가. `kebab-app::ingest_with_config_progress(.., progress: Option<Sender<IngestEvent>>)` facade 추가, 기존 `_with_config``progress=None` forwarding wrapper. CLI 가 indicatif TTY 진행 바 (stderr) / non-TTY 한 줄씩 / `--json` 모드는 line-delimited stdout. p9-fb-03 (TUI background worker) + p9-fb-04 (cancel) 가 같은 stream 위에 build.
## 다음 task 후보

View File

@@ -70,7 +70,7 @@ kebab doctor
| 명령 | 동작 |
|------|------|
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent) |
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1` |
| `kebab search --mode {lexical,vector,hybrid} "<query>"` | 검색. hybrid는 RRF fusion, citation 포함 |
| `kebab list docs` | 색인된 문서 목록 |
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
@@ -80,7 +80,7 @@ kebab doctor
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
| `kebab eval run / compare` | golden query 회귀 측정 |
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`).
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`).
## 논리 아키텍처

View File

@@ -30,6 +30,12 @@ kebab-tui = { path = "../kebab-tui" }
anyhow = { workspace = true }
serde_json = { workspace = true }
clap = { version = "4", features = ["derive"] }
# p9-fb-02: ingest progress UI.
# - TTY 사람 모드: indicatif spinner + bar (stderr).
# - --json 모드 / non-TTY: indicatif 끄고 raw line emit.
# - timestamp formatting (RFC 3339) 은 time crate.
indicatif = "0.17"
time = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -8,6 +8,7 @@ use clap::{Parser, Subcommand};
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
mod progress;
mod wire;
#[derive(Parser, Debug)]
@@ -283,7 +284,31 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
include: cfg.workspace.include.clone(),
exclude: cfg.workspace.exclude.clone(),
};
let report = kebab_app::ingest_with_config(cfg, scope, *summary_only)?;
// p9-fb-02: spawn the progress display on a background
// thread; the ingest call below holds the `Sender` end of
// the channel and emits per-step events into it. When the
// call returns, the `Sender` drops and the display thread
// sees `recv()` return Err — exits cleanly.
let mode = progress::ProgressMode::from_flags(cli.json);
let (tx, rx) = std::sync::mpsc::channel::<kebab_app::IngestEvent>();
let display_handle = std::thread::spawn(move || {
progress::ProgressDisplay::new(mode).run(rx)
});
let ingest_result = kebab_app::ingest_with_config_progress(
cfg,
scope,
*summary_only,
Some(tx),
);
// Join the display thread *before* surfacing the ingest
// outcome so the spinner / final newline is flushed
// regardless of whether ingest returned Ok or Err.
let _ = display_handle.join();
let report = ingest_result?;
if cli.json {
println!("{}", serde_json::to_string(&wire::wire_ingest(&report))?);
} else {

View File

@@ -0,0 +1,231 @@
//! `kebab ingest` progress display — consumes
//! `kebab_app::IngestEvent` and renders to one of three surfaces:
//!
//! - **TTY 사람 모드**: indicatif `ProgressBar` on stderr (spinner
//! while scanning, bar after `ScanCompleted`, message updates per
//! asset). stdout is reserved for the final `ingest_report.v1`.
//! - **non-TTY 사람 모드** (CI / pipe): indicatif uses `hidden`
//! draw target (no terminal control codes), and we emit one
//! `ingest: scanning…` / `ingest: N/M …` line per event to stderr
//! instead. CLI consumers redirecting stderr can still parse it.
//! - **`--json` 모드**: stderr stays silent; every event is dumped to
//! stdout as `ingest_progress.v1` line-delimited JSON. The final
//! `ingest_report.v1` line follows after the run completes (per
//! §2.4a backwards-compat).
//!
//! Each subprocess of the binary creates one `ProgressDisplay` and
//! drives it from a background thread that drains an
//! `mpsc::Receiver<IngestEvent>`. The thread terminates when the
//! `Sender` end is dropped (i.e. when `ingest_with_config_progress`
//! returns).
use std::io::{IsTerminal, Write};
use std::sync::mpsc::Receiver;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use kebab_app::IngestEvent;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::wire;
/// Rendering mode for `ProgressDisplay`. The mode is fixed at
/// construction — each `kebab ingest` invocation is a single mode
/// (chosen from `--json` plus `IsTerminal` detection).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProgressMode {
/// stdout = line-delimited `ingest_progress.v1`. stderr stays
/// silent for events (errors / log frames still go to stderr).
Json,
/// stdout reserved for the final report; stderr gets an indicatif
/// `ProgressBar` (TTY) or one short line per event (non-TTY).
Human { tty: bool },
}
impl ProgressMode {
/// Pick the right mode from caller flags.
pub fn from_flags(json: bool) -> Self {
if json {
Self::Json
} else {
Self::Human {
tty: std::io::stderr().is_terminal(),
}
}
}
}
/// Drains an `mpsc::Receiver<IngestEvent>` until the sender is dropped
/// and renders each event according to `mode`. Construction only —
/// kick off via [`ProgressDisplay::run`].
pub struct ProgressDisplay {
mode: ProgressMode,
bar: Option<ProgressBar>,
}
impl ProgressDisplay {
pub fn new(mode: ProgressMode) -> Self {
Self { mode, bar: None }
}
/// Block until `rx` returns `Err` (sender dropped). Renders one
/// frame per received event.
pub fn run(mut self, rx: Receiver<IngestEvent>) -> anyhow::Result<()> {
while let Ok(event) = rx.recv() {
self.handle(&event)?;
}
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
}
Ok(())
}
fn handle(&mut self, event: &IngestEvent) -> anyhow::Result<()> {
match self.mode {
ProgressMode::Json => emit_json(event),
ProgressMode::Human { tty } => self.handle_human(event, tty),
}
}
fn handle_human(&mut self, event: &IngestEvent, tty: bool) -> anyhow::Result<()> {
match event {
IngestEvent::ScanStarted { root } => {
let bar = ProgressBar::new_spinner().with_message(format!("scanning {root}"));
bar.set_draw_target(if tty {
ProgressDrawTarget::stderr()
} else {
ProgressDrawTarget::hidden()
});
bar.enable_steady_tick(std::time::Duration::from_millis(100));
self.bar = Some(bar);
if !tty {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "ingest: scanning {root}…");
}
}
IngestEvent::ScanCompleted { total } => {
if let Some(bar) = self.bar.as_mut() {
bar.disable_steady_tick();
bar.set_length(u64::from(*total));
bar.set_position(0);
bar.set_style(
ProgressStyle::with_template(
"ingest [{bar:30}] {pos}/{len} {wide_msg}",
)
.unwrap()
.progress_chars("=> "),
);
bar.set_message("");
}
if !tty {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "ingest: scan complete ({total} assets)");
}
}
IngestEvent::AssetStarted {
idx,
total,
path,
media,
} => {
if let Some(bar) = self.bar.as_ref() {
bar.set_message(format!("{media} {path}"));
}
if !tty {
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "ingest: {idx}/{total} {media} {path}");
}
}
IngestEvent::AssetFinished { idx, .. } => {
if let Some(bar) = self.bar.as_ref() {
bar.set_position(u64::from(*idx));
}
}
IngestEvent::Completed { counts } => {
if let Some(bar) = self.bar.take() {
bar.finish_and_clear();
}
if !tty {
let mut err = std::io::stderr().lock();
let _ = writeln!(
err,
"ingest: complete (scanned={} new={} updated={} skipped={} errors={})",
counts.scanned,
counts.new,
counts.updated,
counts.skipped,
counts.errors,
);
}
}
IngestEvent::Aborted { counts } => {
if let Some(bar) = self.bar.take() {
bar.abandon_with_message(format!(
"aborted at {}/{}",
counts.scanned.saturating_sub(counts.errors),
counts.scanned
));
}
let mut err = std::io::stderr().lock();
let _ = writeln!(
err,
"ingest: aborted (scanned={} new={} updated={} skipped={} errors={})",
counts.scanned,
counts.new,
counts.updated,
counts.skipped,
counts.errors,
);
}
}
Ok(())
}
}
/// Serialize an `IngestEvent` as the `ingest_progress.v1` wire shape
/// (kind discriminator + RFC 3339 `ts`) and println to stdout. One
/// event per line.
fn emit_json(event: &IngestEvent) -> anyhow::Result<()> {
let value = wire::wire_ingest_progress(event)?;
let line = serde_json::to_string(&value)?;
let mut out = std::io::stdout().lock();
writeln!(out, "{line}")?;
Ok(())
}
/// Format the current wall-clock as RFC 3339 — used by `wire_ingest_progress`
/// so every emitted event carries an `ts` field per §2.4a / the wire schema.
pub(crate) fn now_rfc3339() -> anyhow::Result<String> {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_flags_json_takes_priority_over_tty() {
// --json forces Json regardless of TTY state.
assert_eq!(ProgressMode::from_flags(true), ProgressMode::Json);
}
#[test]
fn from_flags_human_reflects_stderr_tty() {
// We can't synthesize a TTY in tests, but we can assert the
// shape — mode is Human { tty: <something> } when --json=false.
match ProgressMode::from_flags(false) {
ProgressMode::Human { .. } => {}
other => panic!("expected Human mode, got {other:?}"),
}
}
#[test]
fn now_rfc3339_parses_back() {
let s = now_rfc3339().unwrap();
// Round-trip via the parser to confirm the formatter emits a
// well-formed RFC 3339 string.
OffsetDateTime::parse(&s, &Rfc3339).expect("RFC 3339 round-trip");
}
}

View File

@@ -114,6 +114,25 @@ pub fn wire_reset(r: &kebab_app::ResetReport) -> Value {
tag_object(v, "reset_report.v1")
}
/// Wrap an [`kebab_app::IngestEvent`] as `ingest_progress.v1`. Adds
/// the `schema_version` discriminator on top of serde's existing
/// `kind` discriminator, plus an `ts` field with the current
/// wall-clock — the emit site is the only place that knows the moment
/// of emission, so the timestamp is stamped here rather than carried
/// on the event itself.
pub fn wire_ingest_progress(
event: &kebab_app::IngestEvent,
) -> anyhow::Result<Value> {
let mut v = serde_json::to_value(event)?;
if let Value::Object(ref mut map) = v {
map.insert(
"ts".to_string(),
Value::String(crate::progress::now_rfc3339()?),
);
}
Ok(tag_object(v, "ingest_progress.v1"))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,164 @@
//! Integration coverage for `kebab ingest` 의 progress display
//! (p9-fb-02). Each test runs the built `kebab` bin in a fresh
//! subprocess against a tempdir-rooted XDG layout + tempdir
//! workspace so the assertions don't depend on the host config.
use std::io::Write;
use std::process::Command;
fn kebab_bin() -> std::path::PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
std::path::PathBuf::from(manifest)
.parent()
.unwrap()
.parent()
.unwrap()
.join("target/debug/kebab")
}
/// Build a tempdir-rooted XDG layout with a workspace containing two
/// markdown files. Returns the tmp guard (to keep the dir alive) and
/// the workspace path the caller should pass to `--root`.
fn fixture_workspace() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let mut a = std::fs::File::create(ws.join("a.md")).unwrap();
writeln!(a, "# Alpha\n\nfirst doc").unwrap();
let mut b = std::fs::File::create(ws.join("b.md")).unwrap();
writeln!(b, "# Beta\n\nsecond doc").unwrap();
(tmp, ws)
}
fn xdg_envs(tmp_path: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] {
[
("XDG_CONFIG_HOME", tmp_path.join("cfg")),
("XDG_DATA_HOME", tmp_path.join("data")),
("XDG_CACHE_HOME", tmp_path.join("cache")),
("XDG_STATE_HOME", tmp_path.join("state")),
]
}
#[test]
fn ingest_json_emits_line_delimited_progress_then_report() {
let (tmp, ws) = fixture_workspace();
let mut cmd = Command::new(kebab_bin());
cmd.args([
"--json",
"ingest",
"--root",
ws.to_str().unwrap(),
"--summary-only",
]);
for (k, v) in xdg_envs(tmp.path()) {
cmd.env(k, v);
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
// Every stdout line must be a JSON object. The last line is the
// existing ingest_report.v1; everything above is ingest_progress.v1.
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert!(lines.len() >= 2, "expected ≥2 stdout lines, got: {stdout}");
let mut progress_seen = 0usize;
let mut last_schema = None;
for line in &lines {
let v: serde_json::Value =
serde_json::from_str(line).unwrap_or_else(|e| panic!("bad json line: {line:?} ({e})"));
let schema = v
.get("schema_version")
.and_then(|s| s.as_str())
.unwrap_or_else(|| panic!("missing schema_version: {line}"));
if schema == "ingest_progress.v1" {
progress_seen += 1;
}
last_schema = Some(schema.to_string());
}
assert!(progress_seen >= 4, "progress events: {progress_seen}");
assert_eq!(last_schema.as_deref(), Some("ingest_report.v1"));
}
#[test]
fn ingest_human_non_tty_emits_progress_lines_to_stderr() {
// Command::output gives no controlling tty, so the indicatif draw
// target is `hidden` and progress lines go to stderr instead.
let (tmp, ws) = fixture_workspace();
let mut cmd = Command::new(kebab_bin());
cmd.args([
"ingest",
"--root",
ws.to_str().unwrap(),
"--summary-only",
]);
for (k, v) in xdg_envs(tmp.path()) {
cmd.env(k, v);
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("ingest: scanning") || stderr.contains("ingest:"),
"expected progress text in stderr, got: {stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("scanned ") && stdout.contains("new "),
"expected the human-mode summary line on stdout, got: {stdout}"
);
}
#[test]
fn ingest_json_progress_lines_carry_kind_and_ts() {
let (tmp, ws) = fixture_workspace();
let mut cmd = Command::new(kebab_bin());
cmd.args([
"--json",
"ingest",
"--root",
ws.to_str().unwrap(),
"--summary-only",
]);
for (k, v) in xdg_envs(tmp.path()) {
cmd.env(k, v);
}
let out = cmd.output().unwrap();
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let mut saw_scan_started = false;
let mut saw_completed = false;
for line in stdout.lines().filter(|l| !l.is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
let schema = v.get("schema_version").and_then(|s| s.as_str()).unwrap();
if schema != "ingest_progress.v1" {
continue;
}
let kind = v.get("kind").and_then(|s| s.as_str()).unwrap();
// ts is a non-empty string and must round-trip as RFC 3339.
let ts = v.get("ts").and_then(|s| s.as_str()).unwrap();
assert!(!ts.is_empty(), "ts empty for {kind}");
if kind == "scan_started" {
saw_scan_started = true;
}
if kind == "completed" {
saw_completed = true;
// Counts mirror the report.
let counts = v.get("counts").unwrap();
assert_eq!(counts.get("scanned").and_then(|n| n.as_u64()), Some(2));
assert_eq!(counts.get("new").and_then(|n| n.as_u64()), Some(2));
}
}
assert!(saw_scan_started, "missing scan_started event");
assert!(saw_completed, "missing completed event");
}

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-app + kebab-core
task_id: p9-fb-01
title: "Ingest progress callback / event channel"
status: in_progress
status: completed
depends_on: []
unblocks: [p9-fb-02, p9-fb-03]
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-cli
task_id: p9-fb-02
title: "CLI progress display (spinner + text + --json line events)"
status: planned
status: in_progress
depends_on: [p9-fb-01]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md