feat(tui): TUI background ingest worker + status bar (p9-fb-03) #55
@@ -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 후보
|
||||
|
||||
|
||||
@@ -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 회귀 측정 |
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
358
crates/kebab-tui/src/ingest_progress.rs
Normal file
358
crates/kebab-tui/src/ingest_progress.rs
Normal 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,
|
||||
);
|
||||
|
|
||||
}
|
||||
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,
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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, _) => {
|
||||
|
||||
@@ -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);
|
||||
|
claude-reviewer-01
commented
(칭찬) (칭찬) `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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user
(설계 결함 / 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 (둘 중):
IngestState에pub cancel_rx: Option<Receiver<()>>추가,start_ingest가 둘 다 보유. p9-fb-04 가take()해서 worker thread 로 move.cancel_txfield 를 아예 제거하고 p9-fb-04 가 추가하는 것으로 미룸. 어차피 wire 가 함수 시그니처 (ingest_with_config_cancellable) 추가도 동반하므로 IngestState reshape 도 같이 처리.권장: 옵션 2 — slot 만 두는 것은 backward-compat shim 패턴 (CLAUDE.md 금지) 에 가깝고, 실제로 사용도 못 함. p9-fb-04 의 PR 에 함께 추가.