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

Library 의 `r` 키가 `kebab_app::ingest_with_config_progress` 를
spawned thread 에서 호출. run loop 가 매 frame 마다 progress channel
drain → 화면 하단 status bar 1 줄 갱신. blocking 하지 않음.

신규:
- crates/kebab-tui/src/app.rs: `IngestState` struct (rx + counts +
  current_path + started_at + terminal_at + aborted + thread +
  cancel_tx) + `App.ingest_state` slot + `TERMINAL_LINE_HOLD_SECS`.
- crates/kebab-tui/src/ingest_progress.rs: `start_ingest` (worker
  spawn + channel allocation), `drain_progress` (try_recv loop),
  `apply_event` (per-kind counter accumulation + Completed/Aborted
  marking), `status_line` (사람-친화 텍스트), `ready_to_clear`
  (3 초 hold).
- 키 cheatsheet: Library footer 에 `r=ingest` 추가.

Run loop:
- 매 tick `drain_progress` + `ready_to_clear` 체크 → terminal 후
  3 초 경과 시 slot drop + worker 스레드 join + Library refresh
  큐.
- Layout: ingest_state Some 일 때 footer 위에 status line 1 줄
  추가 (있을 때만, 평시 영향 0).
- status line: scanning 중 / 진행 (idx/total %, current path,
  elapsed) / 완료 (✓) / abort (✗) 4 모드.

Cancel wiring (p9-fb-04) 의 `IngestState.cancel_tx: Sender<()>`
slot 은 정의만 — 본 PR 에서 sender 보유, send 호출 X.

Test:
- 10 lib unit (apply_event 분기 5 / status_line 4 / ready_to_clear 2).
- 기존 15 tui test 회귀 0.

Plan 갱신:
- p9-fb-03: 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 20:42:32 +00:00
parent fdd1ed3c56
commit 474b776c09
8 changed files with 493 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,46 @@ 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.
///
/// `cancel_tx` is reserved for `p9-fb-04` (Ctrl-C / Esc cancel
/// wiring); this task allocates the channel but never sends on it.
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>>>,
/// Cancel-token slot for `p9-fb-04`. Defined here so that task
/// can wire `Esc` / `Ctrl-C` without restructuring `IngestState`.
/// This task never sends on it.
pub cancel_tx: std::sync::mpsc::Sender<()>,
}
/// 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 +218,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 +243,7 @@ impl App {
search: None,
ask: None,
inspect: None,
ingest_state: None,
error_overlay: None,
should_quit: false,
})

View File

@@ -0,0 +1,364 @@
//! 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_tx` is allocated here but never sent on — the cancel
//! wiring (`Esc` / `Ctrl-C`) lands in `p9-fb-04`. The slot exists
//! today so this task does not have to reshape `IngestState` later.
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 (cancel_tx, _cancel_rx) = mpsc::channel::<()>();
// _cancel_rx is intentionally dropped here; p9-fb-04 will wire it
// through to `ingest_with_config_cancellable` (or equivalent).
// Holding both ends today keeps the channel allocated without
// doing anything with it.
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),
cancel_tx,
});
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>();
let (cancel_tx, _cancel_rx) = mpsc::channel::<()>();
IngestState {
rx,
counts: AggregateCounts::default(),
current_path: None,
current_idx: 0,
started_at: std::time::Instant::now(),
terminal_at: None,
aborted: false,
thread: None,
cancel_tx,
}
}
#[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);
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