V009 unicode61 + 형태소 tokenizer 환경에서 2-char 한국어 query 가
hit 가능해졌으므로 V007 시기의 "3자 이상 권장" hint 가 obsolete.
SearchResponse.hint field 는 wire schema 보존 위해 struct 에 유지 +
항상 None.
- kebab-app/src/app.rs: short_query_hint 함수 + doc-comment 삭제.
2 호출 site 가 hint = None 으로 정리.
- kebab-app/src/lib.rs: re-export 에서 short_query_hint 제거.
- kebab-tui/{app.rs,search.rs,run.rs}: short_query_hint field + 4
호출 cascade 제거.
- kebab-cli/tests/wire_search_response.rs:
search_plain_emits_short_query_hint_to_stderr test 삭제.
search_json_emits_hint_field_for_short_query →
search_json_hint_absent_for_short_query_v009 으로 교체
(hint 항상 None 검증).
- kebab-search/src/lexical.rs::build_match_string: V007 의 trigram
multi-token OR-combine 분기는 V009 환경에서 redundant 하나 보존
(future 확장성) — doc-comment 1 줄 추가.
Wire schema shape 변경 없음 (search_response.schema.json:33 의 hint
field 보존, struct 에 None 으로 항상 셋팅).
Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md §7.2, §7.3, §11.3
Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (S5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
562 lines
23 KiB
Rust
562 lines
23 KiB
Rust
//! `App` — TUI shell state, owned by p9-1.
|
|
//!
|
|
//! The struct's full set of fields is owned here; the layout reserves
|
|
//! one `Option<*State>` slot per pane so p9-2 / p9-3 / p9-4 can plug
|
|
//! their state in WITHOUT modifying the struct definition. p9-1 is the
|
|
//! only crate that ever changes `App`.
|
|
|
|
use kebab_config::Config;
|
|
|
|
use crate::error_popup::ErrorOverlay;
|
|
use crate::library::LibraryStateInner;
|
|
|
|
/// TUI panes (design §1 UX scenes).
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum Pane {
|
|
Library,
|
|
Search,
|
|
Ask,
|
|
Inspect,
|
|
Jobs,
|
|
}
|
|
|
|
/// p9-fb-12 (partial): vim-style modal interface.
|
|
///
|
|
/// `Normal` is the navigation / command mode; `Insert` is for typing
|
|
/// queries / questions. The run loop intercepts `i` / `Esc` globally
|
|
/// to flip between them, and pane switches auto-select the natural
|
|
/// mode for the destination (Library/Inspect → Normal; Search/Ask →
|
|
/// Insert). The status bar shows the active mode label so the user
|
|
/// always knows which keys do what.
|
|
///
|
|
/// **Scope deviation from spec p9-fb-12** (recorded in HOTFIXES):
|
|
/// the existing `is_typing_mod` heuristic in `search::handle_key_search`
|
|
/// and the input-empty heuristic in `ask::handle_key_ask` are NOT
|
|
/// removed in this PR — they continue to gate j/k/e between
|
|
/// "navigation" and "typing" based on input buffer state. Removing
|
|
/// them lands in a follow-up PR so the test surface (which leans on
|
|
/// the heuristics) gets a focused review. The mode label is
|
|
/// authoritative for the user-visible signal in the status bar; the
|
|
/// dispatch is still heuristic-driven.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum Mode {
|
|
#[default]
|
|
Normal,
|
|
Insert,
|
|
}
|
|
|
|
impl Mode {
|
|
/// Status-bar label (`-- NORMAL --` / `-- INSERT --`).
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Mode::Normal => "-- NORMAL --",
|
|
Mode::Insert => "-- INSERT --",
|
|
}
|
|
}
|
|
|
|
/// p9-fb-12: which mode a freshly-focused pane should auto-enter.
|
|
/// Library / Inspect are read-only navigation panes (`Normal`);
|
|
/// Search / Ask are typing panes so we pre-flip to `Insert` so
|
|
/// the user doesn't have to press `i` after every Tab.
|
|
///
|
|
/// **Auto-flip overrides any prior user-flipped mode on pane
|
|
/// switch** — if a user pressed `Esc` on Search to read scroll-
|
|
/// back, then Tab'd back into Ask, the next focus auto-flips
|
|
/// to Insert (clobbering the user's Normal). This is
|
|
/// intentional: the typing case is the dominant one for
|
|
/// Search/Ask, and a sticky-per-pane mode adds state most
|
|
/// users don't ask for. Sticky mode is a future task —
|
|
/// current heuristic optimizes for the common case.
|
|
pub fn auto_for(pane: Pane) -> Self {
|
|
match pane {
|
|
Pane::Search | Pane::Ask => Mode::Insert,
|
|
Pane::Library | Pane::Inspect | Pane::Jobs => Mode::Normal,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Outcome of a key handler — what the run loop should do next.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum KeyOutcome {
|
|
/// Stay on the current pane; re-render only.
|
|
Continue,
|
|
/// Quit the app (`q` / `Esc` from Library, or any pane's quit key).
|
|
Quit,
|
|
/// Switch focus to the named pane.
|
|
SwitchPane(Pane),
|
|
/// Re-run the pane's data fetch (e.g. Library after a filter edit).
|
|
Refresh,
|
|
}
|
|
|
|
/// Library pane state — fully owned by p9-1.
|
|
pub struct LibraryState {
|
|
pub(crate) inner: LibraryStateInner,
|
|
}
|
|
|
|
impl LibraryState {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
inner: LibraryStateInner::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for LibraryState {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Search pane state — owned by p9-2.
|
|
///
|
|
/// Field-set kept in `app.rs` (not in `search.rs`) so cross-module
|
|
/// access from `run.rs` (lazy-init, debounce tick) does not require
|
|
/// re-exporting field accessors. The pane behavior + render live in
|
|
/// `crate::search`.
|
|
pub struct SearchState {
|
|
/// p9-fb-10: `InputBuffer` tracks display-column cursor position
|
|
/// alongside content so wide chars (Hangul, CJK) place the
|
|
/// terminal cursor in the correct column.
|
|
pub input: crate::input::InputBuffer,
|
|
pub mode: kebab_core::SearchMode,
|
|
pub hits: Vec<kebab_core::SearchHit>,
|
|
pub selected_hit: usize,
|
|
/// When the input last changed; the run loop debounces searches
|
|
/// against this (200 ms after the last keystroke).
|
|
pub input_dirty_at: Option<time::OffsetDateTime>,
|
|
/// 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 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 {
|
|
fn default() -> Self {
|
|
Self {
|
|
input: crate::input::InputBuffer::new(),
|
|
mode: kebab_core::SearchMode::Hybrid,
|
|
hits: Vec::new(),
|
|
selected_hit: 0,
|
|
input_dirty_at: None,
|
|
last_query: None,
|
|
searching: false,
|
|
preview: None,
|
|
generation: 0,
|
|
worker_rx: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask pane state — owned by p9-3, extended by p9-fb-16 for
|
|
/// multi-turn conversation transcript.
|
|
///
|
|
/// The worker thread (`thread`) owns the `mpsc::Sender<kebab_app::StreamEvent>`
|
|
/// that `kebab-app::ask` writes events into. The pane keeps the matching
|
|
/// `rx` and drains it once per render frame (no blocking). Only the
|
|
/// `Token { delta }` variant is consumed for the streaming transcript;
|
|
/// `RetrievalDone` and `Final` are ignored (citations render from
|
|
/// `last_answer` after the worker join).
|
|
///
|
|
/// p9-fb-16: completed `Turn`s accumulate in `turns`; the worker
|
|
/// passes a snapshot of `turns` as `history` to
|
|
/// `RagPipeline::ask_with_history`, so each follow-up question sees
|
|
/// the full prior conversation. `conversation_id` is auto-generated
|
|
/// on the first submission (timestamp-based — unique per session,
|
|
/// not cryptographic). `Ctrl-L` clears `turns + conversation_id` to
|
|
/// start a fresh conversation.
|
|
pub struct AskState {
|
|
/// p9-fb-10: `InputBuffer` tracks display-column cursor position
|
|
/// alongside content so wide chars (Hangul, CJK) place the
|
|
/// terminal cursor in the correct column.
|
|
pub input: crate::input::InputBuffer,
|
|
/// Toggled by the `e` key. Re-applied on the next `Enter`.
|
|
pub explain: bool,
|
|
/// True between `Enter` press and worker thread completion.
|
|
pub streaming: bool,
|
|
/// Tokens accumulated from the worker so far. Cleared on each
|
|
/// new submission. Mid-stream this is what the transcript shows
|
|
/// for the in-flight turn.
|
|
pub partial: String,
|
|
/// In-flight worker; `take()`n when it finishes.
|
|
pub thread: Option<std::thread::JoinHandle<anyhow::Result<kebab_core::Answer>>>,
|
|
/// Token receiver paired with the worker's `Sender`. Drained
|
|
/// every render frame.
|
|
pub rx: Option<std::sync::mpsc::Receiver<kebab_app::StreamEvent>>,
|
|
/// Vertical scroll offset for the transcript area when content
|
|
/// exceeds the viewport. Only consulted when `follow_tail` is
|
|
/// false; otherwise the renderer overrides this with the
|
|
/// computed bottom offset.
|
|
pub scroll: u16,
|
|
/// p9-fb-22: when true, the renderer pins the transcript to the
|
|
/// bottom on every frame (so streaming tokens and freshly-
|
|
/// completed turns are visible without manual scrolling). Set
|
|
/// to false the first time the user scrolls up (`k`); restored
|
|
/// to true by `G`, `Ctrl-L`, and a new submission.
|
|
pub follow_tail: bool,
|
|
/// Last error from the worker thread (rendered in popup if Some).
|
|
pub last_error: Option<String>,
|
|
/// p9-fb-16: completed turns of the current conversation. Each
|
|
/// turn = (question, full answer text, citations, ts). Streaming
|
|
/// turn (the one being generated right now) lives in
|
|
/// `current_question` + `partial` and only graduates into
|
|
/// `turns` on `poll_worker` completion.
|
|
pub turns: Vec<kebab_core::Turn>,
|
|
/// p9-fb-16: question text for the in-flight turn. Cleared at
|
|
/// submission (input → current_question, input → empty),
|
|
/// finalized into the new Turn at completion.
|
|
pub current_question: Option<String>,
|
|
/// p9-fb-16: shared id stamped onto every `Answer` of this
|
|
/// conversation. Auto-generated on first submission, cleared by
|
|
/// `Ctrl-L` (next submission generates a fresh id).
|
|
pub conversation_id: Option<String>,
|
|
/// p9-fb-16: most-recent `Answer` for citation / status display
|
|
/// in the right panel. Same data also lives inside the last
|
|
/// `Turn`; this slot is just the easiest place for the panel
|
|
/// renderer to look.
|
|
pub last_answer: Option<kebab_core::Answer>,
|
|
/// p9-fb-41: toggle for the multi-hop pipeline. `F2` flips it
|
|
/// from the Ask pane; the next `Enter` snapshot picks the value
|
|
/// into `AskOpts.multi_hop` before spawning the worker. Default
|
|
/// `false` (single-pass). Conversation history (`turns`) survives
|
|
/// the toggle — flipping mid-conversation just changes the
|
|
/// pipeline used for the *next* turn.
|
|
pub multi_hop: bool,
|
|
}
|
|
|
|
impl Default for AskState {
|
|
fn default() -> Self {
|
|
Self {
|
|
input: crate::input::InputBuffer::default(),
|
|
explain: false,
|
|
streaming: false,
|
|
partial: String::new(),
|
|
thread: None,
|
|
rx: None,
|
|
scroll: 0,
|
|
// p9-fb-22: default to follow-tail so a freshly opened
|
|
// Ask pane auto-scrolls when the first answer streams in.
|
|
follow_tail: true,
|
|
last_error: None,
|
|
turns: Vec::new(),
|
|
current_question: None,
|
|
conversation_id: None,
|
|
last_answer: None,
|
|
multi_hop: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What the Inspect pane is currently showing — owned by p9-4.
|
|
#[derive(Clone, Debug)]
|
|
pub enum InspectTarget {
|
|
Doc(kebab_core::DocumentId),
|
|
Chunk(kebab_core::ChunkId),
|
|
}
|
|
|
|
/// Inspect pane state — owned by p9-4.
|
|
///
|
|
/// Read-only view; data fetched on each target change via the
|
|
/// `kebab-app::inspect_*_with_config` facade (run-loop hook).
|
|
pub struct InspectState {
|
|
pub target: Option<InspectTarget>,
|
|
pub doc: Option<kebab_core::CanonicalDocument>,
|
|
pub chunk: Option<kebab_core::Chunk>,
|
|
/// Section names currently collapsed (e.g. "metadata", "provenance",
|
|
/// "blocks", "embeddings"). Toggled by `c`.
|
|
pub collapsed: std::collections::HashSet<&'static str>,
|
|
pub scroll: u16,
|
|
/// Pane the user came from — Library or Search. `Esc` returns
|
|
/// here.
|
|
pub return_to: Pane,
|
|
/// True when `target` differs from the last fetched result; the
|
|
/// run loop's idle tick services it.
|
|
pub needs_fetch: bool,
|
|
/// True while the inspect call is in flight (synchronous in v1).
|
|
pub loading: bool,
|
|
}
|
|
|
|
impl Default for InspectState {
|
|
fn default() -> Self {
|
|
Self {
|
|
target: None,
|
|
doc: None,
|
|
chunk: None,
|
|
collapsed: std::collections::HashSet::new(),
|
|
scroll: 0,
|
|
return_to: Pane::Library,
|
|
needs_fetch: false,
|
|
loading: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Background-ingest state — owned by p9-fb-03 + extended by
|
|
/// p9-fb-04 (cancel).
|
|
///
|
|
/// 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_cancellable(.., Some(tx), Some(cancel))`;
|
|
/// 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` is the same `Arc<AtomicBool>` the worker polls at each
|
|
/// step boundary. The `Esc` / `Ctrl-C` key (only while ingest is
|
|
/// in flight) flips it via `cancel.store(true, Ordering::Relaxed)`
|
|
/// — the worker breaks at its next iteration check, emits
|
|
/// `IngestEvent::Aborted { counts: <partial> }`, and joins.
|
|
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>>>,
|
|
/// p9-fb-04: shared cancel token. `Esc` / `Ctrl-C` flip it; the
|
|
/// worker thread polls it at each asset-loop boundary.
|
|
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
}
|
|
|
|
/// 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 {
|
|
pub config: Config,
|
|
/// p9-fb-14: resolved palette + role-style mapping. Built once
|
|
/// in `App::new` from `config.ui.theme` (`"dark"` / `"light"`,
|
|
/// fallback dark on unknown). Every pane reads its styles via
|
|
/// `app.theme.style(Role::X)` instead of inlining
|
|
/// `Style::default().fg(Color::*)`.
|
|
pub theme: crate::theme::Theme,
|
|
pub focus: Pane,
|
|
/// p9-fb-12 (partial): vim-style modal interface. Run loop
|
|
/// intercepts `i` / `Esc` to toggle, pane switches auto-flip via
|
|
/// `Mode::auto_for(pane)`. Status bar renders the label. The
|
|
/// per-pane key handlers still use their pre-fb-12 input-empty
|
|
/// heuristics for j/k vs typing — full mode-authoritative
|
|
/// dispatch is a follow-up PR.
|
|
pub mode: Mode,
|
|
pub library: LibraryState,
|
|
/// Populated by p9-2 (None until that crate links in).
|
|
pub search: Option<SearchState>,
|
|
/// Populated by p9-3.
|
|
pub ask: Option<AskState>,
|
|
/// Populated by p9-4.
|
|
pub inspect: Option<InspectState>,
|
|
/// p9-fb-37: trace popup state, `Some` while open.
|
|
pub trace_popup: Option<crate::trace_popup::TracePopupState>,
|
|
/// 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>,
|
|
/// Set by `handle_key_library` when the user presses `q` / `Esc`
|
|
/// or by a future pane's quit key. The run loop drains this on
|
|
/// each tick.
|
|
pub(crate) should_quit: bool,
|
|
/// p9-fb-09: deferred external-program request. A pane's key
|
|
/// handler enqueues an `EditorRequest` here when the user wants
|
|
/// to spawn `$EDITOR` (e.g. Search `g` jumps to a citation in
|
|
/// vim) — the actual suspend / spawn / restore happens in the
|
|
/// run loop, where the `TuiTerminal` handle is in scope.
|
|
/// Drained every tick after the key dispatch.
|
|
///
|
|
/// `pub(crate)` because the enqueue/take invariant ("set by a
|
|
/// key handler, drained by the next run-loop tick") only holds
|
|
/// for in-crate callers; external mutation could leave a stale
|
|
/// request that never gets serviced.
|
|
pub(crate) pending_editor: Option<EditorRequest>,
|
|
/// p9-fb-09: when set, the next run-loop draw runs
|
|
/// `terminal.clear()` first so any leftover screen content from
|
|
/// a suspension (post-editor, future config-reload, …) is wiped
|
|
/// before Ratatui's diff renders the new frame. Reset back to
|
|
/// false after the clear. Independent of `pending_editor` —
|
|
/// any future code path that needs a forced redraw can flip
|
|
/// this flag.
|
|
pub(crate) force_redraw: bool,
|
|
/// p9-fb-13: cheatsheet popup visibility. Toggled by `F1` (set
|
|
/// via `cheatsheet_intercept` in the run loop). When true, the
|
|
/// renderer overlays a modal listing every keybinding for the
|
|
/// active pane plus the global mode toggles.
|
|
pub(crate) cheatsheet_visible: bool,
|
|
}
|
|
|
|
impl App {
|
|
/// p9-fb-13: read-only accessor for the cheatsheet visibility
|
|
/// flag — used by integration tests to assert the toggle
|
|
/// without exposing the field as `pub` (which would let
|
|
/// external code break the F1-only set/unset invariant).
|
|
pub fn cheatsheet_visible(&self) -> bool {
|
|
self.cheatsheet_visible
|
|
}
|
|
}
|
|
|
|
/// p9-fb-09: external-program spawn request. Posted by a pane's key
|
|
/// handler, serviced by the run loop on the next tick.
|
|
#[derive(Clone, Debug)]
|
|
pub struct EditorRequest {
|
|
pub citation: kebab_core::Citation,
|
|
pub editor_env: String,
|
|
pub workspace_root: std::path::PathBuf,
|
|
}
|
|
|
|
impl App {
|
|
/// Build an `App` against `config`. Does not load documents — the
|
|
/// run loop calls `library.refresh` on first frame so a slow
|
|
/// `kebab-app::list_docs_with_config` does not block startup.
|
|
pub fn new(config: Config) -> anyhow::Result<Self> {
|
|
let theme = crate::theme::Theme::from_name(&config.ui.theme);
|
|
let initial_pane = Pane::Library;
|
|
Ok(Self {
|
|
config,
|
|
theme,
|
|
focus: initial_pane,
|
|
// p9-fb-12: starting pane = Library → Normal mode.
|
|
mode: Mode::auto_for(initial_pane),
|
|
library: LibraryState::new(),
|
|
search: None,
|
|
ask: None,
|
|
inspect: None,
|
|
trace_popup: None,
|
|
ingest_state: None,
|
|
error_overlay: None,
|
|
should_quit: false,
|
|
pending_editor: None,
|
|
force_redraw: false,
|
|
cheatsheet_visible: false,
|
|
})
|
|
}
|
|
|
|
/// Read-only accessor for the in-flight external-program request.
|
|
/// Tests and future external observers (e.g. integration smokes)
|
|
/// use this to assert that a key dispatch enqueued a spawn —
|
|
/// mutating the slot stays `pub(crate)` to preserve the
|
|
/// "set-then-drained-on-next-tick" invariant.
|
|
pub fn pending_editor(&self) -> Option<&EditorRequest> {
|
|
self.pending_editor.as_ref()
|
|
}
|
|
|
|
/// Blocking event loop. Returns when the user quits or a fatal
|
|
/// error escapes the loop (terminal raw-mode is restored either
|
|
/// way via the `Terminal` Drop guard).
|
|
pub fn run(&mut self) -> anyhow::Result<()> {
|
|
crate::run::run_loop(self)
|
|
}
|
|
|
|
/// Test-only: hand-populate the Library pane with docs without
|
|
/// going through `kebab-app::list_docs_with_config`. Snapshot /
|
|
/// key-handler tests use this to drive a deterministic view
|
|
/// instead of standing up a TempDir SQLite KB.
|
|
///
|
|
/// Marked `#[doc(hidden)]` because it is a test seam, not part
|
|
/// of the official UI API.
|
|
#[doc(hidden)]
|
|
pub fn populate_library_for_testing(&mut self, docs: Vec<kebab_core::DocSummary>) {
|
|
self.library.inner.docs = docs;
|
|
self.library.inner.needs_refresh = false;
|
|
let len = self.library.inner.docs.len();
|
|
if len == 0 {
|
|
self.library.inner.list_state.select(None);
|
|
} else {
|
|
self.library.inner.list_state.select(Some(0));
|
|
}
|
|
}
|
|
|
|
/// Test-only: read back the current Library doc filter so tests
|
|
/// can assert on what `FilterEdit::commit_into` produced after a
|
|
/// simulated Enter key. Never call this in the render path.
|
|
///
|
|
/// Marked `#[doc(hidden)]` because it is a test seam, not part
|
|
/// of the official UI API.
|
|
#[doc(hidden)]
|
|
pub fn library_filter_for_testing(&self) -> &kebab_core::DocFilter {
|
|
&self.library.inner.filter
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod mode_tests {
|
|
use super::*;
|
|
|
|
/// p9-fb-12: Library / Inspect / Jobs auto-Normal; Search / Ask
|
|
/// auto-Insert. Pin so a future pane addition has to think
|
|
/// explicitly about its starting mode.
|
|
#[test]
|
|
fn auto_for_pane_routes_to_natural_mode() {
|
|
assert_eq!(Mode::auto_for(Pane::Library), Mode::Normal);
|
|
assert_eq!(Mode::auto_for(Pane::Inspect), Mode::Normal);
|
|
assert_eq!(Mode::auto_for(Pane::Jobs), Mode::Normal);
|
|
assert_eq!(Mode::auto_for(Pane::Search), Mode::Insert);
|
|
assert_eq!(Mode::auto_for(Pane::Ask), Mode::Insert);
|
|
}
|
|
|
|
/// p9-fb-12: status-bar label literals are part of the contract
|
|
/// (the user sees them; tests / docs reference them).
|
|
#[test]
|
|
fn label_literals_stable() {
|
|
assert_eq!(Mode::Normal.label(), "-- NORMAL --");
|
|
assert_eq!(Mode::Insert.label(), "-- INSERT --");
|
|
}
|
|
|
|
/// p9-fb-12: default `Mode` = `Normal` (the safe non-typing
|
|
/// state). Pin so a future #[derive(Default)] tweak doesn't
|
|
/// silently flip.
|
|
#[test]
|
|
fn default_is_normal() {
|
|
assert_eq!(Mode::default(), Mode::Normal);
|
|
}
|
|
}
|