Merge pull request 'feat(kebab-tui): p9-fb-08 async search worker + generation counter' (#74) from feat/p9-fb-08-search-async into main

This commit was merged in pull request #74.
This commit is contained in:
2026-05-03 03:55:42 +00:00
8 changed files with 308 additions and 31 deletions

View File

@@ -51,6 +51,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-09)** — TUI external editor return restore. Search `g` 키 (citation jump) 후 TUI 화면이 깨지는 버그 수정. `kebab-tui::editor::with_external_program(&mut TuiTerminal, Command)` helper 가 suspend (LeaveAlternateScreen + Show cursor + disable_raw_mode) → spawn → restore (enable_raw_mode + EnterAlternateScreen + Hide cursor + `terminal.clear()`) 시퀀스를 RAII guard 로 atomic 하게 묶음. `App.pending_editor: Option<EditorRequest>` + `App.force_redraw: bool` 추가 — 키 핸들러는 EditorRequest enqueue 만, 실제 spawn 은 run loop 가 `TuiTerminal` 핸들 들고 처리. 후속 task (p9-fb-20 의 citation jump 등) 가 같은 helper 위에 build. spec: `tasks/p9/p9-fb-09-tui-editor-restore.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-14)** — TUI color theme module. `kebab-tui::theme::{Theme, Role, Palette}` 신규 — 16 개 Role (BorderActive/Title/Path/ModeLexical/ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/CitationMarker/Bullet/Body/BorderInactive) 을 dark + light 두 팔레트가 exhaustive match 로 매핑. 모든 Pane (library/search/ask/inspect/run/error_popup) 의 inline `Style::default().fg(Color::*)` 호출이 `theme.style(Role::X)` 로 격리됨. `Config.ui.theme: String` (default `"dark"`) 신규. `App.theme: Theme``App::new` 에서 `Theme::from_name(&config.ui.theme)` 로 build — 알 수 없는 값은 dark fallback (config 가 typo 로 죽지 않음). `T` 키 runtime toggle 은 mode machine (p9-fb-12) 미진행이라 skip — config 만으로 결정. p9-fb-11 (ask markdown render) 의 Theme 의존성 unblock. spec: `tasks/p9/p9-fb-14-tui-color-theme.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-11)** — TUI Ask 답변 본문 markdown 렌더. `kebab-tui::markdown::render(text, &Theme) -> Vec<Line<'static>>` 신규 — `pulldown-cmark = "0.13"` 위에서 inline (bold/italic/strikethrough/inline code/link)·block (heading H1-H6, ordered/unordered list with nesting, fenced code block, table, blockquote `▎`, horizontal rule) 변환. heading H1/H2 = `Role::Heading`, H3+ = `Role::Title`, link = `Role::CitationMarker + UNDERLINE`, code = `Role::Hint`. ask `push_turn_lines` 가 grounded 답변에서만 markdown 렌더; refusal (`Role::Warning`) / streaming (`Role::Hint`) 은 raw 로 두어 role color 시그널 보존. CLI `kebab ask` 출력은 raw markdown 그대로 (terminal 호환성). 매 frame 재 parse — pulldown 토크나이저가 µs/KB 라 비용 무시. spec: `tasks/p9/p9-fb-11-ask-markdown-render.md`.
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState``generation: u64` + `worker_thread: Option<JoinHandle>` + `worker_rx: Option<Receiver<SearchWorkerMessage>>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`.
## 다음 task 후보

View File

@@ -76,7 +76,7 @@ kebab doctor
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
| `kebab ask "<query>" [--show-citations / --hide-citations]` | RAG 답변 + 근거 인용. 답변 후 `근거:` block 으로 full path / line range / score 한 줄씩 (default ON — `--hide-citations` 로 끄기, pipe 시 유용). 근거 부족 시 거절. Ollama 필요 |
| `kebab doctor` | 설정/모델/DB 헬스 체크 |
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) |
| `kebab tui` | Ratatui 셸 (Library + Search + Ask + Inspect 패널, desktop 진행 중). Library 에서 `r` 키로 background ingest 시작 — 화면 하단 status bar 가 진행 표시, 완료/abort 시 final 라인 잠시 유지 후 자동 hide. ingest 진행 중 `Esc` / `Ctrl-C` 가 cancel signal (그 외에는 quit). Search 패널은 200ms debounce 후 background worker 가 검색 — 키 입력으로 UI freeze 안 됨, 사용자가 계속 타이핑하면 stale 결과 자동 폐기 (generation counter). Ask 패널은 multi-turn — 같은 conversation 안에서 Q1/A1, Q2/A2 transcript 누적, 다음 질문이 이전 턴을 history 로 받아 답변. 답변 본문은 markdown 렌더 (bold/italic/inline code/heading/list/code fence/table/blockquote, raw `**bold**` 가 실제 굵게 표시). `Ctrl-L` 로 새 conversation 시작. Search 의 `g` 키가 `$EDITOR` (기본 `vi`) 로 hit 의 citation 위치 열기 — 종료 후 TUI 화면이 자동으로 깨끗이 redraw. CLI `kebab ask` 는 raw markdown 그대로 (terminal 호환성 위해) |
| `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

