feat(tui): TUI background ingest worker + status bar (p9-fb-03) #55

Merged
altair823 merged 2 commits from feat/p9-fb-03-tui-bg into main 2026-05-02 20:46:28 +00:00
8 changed files with 489 additions and 9 deletions

View File

@@ -42,6 +42,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **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.
- **2026-05-02 P9 도그푸딩 후속 (p9-fb-03)** — TUI 의 background ingest worker. Library 의 `r` 키가 `kebab_app::ingest_with_config_progress` 를 spawned thread 에서 호출, run loop 가 매 frame 마다 progress channel drain → 화면 하단 status bar 1 줄 갱신. terminal event (`Completed`/`Aborted`) 후 3 초 final 라인 hold + 자동 hide + Library auto-refresh. `IngestState``cancel_tx: Sender<()>` slot 만 정의 (p9-fb-04 가 wire). spec: `tasks/p9/p9-fb-03-tui-ingest-background.md`.
## 다음 task 후보

View File

@@ -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 패널, desktop 진행 중) |
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide |
| `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 회귀 측정 |

View File

@@ -166,6 +166,48 @@ impl Default for InspectState {
}
}
/// Background-ingest state — owned by p9-fb-03.
///
/// The TUI lets the user fire `kebab ingest` from inside the shell
/// without blocking the event loop. Pressing `r` on the Library pane
/// spawns a worker thread that calls
/// `kebab_app::ingest_with_config_progress(.., Some(tx))`; the run
/// loop drains `rx` once per frame and updates the visible status
/// bar. When the worker thread joins (Sender dropped → `recv()` Err),
/// the final aggregate counts stay on screen for a few seconds and
/// then the slot clears.
///
/// `p9-fb-04` adds the cancel surface — at that point this struct
/// gains a real `(cancel_tx, cancel_rx)` pair (the receiver moved
/// into the worker thread alongside the progress sender). We do
/// NOT pre-define a `cancel_tx` slot here because doing so without
/// a matching receiver-bound worker would yield a dead channel
/// (`send` returning `Err(SendError)` forever) — empty slot that
/// pretends to be a future-compat shim is worse than no slot
/// (CLAUDE.md "backward-compat shim 금지").
pub struct IngestState {
pub rx: std::sync::mpsc::Receiver<kebab_app::IngestEvent>,
pub counts: kebab_app::AggregateCounts,
pub current_path: Option<String>,
pub current_idx: u32,
pub started_at: std::time::Instant,
/// `Some(_)` once a `Completed` or `Aborted` event has arrived;
/// the run loop holds the final line on screen for
/// `TERMINAL_LINE_HOLD_SECS` seconds and then clears the slot.
pub terminal_at: Option<std::time::Instant>,
/// True when the terminal event was `Aborted` (vs `Completed`).
/// Used to colour the final line.
pub aborted: bool,
/// Worker thread handle. `take()`n at clear time so the join
/// happens after the user has had time to read the final line.
pub thread: Option<std::thread::JoinHandle<anyhow::Result<kebab_core::IngestReport>>>,
}
/// Seconds the final ingest status line stays on screen after a run
/// completes / aborts. After this elapses the run loop clears
/// `App.ingest_state` so the footer returns to the standard hints.
pub const TERMINAL_LINE_HOLD_SECS: u64 = 3;
/// TUI application. The shell that p9-1 stands up; later p9-* tasks
/// add panes by populating their `Option<*State>` slot.
pub struct App {
@@ -178,6 +220,10 @@ pub struct App {
pub ask: Option<AskState>,
/// Populated by p9-4.
pub inspect: Option<InspectState>,
/// Populated by p9-fb-03 when the user kicks off an in-shell
/// ingest (Library `r`). Cleared by the run loop a few seconds
/// after the run reaches a terminal event.
pub ingest_state: Option<IngestState>,
/// In-flight error overlay (popup); `Some` when the last facade
/// call returned `Err` and the user has not dismissed yet.
pub(crate) error_overlay: Option<ErrorOverlay>,
@@ -199,6 +245,7 @@ impl App {
search: None,
ask: None,
inspect: None,
ingest_state: None,
error_overlay: None,
should_quit: false,
})

View File

@@ -0,0 +1,358 @@
//! TUI background-ingest worker + status-bar reducer (p9-fb-03).
//!
//! The Library pane's `r` key fires `start_ingest`, which spawns a
//! worker thread calling
//! `kebab_app::ingest_with_config_progress(.., Some(tx))`. The run
//! loop drains the matching `rx` once per frame via
//! `drain_progress` and re-renders the status bar from the
//! accumulated counts. When the worker emits a terminal event
//! (`Completed` / `Aborted`) the status line freezes for a few
//! seconds (`TERMINAL_LINE_HOLD_SECS`) and then `tick_clear` returns
//! true so the run loop can drop the slot.
//!
//! Cancel surface (Esc / Ctrl-C) lands in `p9-fb-04`; that task
//! adds a `(cancel_tx, cancel_rx)` pair to `IngestState` and
//! threads the receiver through `kebab_app::ingest_with_config_cancellable`.
//! This task does NOT pre-allocate the channel — see the comment on
//! `IngestState` for the rationale.
use std::sync::mpsc;
use std::thread;
use kebab_app::IngestEvent;
use kebab_core::SourceScope;
use crate::app::{App, IngestState, TERMINAL_LINE_HOLD_SECS};
/// Already-running guard. Returns `Err` if `app.ingest_state` is
/// already populated — pressing `r` twice in a row should not spawn
/// two parallel workers (SQLite is mutexed but Lance writes can race
/// each other).
pub fn start_ingest(app: &mut App) -> anyhow::Result<()> {
if app.ingest_state.is_some() {
anyhow::bail!("ingest already running");
}
let cfg = app.config.clone();
let scope = SourceScope {
root: std::path::PathBuf::from(&cfg.workspace.root),
include: cfg.workspace.include.clone(),
exclude: cfg.workspace.exclude.clone(),
};
let (tx, rx) = mpsc::channel::<IngestEvent>();
let cfg_for_thread = cfg;
let thread = thread::spawn(move || {
kebab_app::ingest_with_config_progress(cfg_for_thread, scope, true, Some(tx))
});
app.ingest_state = Some(IngestState {
rx,
counts: kebab_app::AggregateCounts::default(),
current_path: None,
current_idx: 0,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: Some(thread),
});
Ok(())
}
/// Drain whatever progress events have arrived since the last tick.
/// Non-blocking. Caller (the run loop) calls this once per frame.
///
/// On a terminal event (`Completed` / `Aborted`) the function records
/// `terminal_at = Instant::now()` so subsequent ticks can decide when
/// to clear the slot.
pub fn drain_progress(app: &mut App) {
let Some(state) = app.ingest_state.as_mut() else {
return;
};
while let Ok(event) = state.rx.try_recv() {
apply_event(state, event);
}
}
fn apply_event(state: &mut IngestState, event: IngestEvent) {
match event {
IngestEvent::ScanStarted { .. } => {
// No counter to update; `started_at` already set by
// `start_ingest`. The status line shows "scanning…" while
// counts.scanned is zero.
}
IngestEvent::ScanCompleted { total } => {
state.counts.scanned = total;
}
IngestEvent::AssetStarted { idx, path, .. } => {
state.current_idx = idx;
state.current_path = Some(path);
}
IngestEvent::AssetFinished {
result, chunks, ..
} => {
// Per-asset counter increments mirror the way
// `kebab-app::ingest_with_config_progress` aggregates the
// final report — kept in sync so the status bar's running
// totals match the eventual `Completed { counts }`.
match result {
kebab_core::IngestItemKind::New => {
state.counts.new = state.counts.new.saturating_add(1);
state.counts.chunks_indexed =
state.counts.chunks_indexed.saturating_add(chunks);
}
kebab_core::IngestItemKind::Updated => {
state.counts.updated = state.counts.updated.saturating_add(1);
state.counts.chunks_indexed =
state.counts.chunks_indexed.saturating_add(chunks);
}
kebab_core::IngestItemKind::Skipped => {
state.counts.skipped = state.counts.skipped.saturating_add(1);
}
kebab_core::IngestItemKind::Error => {
state.counts.errors = state.counts.errors.saturating_add(1);
}
}
}
IngestEvent::Completed { counts } => {
// Trust the facade's authoritative aggregate — replaces
// any tiny drift between our running totals and the
// final report.
state.counts = counts;
state.current_path = None;
state.terminal_at = Some(std::time::Instant::now());
state.aborted = false;
}
IngestEvent::Aborted { counts } => {
state.counts = counts;
state.current_path = None;
state.terminal_at = Some(std::time::Instant::now());
state.aborted = true;
}
}
}
/// Should the run loop drop `app.ingest_state` now? True when the
/// terminal event arrived ≥ `TERMINAL_LINE_HOLD_SECS` ago.
pub fn ready_to_clear(state: &IngestState) -> bool {
match state.terminal_at {
Some(t) => t.elapsed().as_secs() >= TERMINAL_LINE_HOLD_SECS,
None => false,
}
}
/// Render the status-bar text for the current `IngestState`. Pure —
/// the run loop wraps this in a Paragraph widget. Returns the
/// human-friendly line per spec §p9-fb-03 ("`ingest: 142/1024 (14%)
/// parsing notes/foo.md [0:42]`").
pub fn status_line(state: &IngestState) -> String {
if state.terminal_at.is_some() {
let elapsed = state.started_at.elapsed();
let secs = elapsed.as_secs();
if state.aborted {
return format!(
"✗ ingest aborted at {}/{} after {}s (new={} updated={} skipped={} errors={})",
state.counts.scanned.saturating_sub(state.counts.errors),
state.counts.scanned,
secs,
state.counts.new,
state.counts.updated,
state.counts.skipped,
state.counts.errors,
);

(설계 결함 / cancel slot 사용 불가) _cancel_rxstart_ingest 안에서 즉시 drop 하면 IngestState.cancel_tx.send(()) 가 항상 Err(SendError) — channel 의 receiver 가 사라졌기 때문. p9-fb-04 가 cancel 을 wire 하려면 (a) receiver 도 worker thread 로 move 하거나 (b) IngestState 가 receiver 를 함께 보유해야 함. 현 상태로는 cancel_tx 가 슬롯이 아니라 dead channel — p9-fb-04 가 IngestState 를 reshape 해야 동작.

Why: spec 의 의도는 "slot 만 정의, p9-fb-04 가 send 만 추가". 그러나 receiver 가 같은 함수에서 dropped 면 send 결과가 noop 이라 cancel 신호 자체가 worker 에 도달 못 함. p9-fb-04 가 어차피 IngestState 손대야 한다면 본 PR 의 cancel_tx slot 은 미래 호환성 기여 0 — 차라리 p9-fb-04 까지 미루는 게 정직.

How to apply (둘 중):

  1. IngestStatepub cancel_rx: Option<Receiver<()>> 추가, start_ingest 가 둘 다 보유. p9-fb-04 가 take() 해서 worker thread 로 move.
  2. 또는 본 PR 에서 cancel_tx field 를 아예 제거하고 p9-fb-04 가 추가하는 것으로 미룸. 어차피 wire 가 함수 시그니처 (ingest_with_config_cancellable) 추가도 동반하므로 IngestState reshape 도 같이 처리.

권장: 옵션 2 — slot 만 두는 것은 backward-compat shim 패턴 (CLAUDE.md 금지) 에 가깝고, 실제로 사용도 못 함. p9-fb-04 의 PR 에 함께 추가.

(설계 결함 / cancel slot 사용 불가) `_cancel_rx` 를 `start_ingest` 안에서 즉시 drop 하면 `IngestState.cancel_tx.send(())` 가 항상 `Err(SendError)` — channel 의 receiver 가 사라졌기 때문. p9-fb-04 가 cancel 을 wire 하려면 (a) receiver 도 worker thread 로 move 하거나 (b) IngestState 가 receiver 를 함께 보유해야 함. 현 상태로는 cancel_tx 가 슬롯이 아니라 dead channel — p9-fb-04 가 `IngestState` 를 reshape 해야 동작. Why: spec 의 의도는 "slot 만 정의, p9-fb-04 가 send 만 추가". 그러나 receiver 가 같은 함수에서 dropped 면 send 결과가 noop 이라 cancel 신호 자체가 worker 에 도달 못 함. p9-fb-04 가 어차피 IngestState 손대야 한다면 본 PR 의 cancel_tx slot 은 미래 호환성 기여 0 — 차라리 p9-fb-04 까지 미루는 게 정직. How to apply (둘 중): 1. `IngestState` 에 `pub cancel_rx: Option<Receiver<()>>` 추가, `start_ingest` 가 둘 다 보유. p9-fb-04 가 `take()` 해서 worker thread 로 move. 2. 또는 본 PR 에서 `cancel_tx` field 를 아예 제거하고 p9-fb-04 가 추가하는 것으로 미룸. 어차피 wire 가 함수 시그니처 (`ingest_with_config_cancellable`) 추가도 동반하므로 IngestState reshape 도 같이 처리. 권장: 옵션 2 — slot 만 두는 것은 backward-compat shim 패턴 (CLAUDE.md 금지) 에 가깝고, 실제로 사용도 못 함. p9-fb-04 의 PR 에 함께 추가.
}
return format!(
"✓ ingest: {} docs ({} new, {} updated, {} skipped), {} chunks indexed in {}s",
state.counts.scanned,
state.counts.new,
state.counts.updated,
state.counts.skipped,
state.counts.chunks_indexed,
secs,
);
}
if state.counts.scanned == 0 {
let secs = state.started_at.elapsed().as_secs();
return format!("ingest: scanning… [{}s]", secs);
}
let pct = (state.current_idx as u64).saturating_mul(100) / state.counts.scanned.max(1) as u64;
let elapsed = state.started_at.elapsed();
let mm = elapsed.as_secs() / 60;
let ss = elapsed.as_secs() % 60;
let path = state.current_path.as_deref().unwrap_or("");
format!(
"ingest: {}/{} ({}%) {} [{}:{:02}]",
state.current_idx, state.counts.scanned, pct, path, mm, ss,
)
}
#[cfg(test)]
mod tests {
use super::*;
use kebab_app::AggregateCounts;
use kebab_core::IngestItemKind;
use std::sync::mpsc;
fn fresh_state() -> IngestState {
let (_tx, rx) = mpsc::channel::<IngestEvent>();
IngestState {
rx,

(칭찬) apply_eventCompleted { counts } / Aborted { counts } 분기에서 facade 의 authoritative counts 로 교체 (state.counts = counts). running totals 와 final report 사이 작은 drift (예: AssetFinished 가 chunks 보고 vs facade 의 chunks_indexed = embed_active 시에만 누적) 가 자동 정정. kebab-app::ingest_with_config_progress 의 보고와 status bar 가 항상 일치 보장.

(칭찬) `apply_event` 의 `Completed { counts }` / `Aborted { counts }` 분기에서 facade 의 authoritative counts 로 교체 (`state.counts = counts`). running totals 와 final report 사이 작은 drift (예: AssetFinished 가 chunks 보고 vs facade 의 `chunks_indexed = embed_active 시에만 누적`) 가 자동 정정. `kebab-app::ingest_with_config_progress` 의 보고와 status bar 가 항상 일치 보장.
counts: AggregateCounts::default(),
current_path: None,
current_idx: 0,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: None,
}
}
#[test]
fn apply_scan_completed_sets_total() {
let mut s = fresh_state();
apply_event(&mut s, IngestEvent::ScanCompleted { total: 42 });
assert_eq!(s.counts.scanned, 42);
}
#[test]
fn apply_asset_finished_accumulates_per_kind_counters() {
let mut s = fresh_state();
apply_event(
&mut s,
IngestEvent::AssetFinished {
idx: 1,
total: 3,
result: IngestItemKind::New,
chunks: 5,
},
);
apply_event(
&mut s,
IngestEvent::AssetFinished {
idx: 2,
total: 3,
result: IngestItemKind::Updated,
chunks: 2,
},
);
apply_event(
&mut s,
IngestEvent::AssetFinished {
idx: 3,
total: 3,
result: IngestItemKind::Skipped,
chunks: 0,
},
);
assert_eq!(s.counts.new, 1);
assert_eq!(s.counts.updated, 1);
assert_eq!(s.counts.skipped, 1);
assert_eq!(s.counts.chunks_indexed, 7);
}
#[test]
fn apply_completed_replaces_counts_and_marks_terminal() {
let mut s = fresh_state();
let final_counts = AggregateCounts {
scanned: 10,
new: 5,
updated: 5,
chunks_indexed: 50,
..Default::default()
};
apply_event(&mut s, IngestEvent::Completed { counts: final_counts });
assert_eq!(s.counts, final_counts);
assert!(s.terminal_at.is_some());
assert!(!s.aborted);
}
#[test]
fn apply_aborted_marks_aborted_flag() {
let mut s = fresh_state();
apply_event(
&mut s,
IngestEvent::Aborted {
counts: AggregateCounts::default(),
},
);
assert!(s.terminal_at.is_some());
assert!(s.aborted);
}
#[test]
fn status_line_scanning_shows_dots() {
let s = fresh_state();
let line = status_line(&s);
assert!(line.starts_with("ingest: scanning…"), "got: {line}");
}
#[test]
fn status_line_in_progress_shows_count_path_pct() {
let mut s = fresh_state();
apply_event(&mut s, IngestEvent::ScanCompleted { total: 100 });
apply_event(
&mut s,
IngestEvent::AssetStarted {
idx: 14,
total: 100,
path: "notes/foo.md".into(),
media: "markdown".into(),
},
);
let line = status_line(&s);
assert!(line.contains("14/100"), "got: {line}");
assert!(line.contains("(14%)"), "got: {line}");
assert!(line.contains("notes/foo.md"), "got: {line}");
}
#[test]
fn status_line_terminal_completed_shows_check_mark_and_totals() {
let mut s = fresh_state();
apply_event(
&mut s,
IngestEvent::Completed {
counts: AggregateCounts {
scanned: 10,
new: 8,
updated: 1,
skipped: 1,
chunks_indexed: 50,
..Default::default()
},
},
);
let line = status_line(&s);
assert!(line.starts_with("✓ ingest:"), "got: {line}");
assert!(line.contains("10 docs"), "got: {line}");
assert!(line.contains("50 chunks"), "got: {line}");
}
#[test]
fn status_line_terminal_aborted_shows_cross() {
let mut s = fresh_state();
s.current_idx = 7;
apply_event(
&mut s,
IngestEvent::Aborted {
counts: AggregateCounts {
scanned: 100,
errors: 0,
..Default::default()
},
},
);
let line = status_line(&s);
assert!(line.starts_with("✗ ingest aborted"), "got: {line}");
assert!(line.contains("100/100"), "got: {line}");
}
#[test]
fn ready_to_clear_false_until_hold_elapses() {
let mut s = fresh_state();
s.terminal_at = Some(std::time::Instant::now());
assert!(!ready_to_clear(&s));
}
#[test]
fn ready_to_clear_true_in_absence_of_terminal_is_false() {
let s = fresh_state();
assert!(!ready_to_clear(&s));
}
}