@@ -69,12 +69,43 @@ pub struct SearchState {
/// Snapshot of `(input, mode)` at the moment the last search
/// fired. The debounce skips re-searches when nothing changed.
pub last_query: Option<(String, kebab_core::SearchMode)>,
/// True while a synchronous search call is in flight. The run
/// loop uses this to overlay a "searching…" hint.
/// True while a search worker is in flight. The run loop uses
/// this to overlay a "searching…" hint and to dedupe rapid
/// keystroke spawns.
pub searching: bool,
/// Cached preview text for the currently-selected hit (lazily
/// fetched via `kebab-app::inspect_chunk_with_config`).
pub preview: Option<String>,
/// p9-fb-08: monotonic counter incremented every time
/// `fire_search` spawns a worker. Each worker carries its
/// generation back via the channel; if it doesn't match the
/// current value at receive time, the result is silently dropped
/// (the user kept typing and a newer query is already in
/// flight). Wraps at u64::MAX which is unreachable in practice.
pub generation: u64,
/// p9-fb-08: receiver for the in-flight worker's
/// `SearchWorkerMessage::Done`. Drained every tick by
/// `crate::search::poll_worker`. `None` between runs.
///
/// Workers are fire-and-forget (no join) — search is a pure
/// read with no cleanup obligation, and dropping the receiver
/// makes the worker's `tx.send` no-op (worker exits after).
/// We don't store the `JoinHandle` because nothing observes it
/// (cf. `AskState.thread` which is `take().join()`'d on
/// `Ctrl-L`); the previous draft kept one for "symmetry" but
/// it was dead code.
pub worker_rx: Option<std::sync::mpsc::Receiver<SearchWorkerMessage>>,
}
/// p9-fb-08: payload posted by the search worker on completion.
/// `generation` matches the value of `SearchState.generation` at the
/// moment the worker was spawned; the run loop drops the message if
/// `generation` no longer matches (a newer query is in flight).
pub enum SearchWorkerMessage {
Done {
generation: u64,
result: anyhow::Result<Vec<kebab_core::SearchHit>>,
},
}
impl Default for SearchState {
@@ -88,6 +119,8 @@ impl Default for SearchState {
last_query: None,
searching: false,
preview: None,
generation: 0,
worker_rx: None,
}
}
}

View File

@@ -28,7 +28,7 @@ mod theme;
pub use theme::{Palette, Role, Theme};
pub use app::{
App, AskState, IngestState, InspectState, InspectTarget, KeyOutcome, LibraryState, Pane,
SearchState, TERMINAL_LINE_HOLD_SECS,
SearchState, SearchWorkerMessage, TERMINAL_LINE_HOLD_SECS,
};
pub use ask::{handle_key_ask, render_ask};
pub use error_popup::{ErrorOverlay, render_error_overlay};
@@ -43,3 +43,10 @@ pub use library::{handle_key_library, render_library};
// only safe constructor path for raw mode + alt-screen). External
// callers stage editor spawns via `App.pending_editor` instead.
pub use search::{build_jump_command, handle_key_search, render_search};
// p9-fb-08: expose `poll_worker` + `debounce_due` so integration
// tests can drive the stale-result drop / fresh-result apply paths
// without spawning the real thread (they inject a
// `SearchWorkerMessage` directly via a channel they construct in
// the test) and can pin the in-flight-skip invariant of debounce.
pub use search::poll_worker as poll_search_worker;
pub use search::debounce_due as search_debounce_due;

View File

@@ -65,6 +65,11 @@ pub(crate) fn run_loop(app: &mut App) -> Result<()> {
}
}
Pane::Search => {
// p9-fb-08: drain the async search worker first.
// Stale generations are silently dropped; the
// current generation's result populates `hits`
// / clears `searching` here.
crate::search::poll_worker(app);
let due = app
.search
.as_ref()

View File

@@ -427,8 +427,11 @@ fn parse_editor_env(env: &str) -> (String, Vec<String>) {
/// Run-loop hook: tick called every poll cycle. Returns `true` if a
/// search should fire this tick (debounce expired and query
/// changed).
pub(crate) fn debounce_due(s: &SearchState) -> bool {
/// changed). p9-fb-08 adds two skip cases:
/// - if a worker is already in flight for the *same* `(input, mode)`
/// the spawn is redundant — wait for the result.
/// - dedupe against `last_query` (was already there pre-fb-08, kept).
pub fn debounce_due(s: &SearchState) -> bool {
let Some(at) = s.input_dirty_at else { return false };
let elapsed = (time::OffsetDateTime::now_utc() - at)
.try_into()
@@ -440,6 +443,16 @@ pub(crate) fn debounce_due(s: &SearchState) -> bool {
if q.is_empty() {
return false;
}
// p9-fb-08: if the most-recent in-flight query is identical to
// the current input/mode pair, don't spawn another worker — the
// existing result will land via `poll_worker`.
if s.searching {
if let Some((prev_input, prev_mode)) = &s.last_query {
if prev_input == &s.input && *prev_mode == s.mode {
return false;
}
}
}
!matches!(
&s.last_query,
Some((prev_input, prev_mode))
@@ -447,38 +460,112 @@ pub(crate) fn debounce_due(s: &SearchState) -> bool {
)
}
/// Run-loop hook: actually perform the search, populate `hits`. The
/// state's `input_dirty_at` is cleared, `last_query` snapshots, and
/// `searching` flag toggles around the call.
/// Run-loop hook: spawn an asynchronous search worker. Returns
/// immediately so the event loop keeps polling — the result lands in
/// `state.search.worker_rx` and is applied by `poll_worker` on a
/// later tick. p9-fb-08 deviation from the original synchronous
/// design (the user typed faster than vector search could complete,
/// freezing the UI for 50-200 ms per keystroke under hybrid mode).
///
/// Behavior:
/// 1. Increment `generation` so any in-flight result becomes stale
/// on receive (`poll_worker` drops it).
/// 2. Drop the prior `worker_rx` (the old worker keeps running and
/// its result is silently discarded — search is a pure read with
/// no cleanup obligation).
/// 3. Snapshot `last_query` + clear `input_dirty_at` for the
/// debounce machinery (so a no-op keystroke doesn't re-spawn).
/// 4. Spawn a fresh worker carrying its generation token.
pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> {
let cfg = state.config.clone();
let (q_text, mode) = {
let (q_text, mode, generation) = {
let s = state.search.as_mut().expect("Search slot must exist");
s.generation = s.generation.wrapping_add(1);
s.searching = true;
s.input_dirty_at = None;
s.last_query = Some((s.input.clone(), s.mode));
(s.input.clone(), s.mode)
(s.input.clone(), s.mode, s.generation)
};
let query = SearchQuery {
text: q_text,
mode,
k: SEARCH_K,
filters: kebab_core::SearchFilters::default(),
};
let result = kebab_app::search_with_config(cfg, query);
let (tx, rx) = std::sync::mpsc::channel();
// Fire-and-forget — `JoinHandle` is dropped immediately so the
// OS detaches the thread. Search is a pure read with no
// cleanup obligation; if the receiver is replaced (next
// keystroke spawns a fresh worker), the old worker's
// `tx.send` no-ops and it exits silently.
std::thread::Builder::new()
.name(format!("kebab-tui-search-gen{generation}"))
.spawn(move || {
let query = SearchQuery {
text: q_text,
mode,
k: SEARCH_K,
filters: kebab_core::SearchFilters::default(),
};
let result = kebab_app::search_with_config(cfg, query);
let _ = tx.send(crate::app::SearchWorkerMessage::Done {
generation,
result,
});
})
.map_err(|e| anyhow::anyhow!("spawn search worker: {e}"))?;
let s = state.search.as_mut().expect("Search slot must exist");
s.searching = false;
match result {
Ok(hits) => {
s.hits = hits;
s.selected_hit = 0;
s.preview = None;
Ok(())
s.worker_rx = Some(rx);
Ok(())
}
/// Run-loop hook: drain any pending message from the search worker.
/// Stale results (newer query already in flight) are silently
/// dropped per the generation-counter contract. `pub` so integration
/// tests can drive the stale-result paths by injecting a channel.
pub fn poll_worker(state: &mut App) {
let Some(s) = state.search.as_mut() else { return };
let Some(rx) = s.worker_rx.as_ref() else { return };
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// Worker panicked or dropped tx without sending. Clear
// the rx + searching flag so the next debounce tick can
// re-fire if needed.
s.worker_rx = None;
s.searching = false;
return;
}
Err(e) => {
s.hits.clear();
s.selected_hit = 0;
Err(e)
};
s.worker_rx = None;
match msg {
crate::app::SearchWorkerMessage::Done { generation, result } => {
// p9-fb-08: stale guard. The user kept typing after this
// worker spawned and a newer query is in flight — drop
// the result. Don't clear `searching` because the newer
// worker (if any) is still running; if there's no newer
// worker (rare race), the next debounce_due tick will
// re-fire `fire_search` and reset everything.
if generation != s.generation {
tracing::debug!(
target: "kebab-tui",
stale_gen = generation,
current_gen = s.generation,
"dropping stale search result"
);
return;
}
s.searching = false;
match result {
Ok(hits) => {
s.hits = hits;
s.selected_hit = 0;
s.preview = None;
}
Err(e) => {
s.hits.clear();
s.selected_hit = 0;
state.error_overlay =
Some(crate::error_popup::ErrorOverlay::from_anyhow(&e));
}
}
}
}
}

View File

@@ -7,7 +7,8 @@ use kebab_core::{
RetrievalDetail, SearchHit, SearchMode, WorkspacePath,
};
use kebab_tui::{
App, KeyOutcome, Pane, SearchState, build_jump_command, handle_key_search, render_search,
App, KeyOutcome, Pane, SearchState, SearchWorkerMessage, build_jump_command,
handle_key_search, poll_search_worker, render_search, search_debounce_due,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -334,6 +335,149 @@ fn g_key_with_no_hits_does_not_enqueue() {
);
}
// ── p9-fb-08: async search worker + generation counter ────────────
/// `poll_search_worker` applies a fresh result (matching generation)
/// to `state.search.hits` and clears `searching`.
#[test]
fn poll_worker_applies_fresh_result_to_hits() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel();
{
let s = app.search.as_mut().unwrap();
s.generation = 5;
s.searching = true;
s.worker_rx = Some(rx);
}
let hit = make_hit(1, "a.md", "snip", line_citation("a.md", 1));
tx.send(SearchWorkerMessage::Done {
generation: 5,
result: Ok(vec![hit]),
})
.unwrap();
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert_eq!(s.hits.len(), 1, "fresh result populates hits");
assert!(!s.searching, "searching cleared");
assert!(s.worker_rx.is_none(), "rx drained");
}
/// p9-fb-08 — a stale result (generation mismatch) is silently
/// dropped. `searching` remains true since a newer worker is
/// (presumed) still in flight.
#[test]
fn poll_worker_drops_stale_result() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel();
{
let s = app.search.as_mut().unwrap();
s.generation = 7;
s.searching = true;
s.worker_rx = Some(rx);
}
let hit = make_hit(1, "stale.md", "snip", line_citation("stale.md", 1));
// generation 3 < current 7 → stale.
tx.send(SearchWorkerMessage::Done {
generation: 3,
result: Ok(vec![hit]),
})
.unwrap();
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert!(s.hits.is_empty(), "stale result must not populate hits");
assert!(
s.searching,
"searching stays true so newer worker can resolve it"
);
assert!(
s.worker_rx.is_none(),
"stale message still drains the rx slot — worker is one-shot"
);
}
/// p9-fb-08 — `poll_search_worker` is a no-op when no worker is in
/// flight (no rx). Common case on every tick the user isn't typing.
#[test]
fn poll_worker_noop_when_no_rx() {
let mut app = fresh_app();
{
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(1, "x.md", "snip", line_citation("x.md", 1))];
}
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert_eq!(s.hits.len(), 1, "existing hits preserved");
assert!(s.worker_rx.is_none());
}
/// Helper for the debounce_due tests — build a state with the four
/// fields the test cares about set, others default.
#[allow(clippy::field_reassign_with_default)]
fn search_state_with(input: &str, mode: SearchMode, searching: bool, last_query: Option<(String, SearchMode)>) -> SearchState {
let mut s = SearchState::default();
s.input = input.into();
s.mode = mode;
s.searching = searching;
s.last_query = last_query;
s.input_dirty_at = Some(
time::OffsetDateTime::now_utc() - time::Duration::seconds(1),
);
s
}
/// p9-fb-08 — `debounce_due` skips when an in-flight worker is
/// already running for the same `(input, mode)` pair. Without this
/// guard, a "phantom keystroke" (re-typing the same chars) would
/// pile up workers and burn CPU.
#[test]
fn debounce_due_skips_when_in_flight_for_same_query() {
let s = search_state_with(
"hello",
SearchMode::Hybrid,
true,
Some(("hello".into(), SearchMode::Hybrid)),
);
assert!(
!search_debounce_due(&s),
"in-flight worker for same query → debounce must skip"
);
}
/// p9-fb-08 — `debounce_due` still fires when a different query is
/// in flight (user typed past the in-flight one). The new spawn
/// makes the prior result stale (handled by `poll_worker`).
#[test]
fn debounce_due_fires_when_in_flight_for_different_query() {
let s = search_state_with(
"hello world",
SearchMode::Hybrid,
true,
Some(("hello".into(), SearchMode::Hybrid)),
);
assert!(
search_debounce_due(&s),
"in-flight worker for old query → new query still spawns"
);
}
/// p9-fb-08 — disconnected channel (worker panicked) clears the rx
/// + searching flag so the next debounce tick can re-fire cleanly.
#[test]
fn poll_worker_handles_disconnected_channel() {
let mut app = fresh_app();
let (tx, rx) = std::sync::mpsc::channel::<SearchWorkerMessage>();
{
let s = app.search.as_mut().unwrap();
s.searching = true;
s.worker_rx = Some(rx);
}
drop(tx); // simulate worker panic before send
poll_search_worker(&mut app);
let s = app.search.as_ref().unwrap();
assert!(!s.searching, "searching cleared on disconnect");
assert!(s.worker_rx.is_none());
}
#[test]
fn no_search_state_returns_to_library() {
let mut config = Config::defaults();

View File

@@ -3,7 +3,7 @@ phase: P9
component: kebab-tui (search pane)
task_id: p9-fb-08
title: "Search debounce + Enter-immediate trigger"
status: planned
status: in_progress
depends_on: []
unblocks: []
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md