View File

@@ -15,6 +15,7 @@
mod app;
mod ask;
mod error_popup;
mod ingest_progress;
mod inspect;
mod library;
mod run;
@@ -22,10 +23,12 @@ mod search;
mod terminal;
pub use app::{
App, AskState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane, SearchState,
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane,
SearchState, TERMINAL_LINE_HOLD_SECS,
};
pub use ask::{handle_key_ask, render_ask};
pub use error_popup::{ErrorOverlay, render_error_overlay};
pub use ingest_progress::{drain_progress, ready_to_clear, start_ingest, status_line};
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};

View File

@@ -273,6 +273,18 @@ pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome {
inner.filter_edit = Some(FilterEdit::from_filter(&inner.filter));
KeyOutcome::Continue
}
(KeyCode::Char('r'), _) => {
// p9-fb-03: trigger background ingest. The `inner` mutable
// borrow above is not used in this arm, so NLL releases it
// before we re-borrow `state` for `start_ingest`. Errors
// (e.g. "ingest already running") surface via the error
// overlay.
if let Err(e) = crate::ingest_progress::start_ingest(state) {
state.error_overlay =
Some(crate::ErrorOverlay::from_anyhow(&e));
}
KeyOutcome::Continue
}
(KeyCode::Char('/'), _) => KeyOutcome::SwitchPane(Pane::Search),
(KeyCode::Char('?'), _) => KeyOutcome::SwitchPane(Pane::Ask),
(KeyCode::Enter, _) => {

View File

@@ -29,6 +29,31 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
let mut terminal = TuiTerminal::enter()?;
while !app.should_quit {
// p9-fb-03: ingest progress is pane-independent. Drain
// freshly-arrived events every tick + clear the slot a few
// seconds after the run terminated so the user has time to
// read the final line.
crate::ingest_progress::drain_progress(app);
let clear_now = app
.ingest_state
.as_ref()
.map(crate::ingest_progress::ready_to_clear)
.unwrap_or(false);

(칭찬) clear_now 분기가 take()thread.take() + join() + Library needs_refresh 큐 — terminal event 후 3 초 hold 동안 사용자가 final 라인 읽고, slot drop 시 worker 정리 + 다음 idle tick 에서 Library 가 재로드 → 새 doc 자동 surface. 사용자 명시 action 없이 신선한 view 보장.

(칭찬) `clear_now` 분기가 `take()` 후 `thread.take()` + `join()` + Library `needs_refresh` 큐 — terminal event 후 3 초 hold 동안 사용자가 final 라인 읽고, slot drop 시 worker 정리 + 다음 idle tick 에서 Library 가 재로드 → 새 doc 자동 surface. 사용자 명시 action 없이 신선한 view 보장.
if clear_now {
if let Some(mut state) = app.ingest_state.take() {
// Reap the worker thread now that the user has seen
// the final status line; ignore the join result —
// `IngestReport` was already mirrored into the status
// bar via `Completed { counts }`.
if let Some(handle) = state.thread.take() {
let _ = handle.join();
}
}
// Library may show stale doc list; queue a refresh so the
// next idle tick picks up the just-ingested rows.
app.library.inner.needs_refresh = true;
}
// Per-pane idle work BEFORE rendering so the frame reflects
// freshly-loaded state.
if app.error_overlay.is_none() {
@@ -149,13 +174,26 @@ fn handle_key_unimplemented_pane(
}
fn render_root(f: &mut Frame, app: &App) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
// p9-fb-03: insert a 1-line status bar above the footer when an
// ingest is in flight (or its terminal line is still on hold).
let has_ingest = app.ingest_state.is_some();
let constraints: Vec<Constraint> = if has_ingest {
vec![
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1), // ingest status bar
Constraint::Length(1), // existing footer hints
]
} else {
vec![
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
]
};
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(f.area());
render_header(f, outer[0], app);
match app.focus {
@@ -166,12 +204,33 @@ fn render_root(f: &mut Frame, app: &App) {
// p9-5 Jobs not yet rendered; Library placeholder.
Pane::Jobs => render_library(f, outer[1], app),
}
render_footer(f, outer[2], app);
if has_ingest {
render_ingest_status(f, outer[2], app);
render_footer(f, outer[3], app);
} else {
render_footer(f, outer[2], app);
}
if let Some(err) = &app.error_overlay {
render_error_overlay(f, f.area(), err);
}
}
fn render_ingest_status(f: &mut Frame, area: Rect, app: &App) {
let Some(state) = app.ingest_state.as_ref() else {
return;
};
let line = crate::ingest_progress::status_line(state);
let style = if state.aborted {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(line, style))),
area,
);
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let pane_label = match app.focus {
Pane::Library => "Library",
@@ -197,7 +256,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
if app.library.inner.filter_edit.is_some() {
"Tab=field Enter=apply Esc=cancel"
} else {
"j/k=move gg=top G=bottom f=filter /=search ?=ask Enter=inspect q=quit"
"j/k=move gg=top G=bottom f=filter /=search ?=ask Enter=inspect r=ingest q=quit"
}
}
Pane::Search => "type=query Tab=mode Enter=search j/k=move g=open in $EDITOR Esc=back",

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-tui
task_id: p9-fb-03
title: "TUI ingest as background worker + status bar"
status: planned
status: in_progress
depends_on: [p9-fb-01]
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md