feat(kebab-tui): P9-1 Ratatui shell + Library pane
새 crate `kebab-tui` 가 §8 facade rule 따라 `kebab-app` 만 import. Ratatui 0.28 + crossterm 0.28 기반 shell 이 다음을 제공: - `App` 구조체: config + focus + library + 3 Option sub-state slot (search/ask/inspect — p9-2/3/4 가 자기 모듈에서 채우는 parallel-safety contract). p9-1 외에 App 정의 손대지 않음. - `Pane` enum (Library/Search/Ask/Inspect/Jobs). - `KeyOutcome` (Continue/Quit/SwitchPane/Refresh). - `LibraryState` + 내부 inner: docs / list_state / filter / filter_edit / needs_refresh / loading / pending_g. - `render_library` (Frame, area, &App) — heading/body, filter overlay toggleable, Korean/wide-char 너비는 unicode-width 로 계산. - `handle_key_library`: j/k/Down/Up 이동, gg/G 끝, f 필터 overlay, /=>Search ?=>Ask Enter=>Inspect, q/Esc 종료. error overlay 가 켜 있으면 어떤 키든 dismiss. - 필터 overlay: tags_any (CSV) + lang 두 필드, Tab cycle, Enter apply→Refresh, Esc cancel. - `ErrorOverlay`: anyhow chain 캡쳐 후 popup 렌더 (Clear + 빨간 border). - 터미널 lifecycle: `TuiTerminal` 가 enter raw mode + alt screen, Drop 이 종료 시 (panic 포함) restore — 사용자 쉘 깨지지 않게. - 비동기 없음: facade 호출은 main thread 동기. v1 의 brief hang 수용. CLI: `kebab tui` 서브커맨드 추가, --config 받아 App::new + run. 테스트 10건 (`tests/library.rs`, TestBackend 사용): - 빈 library / 3-doc render / q,Esc quit / / Search 전환 / ? Ask 전환 - Enter 빈 list 무동작 / Enter Inspect 전환 / j 이동 (3-step clamp) / f 필터 overlay → 입력 → Enter Refresh. Test seam: `App::populate_library_for_testing` (#[doc(hidden)]) 가 `pub(crate)` inner 를 우회. spec parallel-safety contract 그대로 유지. Spec deviation (HOTFIXES `2026-05-02 P9-1`): - `render_library` 의 `<B: Backend>` generic 제거 — ratatui 0.28 의 Frame 이 backend-agnostic. - `populate_library_for_testing` 추가 (test seam, 공식 API 아님). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
153
Cargo.lock
generated
153
Cargo.lock
generated
@@ -813,6 +813,12 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
@@ -936,7 +942,21 @@ checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56"
|
||||
dependencies = [
|
||||
"strum 0.26.3",
|
||||
"strum_macros 0.26.4",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -972,7 +992,7 @@ dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -1107,6 +1127,31 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
@@ -3222,10 +3267,32 @@ dependencies = [
|
||||
"console",
|
||||
"number_prefix",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c"
|
||||
dependencies = [
|
||||
"darling 0.20.11",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "integer-encoding"
|
||||
version = "3.0.4"
|
||||
@@ -3454,6 +3521,7 @@ dependencies = [
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"kebab-eval",
|
||||
"kebab-tui",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -3733,6 +3801,23 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kebab-tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossterm",
|
||||
"kebab-app",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
"ratatui",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tracing",
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lance"
|
||||
version = "1.0.1"
|
||||
@@ -4748,6 +4833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -5817,6 +5903,27 @@ version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cassowary",
|
||||
"compact_str 0.8.1",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"itertools 0.13.0",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum 0.26.3",
|
||||
"strum_macros 0.26.4",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
@@ -6575,6 +6682,27 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -7223,7 +7351,7 @@ checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"aho-corasick",
|
||||
"compact_str",
|
||||
"compact_str 0.9.0",
|
||||
"dary_heap",
|
||||
"derive_builder",
|
||||
"esaxx-rs",
|
||||
@@ -7597,6 +7725,23 @@ version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
|
||||
@@ -21,6 +21,7 @@ members = [
|
||||
"crates/kebab-eval",
|
||||
"crates/kebab-parse-image",
|
||||
"crates/kebab-parse-pdf",
|
||||
"crates/kebab-tui",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -23,6 +23,10 @@ kebab-app = { path = "../kebab-app" }
|
||||
# kb-cli → kb-eval directly; documented in
|
||||
# `tasks/p5/p5-2-metrics-compare.md`.
|
||||
kebab-eval = { path = "../kebab-eval" }
|
||||
# P9-1: Ratatui shell. UI consumes `kebab-app` only — `kebab-tui`
|
||||
# enforces the §8 boundary in its own Cargo.toml; kb-cli just
|
||||
# launches it.
|
||||
kebab-tui = { path = "../kebab-tui" }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
@@ -102,6 +102,10 @@ enum Cmd {
|
||||
/// Health check.
|
||||
Doctor,
|
||||
|
||||
/// Launch the Ratatui shell (P9-1 — Library pane only; search /
|
||||
/// ask / inspect panes land with p9-2 / p9-3 / p9-4).
|
||||
Tui,
|
||||
|
||||
/// Eval suite (placeholder; lands in P9).
|
||||
Eval {
|
||||
#[command(subcommand)]
|
||||
@@ -400,6 +404,17 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::Tui => {
|
||||
// P9-1: Ratatui shell with Library pane. Search / Ask /
|
||||
// Inspect panes land in p9-2 / p9-3 / p9-4.
|
||||
let config = match cli.config.as_deref() {
|
||||
Some(path) => kebab_config::Config::load(Some(path))?,
|
||||
None => kebab_config::Config::load(None)?,
|
||||
};
|
||||
let mut app = kebab_tui::App::new(config)?;
|
||||
app.run()
|
||||
}
|
||||
|
||||
Cmd::Eval { what } => match what {
|
||||
EvalWhat::Run {
|
||||
suite,
|
||||
|
||||
28
crates/kebab-tui/Cargo.toml
Normal file
28
crates/kebab-tui/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "kebab-tui"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
license = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
description = "Ratatui shell + Library pane for kebab — UI consumes kebab-app facade only (P9-1)"
|
||||
|
||||
[dependencies]
|
||||
kebab-core = { path = "../kebab-core" }
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
# UI facade rule (design §8): UI crates may only touch `kebab-app`. The
|
||||
# search / store / embed / llm / rag layers stay invisible behind it.
|
||||
kebab-app = { path = "../kebab-app" }
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.28"
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
time = { workspace = true }
|
||||
# Korean / wide-char column width — Ratatui's `Span` truncates by chars,
|
||||
# not display width, so a list cell with `한` (width 2) followed by `a`
|
||||
# (width 1) overflows by one column without explicit width accounting.
|
||||
unicode-width = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
132
crates/kebab-tui/src/app.rs
Normal file
132
crates/kebab-tui/src/app.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! `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,
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward-declared opaque sub-state. p9-2 fills the body in its own
|
||||
/// crate. P9-1 only allocates the slot (`Option<SearchState>` on
|
||||
/// `App`).
|
||||
pub struct SearchState;
|
||||
|
||||
/// Forward-declared opaque sub-state. p9-3 fills the body.
|
||||
pub struct AskState;
|
||||
|
||||
/// Forward-declared opaque sub-state. p9-4 fills the body.
|
||||
pub struct InspectState;
|
||||
|
||||
/// 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,
|
||||
pub focus: Pane,
|
||||
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>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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> {
|
||||
Ok(Self {
|
||||
config,
|
||||
focus: Pane::Library,
|
||||
library: LibraryState::new(),
|
||||
search: None,
|
||||
ask: None,
|
||||
inspect: None,
|
||||
error_overlay: None,
|
||||
should_quit: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
75
crates/kebab-tui/src/error_popup.rs
Normal file
75
crates/kebab-tui/src/error_popup.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! Error popup overlay — rendered on top of any pane when the last
|
||||
//! facade call returned `Err`. Any key dismisses (handled by the
|
||||
//! pane's key handler before its own dispatch).
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||
|
||||
/// Captured snapshot of an `anyhow::Error` for rendering. We do NOT
|
||||
/// store the `anyhow::Error` itself (it is `!Sync` in pre-1.0.99
|
||||
/// versions on some toolchains and would force lifetime gymnastics
|
||||
/// on `App`); we render the formatted chain at capture time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ErrorOverlay {
|
||||
pub title: String,
|
||||
/// Each chain link as a separate line, root-cause last.
|
||||
pub chain: Vec<String>,
|
||||
}
|
||||
|
||||
impl ErrorOverlay {
|
||||
pub fn from_anyhow(err: &anyhow::Error) -> Self {
|
||||
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
|
||||
Self {
|
||||
title: "error".to_string(),
|
||||
chain,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_message(title: impl Into<String>, msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
chain: vec![msg.into()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the popup centred in `area`. Caller is responsible for
|
||||
/// clearing the underlying region (`Clear` widget); we do that here.
|
||||
pub fn render_error_overlay(f: &mut Frame, area: Rect, overlay: &ErrorOverlay) {
|
||||
let popup_area = centered_rect(area, 60, 50);
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(overlay.chain.len() + 2);
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{}: {}", overlay.title, overlay.chain.first().map_or("(unknown)", String::as_str)),
|
||||
Style::default()
|
||||
.fg(Color::Red)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for cause in overlay.chain.iter().skip(1) {
|
||||
lines.push(Line::from(format!(" caused by: {cause}")));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"press any key to dismiss",
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
)));
|
||||
|
||||
let block = Block::default()
|
||||
.title("error")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Red));
|
||||
let para = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
|
||||
f.render_widget(para, popup_area);
|
||||
}
|
||||
|
||||
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
|
||||
let w = (area.width * percent_x / 100).max(20).min(area.width);
|
||||
let h = (area.height * percent_y / 100).max(5).min(area.height);
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
Rect::new(x, y, w, h)
|
||||
}
|
||||
23
crates/kebab-tui/src/lib.rs
Normal file
23
crates/kebab-tui/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! `kebab-tui` — Ratatui shell + Library pane (P9-1).
|
||||
//!
|
||||
//! Per design §8 module boundary: UI crates may only touch the
|
||||
//! `kebab-app` facade. The store / search / embed / llm / rag layers
|
||||
//! stay invisible behind it. P9-1 establishes the shell (App loop,
|
||||
//! key dispatch, error popup, raw-mode panic guard) plus the Library
|
||||
//! pane. P9-2/3/4 plug into the same `App` struct via the
|
||||
//! `Option<*State>` slot pattern (parallel-safety: their sub-state
|
||||
//! types start as `pub struct *State;` opaque forward declarations
|
||||
//! and only their authoring crate fills the body).
|
||||
//!
|
||||
//! Per report §16.2 (TUI epic), design §1 (UX scenes), design §3.7
|
||||
//! (`SearchHit` / `DocSummary`).
|
||||
|
||||
mod app;
|
||||
mod error_popup;
|
||||
mod library;
|
||||
mod run;
|
||||
mod terminal;
|
||||
|
||||
pub use app::{App, AskState, InspectState, KeyOutcome, LibraryState, Pane, SearchState};
|
||||
pub use error_popup::{ErrorOverlay, render_error_overlay};
|
||||
pub use library::{handle_key_library, render_library};
|
||||
375
crates/kebab-tui/src/library.rs
Normal file
375
crates/kebab-tui/src/library.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
//! Library pane — list + filter + key dispatch.
|
||||
//!
|
||||
//! State / render / key handler are kept in one module so the slot
|
||||
//! pattern (p9-2/3/4 in their own modules) has a clear template to
|
||||
//! follow. The renderer is `Frame`-typed — ratatui 0.28 dropped the
|
||||
//! `B: Backend` generic from `Frame` (it's bound at `Terminal` init),
|
||||
//! so the spec's `render_library<B: Backend>` literal is collapsed
|
||||
//! here. Logged in HOTFIXES.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use kebab_core::{DocFilter, DocSummary, Lang};
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app::{App, KeyOutcome, Pane};
|
||||
|
||||
/// Internal state owned by `LibraryState`. Public-by-crate so
|
||||
/// `handle_key_library` can mutate it without crossing the
|
||||
/// `pub`-visibility boundary `LibraryState` exposes.
|
||||
pub(crate) struct LibraryStateInner {
|
||||
pub docs: Vec<DocSummary>,
|
||||
pub list_state: ListState,
|
||||
pub filter: DocFilter,
|
||||
/// Edit overlay for the filter (toggled by `f`). `Some` while
|
||||
/// the user is editing tags / lang fields.
|
||||
pub filter_edit: Option<FilterEdit>,
|
||||
/// True after `App::new` and again after every filter refresh,
|
||||
/// flipped to false once the run loop services the refresh.
|
||||
pub needs_refresh: bool,
|
||||
/// True while the run loop is awaiting `kebab-app::list_docs_with_config`
|
||||
/// — drives the "loading…" header span. Synchronous in v1
|
||||
/// (acceptable hang per spec).
|
||||
pub loading: bool,
|
||||
/// `g` waiting for the second `g` (vim-style `gg` → top).
|
||||
pub pending_g: bool,
|
||||
}
|
||||
|
||||
impl Default for LibraryStateInner {
|
||||
fn default() -> Self {
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(None);
|
||||
Self {
|
||||
docs: Vec::new(),
|
||||
list_state,
|
||||
filter: DocFilter::default(),
|
||||
filter_edit: None,
|
||||
needs_refresh: true,
|
||||
loading: false,
|
||||
pending_g: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter edit overlay state. `f` toggles in/out of edit mode;
|
||||
/// while editing, `tab` cycles between fields and `Enter` commits.
|
||||
pub(crate) struct FilterEdit {
|
||||
pub field: FilterField,
|
||||
pub tags_buf: String,
|
||||
pub lang_buf: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum FilterField {
|
||||
Tags,
|
||||
Lang,
|
||||
}
|
||||
|
||||
impl FilterEdit {
|
||||
pub fn from_filter(filter: &DocFilter) -> Self {
|
||||
Self {
|
||||
field: FilterField::Tags,
|
||||
tags_buf: filter.tags_any.join(","),
|
||||
lang_buf: filter
|
||||
.lang
|
||||
.as_ref()
|
||||
.map(|l| l.0.clone())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_into(&self, filter: &mut DocFilter) {
|
||||
filter.tags_any = self
|
||||
.tags_buf
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let trimmed = self.lang_buf.trim();
|
||||
filter.lang = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Lang(trimmed.to_string()))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the Library pane. `area` is the full body region;
|
||||
/// header / footer are owned by the run loop.
|
||||
pub fn render_library(f: &mut Frame, area: Rect, state: &App) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(filter_overlay_height(state)),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
if let Some(edit) = &state.library.inner.filter_edit {
|
||||
render_filter_overlay(f, layout[0], edit);
|
||||
}
|
||||
render_doc_list(f, layout[1], state);
|
||||
}
|
||||
|
||||
fn filter_overlay_height(state: &App) -> u16 {
|
||||
if state.library.inner.filter_edit.is_some() {
|
||||
4
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn render_filter_overlay(f: &mut Frame, area: Rect, edit: &FilterEdit) {
|
||||
let block = Block::default()
|
||||
.title("Filter (Tab=cycle field, Enter=apply, Esc=cancel)")
|
||||
.borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let lines = vec![
|
||||
line_with_focus("tags_any (csv): ", &edit.tags_buf, edit.field == FilterField::Tags),
|
||||
line_with_focus("lang: ", &edit.lang_buf, edit.field == FilterField::Lang),
|
||||
];
|
||||
let para = Paragraph::new(lines);
|
||||
f.render_widget(para, inner);
|
||||
}
|
||||
|
||||
fn line_with_focus<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {
|
||||
let style = if focused {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::from(vec![Span::raw(label), Span::styled(value, style)])
|
||||
}
|
||||
|
||||
fn render_doc_list(f: &mut Frame, area: Rect, state: &App) {
|
||||
let inner = &state.library.inner;
|
||||
let header_text = if inner.loading {
|
||||
"Library — loading…"
|
||||
} else if inner.docs.is_empty() {
|
||||
"Library — no docs (run `kebab ingest` first, then press F5 or re-open)"
|
||||
} else {
|
||||
"Library"
|
||||
};
|
||||
let block = Block::default().title(header_text).borders(Borders::ALL);
|
||||
|
||||
if inner.docs.is_empty() {
|
||||
f.render_widget(block, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let title_w = (area.width as usize).saturating_sub(40).max(20);
|
||||
let items: Vec<ListItem> = inner
|
||||
.docs
|
||||
.iter()
|
||||
.map(|d| ListItem::new(format_doc_row(d, title_w)))
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(block)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
|
||||
.highlight_symbol("> ");
|
||||
|
||||
let mut list_state = inner.list_state.clone();
|
||||
f.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Format a `DocSummary` row using display-width-aware truncation
|
||||
/// (Korean / wide chars contribute 2 columns each).
|
||||
pub(crate) fn format_doc_row(d: &DocSummary, title_w: usize) -> String {
|
||||
let title = truncate_to_display_width(&d.title, title_w);
|
||||
let tags = if d.tags.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
d.tags.join(",")
|
||||
};
|
||||
let updated = d
|
||||
.updated_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "?".to_string());
|
||||
let updated_short = updated.split('T').next().unwrap_or("?");
|
||||
format!(
|
||||
"{title:<title_w$} {tags:<12} {updated_short:<10} {chunk_count}",
|
||||
title = title,
|
||||
tags = truncate_to_display_width(&tags, 12),
|
||||
updated_short = updated_short,
|
||||
chunk_count = d.chunk_count,
|
||||
title_w = title_w,
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_to_display_width(s: &str, max_cols: usize) -> String {
|
||||
if s.width() <= max_cols {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut cols = 0;
|
||||
for ch in s.chars() {
|
||||
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if cols + w > max_cols.saturating_sub(1) {
|
||||
out.push('…');
|
||||
return out;
|
||||
}
|
||||
cols += w;
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Library pane key dispatch. Mutates `App.library.inner`; never
|
||||
/// touches another pane's state (parallel-safety contract).
|
||||
pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
||||
if state.error_overlay.is_some() {
|
||||
// Any key dismisses the popup.
|
||||
state.error_overlay = None;
|
||||
return KeyOutcome::Continue;
|
||||
}
|
||||
|
||||
if state.library.inner.filter_edit.is_some() {
|
||||
return handle_filter_edit_key(state, key);
|
||||
}
|
||||
|
||||
let inner = &mut state.library.inner;
|
||||
let pending_g = std::mem::take(&mut inner.pending_g);
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('q'), _) | (KeyCode::Esc, _) => {
|
||||
state.should_quit = true;
|
||||
KeyOutcome::Quit
|
||||
}
|
||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
|
||||
move_selection(inner, 1);
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
|
||||
move_selection(inner, -1);
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
(KeyCode::Char('g'), m) if !m.contains(KeyModifiers::SHIFT) => {
|
||||
if pending_g {
|
||||
set_selection(inner, 0);
|
||||
KeyOutcome::Continue
|
||||
} else {
|
||||
inner.pending_g = true;
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('G'), _) => {
|
||||
let last = inner.docs.len().saturating_sub(1);
|
||||
set_selection(inner, last);
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
(KeyCode::Char('f'), _) => {
|
||||
inner.filter_edit = Some(FilterEdit::from_filter(&inner.filter));
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
(KeyCode::Char('/'), _) => KeyOutcome::SwitchPane(Pane::Search),
|
||||
(KeyCode::Char('?'), _) => KeyOutcome::SwitchPane(Pane::Ask),
|
||||
(KeyCode::Enter, _) => {
|
||||
if inner.docs.is_empty() {
|
||||
KeyOutcome::Continue
|
||||
} else {
|
||||
KeyOutcome::SwitchPane(Pane::Inspect)
|
||||
}
|
||||
}
|
||||
_ => KeyOutcome::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_filter_edit_key(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
||||
let Some(edit) = state.library.inner.filter_edit.as_mut() else {
|
||||
return KeyOutcome::Continue;
|
||||
};
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
state.library.inner.filter_edit = None;
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
edit.field = match edit.field {
|
||||
FilterField::Tags => FilterField::Lang,
|
||||
FilterField::Lang => FilterField::Tags,
|
||||
};
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let edit = state.library.inner.filter_edit.take().unwrap();
|
||||
edit.commit_into(&mut state.library.inner.filter);
|
||||
state.library.inner.needs_refresh = true;
|
||||
KeyOutcome::Refresh
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let buf = match edit.field {
|
||||
FilterField::Tags => &mut edit.tags_buf,
|
||||
FilterField::Lang => &mut edit.lang_buf,
|
||||
};
|
||||
buf.pop();
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
let buf = match edit.field {
|
||||
FilterField::Tags => &mut edit.tags_buf,
|
||||
FilterField::Lang => &mut edit.lang_buf,
|
||||
};
|
||||
buf.push(c);
|
||||
KeyOutcome::Continue
|
||||
}
|
||||
_ => KeyOutcome::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
fn move_selection(inner: &mut LibraryStateInner, delta: i32) {
|
||||
if inner.docs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = inner.list_state.selected().unwrap_or(0) as i32;
|
||||
let last = (inner.docs.len() as i32) - 1;
|
||||
let next = (current + delta).clamp(0, last);
|
||||
inner.list_state.select(Some(next as usize));
|
||||
}
|
||||
|
||||
fn set_selection(inner: &mut LibraryStateInner, idx: usize) {
|
||||
if inner.docs.is_empty() {
|
||||
inner.list_state.select(None);
|
||||
} else {
|
||||
let clamped = idx.min(inner.docs.len() - 1);
|
||||
inner.list_state.select(Some(clamped));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run-loop hook: refresh `docs` from the facade. Public-by-crate
|
||||
/// because the run loop owns the call site.
|
||||
pub(crate) fn refresh_docs(state: &mut App) -> anyhow::Result<()> {
|
||||
state.library.inner.loading = true;
|
||||
let result = kebab_app::list_docs_with_config(
|
||||
state.config.clone(),
|
||||
state.library.inner.filter.clone(),
|
||||
);
|
||||
state.library.inner.loading = false;
|
||||
match result {
|
||||
Ok(docs) => {
|
||||
let prior = state.library.inner.list_state.selected();
|
||||
state.library.inner.docs = docs;
|
||||
// Clamp selection.
|
||||
let len = state.library.inner.docs.len();
|
||||
if len == 0 {
|
||||
state.library.inner.list_state.select(None);
|
||||
} else {
|
||||
let next = prior.map(|p| p.min(len - 1)).unwrap_or(0);
|
||||
state.library.inner.list_state.select(Some(next));
|
||||
}
|
||||
state.library.inner.needs_refresh = false;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
state.library.inner.needs_refresh = false;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
crates/kebab-tui/src/run.rs
Normal file
131
crates/kebab-tui/src/run.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Run loop — owns the event poll + render cycle. Pane-specific
|
||||
//! key handlers are dispatched on focus.
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{self, Event, KeyEventKind};
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::{App, KeyOutcome, Pane};
|
||||
use crate::error_popup::{ErrorOverlay, render_error_overlay};
|
||||
use crate::library::{handle_key_library, refresh_docs, render_library};
|
||||
use crate::terminal::TuiTerminal;
|
||||
|
||||
/// Poll interval for crossterm's `event::poll`. Short enough that a
|
||||
/// pending data refresh shows up promptly, long enough that an idle
|
||||
/// app doesn't spin the CPU.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(150);
|
||||
|
||||
pub(crate) fn run_loop(app: &mut App) -> Result<()> {
|
||||
let mut terminal = TuiTerminal::enter()?;
|
||||
|
||||
while !app.should_quit {
|
||||
if app.library.inner.needs_refresh
|
||||
&& app.focus == Pane::Library
|
||||
&& app.error_overlay.is_none()
|
||||
{
|
||||
if let Err(e) = refresh_docs(app) {
|
||||
app.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
|
||||
}
|
||||
}
|
||||
|
||||
terminal.inner.draw(|f| render_root(f, app))?;
|
||||
|
||||
if event::poll(POLL_INTERVAL)? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
let outcome = match app.focus {
|
||||
Pane::Library => handle_key_library(app, key),
|
||||
// p9-2/3/4 plug their handlers here as their
|
||||
// crates land. Until then, any non-Library
|
||||
// pane behaves like Library (we never switch
|
||||
// to them at present).
|
||||
Pane::Search | Pane::Ask | Pane::Inspect | Pane::Jobs => {
|
||||
handle_key_library(app, key)
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
KeyOutcome::Quit => app.should_quit = true,
|
||||
KeyOutcome::SwitchPane(p) => app.focus = p,
|
||||
KeyOutcome::Refresh => {
|
||||
// `needs_refresh` was already set by the
|
||||
// pane handler; the next loop iteration
|
||||
// services it.
|
||||
}
|
||||
KeyOutcome::Continue => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_root(f: &mut Frame, app: &App) {
|
||||
let outer = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(f.area());
|
||||
render_header(f, outer[0], app);
|
||||
match app.focus {
|
||||
Pane::Library => render_library(f, outer[1], app),
|
||||
// Until p9-2/3/4 land, the run loop never actually moves
|
||||
// focus to those panes; render_library serves as a safe
|
||||
// placeholder.
|
||||
_ => render_library(f, outer[1], app),
|
||||
}
|
||||
render_footer(f, outer[2], app);
|
||||
if let Some(err) = &app.error_overlay {
|
||||
render_error_overlay(f, f.area(), err);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(f: &mut Frame, area: Rect, app: &App) {
|
||||
let pane_label = match app.focus {
|
||||
Pane::Library => "Library",
|
||||
Pane::Search => "Search",
|
||||
Pane::Ask => "Ask",
|
||||
Pane::Inspect => "Inspect",
|
||||
Pane::Jobs => "Jobs",
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
"kebab",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" / "),
|
||||
Span::raw(pane_label),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(line), area);
|
||||
}
|
||||
|
||||
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
let hints = match app.focus {
|
||||
Pane::Library => {
|
||||
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"
|
||||
}
|
||||
}
|
||||
_ => "q=quit",
|
||||
};
|
||||
let line = Line::from(Span::styled(
|
||||
hints,
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
));
|
||||
f.render_widget(
|
||||
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
37
crates/kebab-tui/src/terminal.rs
Normal file
37
crates/kebab-tui/src/terminal.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! Terminal raw-mode / alternate-screen lifecycle. Critical: the
|
||||
//! `Drop` impl must restore the terminal even if the run loop panics
|
||||
//! — otherwise the user is left with a corrupted shell.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use std::io::{Stdout, stdout};
|
||||
|
||||
pub(crate) struct TuiTerminal {
|
||||
pub inner: Terminal<CrosstermBackend<Stdout>>,
|
||||
}
|
||||
|
||||
impl TuiTerminal {
|
||||
pub fn enter() -> Result<Self> {
|
||||
enable_raw_mode().context("crossterm: enable_raw_mode")?;
|
||||
let mut out = stdout();
|
||||
execute!(out, EnterAlternateScreen).context("crossterm: EnterAlternateScreen")?;
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let inner = Terminal::new(backend).context("ratatui Terminal::new")?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TuiTerminal {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort. Errors here would clobber a real panic if we
|
||||
// propagated them; just log and let the OS recover any
|
||||
// remaining noise.
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(stdout(), LeaveAlternateScreen);
|
||||
}
|
||||
}
|
||||
217
crates/kebab-tui/tests/library.rs
Normal file
217
crates/kebab-tui/tests/library.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! Unit + snapshot tests for the Library pane.
|
||||
//!
|
||||
//! Snapshot tests use `ratatui::backend::TestBackend` so the run loop
|
||||
//! is bypassed entirely — we drive `render_library` directly against
|
||||
//! a synthetic `App`.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{
|
||||
ChunkerVersion, DocSummary, DocumentId, Lang, ParserVersion, SourceType, TrustLevel,
|
||||
WorkspacePath,
|
||||
};
|
||||
use kebab_tui::{App, KeyOutcome, Pane, render_library};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn make_doc(path: &str, title: &str, tags: Vec<&str>) -> DocSummary {
|
||||
DocSummary {
|
||||
doc_id: DocumentId(format!("{:0<32}", path.chars().filter(|c| c.is_alphanumeric()).collect::<String>())),
|
||||
doc_path: WorkspacePath::new(path.into()).unwrap(),
|
||||
title: title.into(),
|
||||
lang: Lang("en".into()),
|
||||
tags: tags.into_iter().map(String::from).collect(),
|
||||
trust_level: TrustLevel::Primary,
|
||||
source_type: SourceType::Note,
|
||||
byte_len: 1024,
|
||||
chunk_count: 4,
|
||||
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
||||
parser_version: ParserVersion("test-parser".into()),
|
||||
chunker_version: ChunkerVersion("test-chunker".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_with_docs(docs: Vec<DocSummary>) -> App {
|
||||
let mut config = Config::defaults();
|
||||
// Storage paths point at /tmp so any accidental facade call
|
||||
// would not touch the user's real KB. Tests below use the
|
||||
// `populate_library_for_testing` test seam, never the facade.
|
||||
config.storage.data_dir = "/tmp/kebab-tui-tests-noop".to_string();
|
||||
let mut app = App::new(config).expect("App::new must succeed with defaults");
|
||||
app.populate_library_for_testing(docs);
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_library_renders_block_only_no_panic() {
|
||||
let app = app_with_docs(vec![]);
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 80, 20);
|
||||
render_library(f, area, &app);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let rendered: String = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
rendered.contains("Library"),
|
||||
"rendered frame must show Library header: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("no docs") || rendered.contains("Library"),
|
||||
"empty state hint should appear in the header line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_q_quits() {
|
||||
let mut app = app_with_docs(vec![]);
|
||||
let outcome = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::Quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_esc_quits_when_no_overlay() {
|
||||
let mut app = app_with_docs(vec![]);
|
||||
let outcome = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::Quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_slash_switches_to_search() {
|
||||
let mut app = app_with_docs(vec![]);
|
||||
let outcome = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Search));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_question_switches_to_ask() {
|
||||
let mut app = app_with_docs(vec![]);
|
||||
let outcome = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Ask));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_enter_does_not_switch_when_empty() {
|
||||
let mut app = app_with_docs(vec![]);
|
||||
let outcome = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::Continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_with_docs_renders_titles() {
|
||||
let app = app_with_docs(vec![
|
||||
make_doc("notes/foo.md", "Foo", vec!["alpha"]),
|
||||
make_doc("notes/bar.md", "Bar", vec!["beta", "gamma"]),
|
||||
make_doc("notes/baz.md", "Baz Title", vec![]),
|
||||
]);
|
||||
let backend = TestBackend::new(80, 10);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
render_library(f, area, &app);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let rendered: String = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
for title in &["Foo", "Bar", "Baz Title"] {
|
||||
assert!(
|
||||
rendered.contains(title),
|
||||
"rendered must contain {title}, got:\n{rendered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_arrow_down_moves_selection() {
|
||||
let mut app = app_with_docs(vec![
|
||||
make_doc("a.md", "A", vec![]),
|
||||
make_doc("b.md", "B", vec![]),
|
||||
make_doc("c.md", "C", vec![]),
|
||||
]);
|
||||
let outcome = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::Continue);
|
||||
let outcome2 = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome2, KeyOutcome::Continue);
|
||||
// Third j hits the bottom; clamp must not panic / overflow.
|
||||
let outcome3 = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome3, KeyOutcome::Continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_enter_inspects_when_docs_present() {
|
||||
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
|
||||
let outcome = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Inspect));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_library_f_opens_filter_overlay_then_enter_refreshes() {
|
||||
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
|
||||
// Open filter.
|
||||
let o1 = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(o1, KeyOutcome::Continue);
|
||||
// Type into tags buffer.
|
||||
for ch in "foo".chars() {
|
||||
kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
// Enter commits + refreshes.
|
||||
let o2 = kebab_tui::handle_key_library(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(o2, KeyOutcome::Refresh);
|
||||
}
|
||||
@@ -14,6 +14,21 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-05-02 — P9-1 TUI Library: render_library generic + test seam
|
||||
|
||||
**Discovered**: P9-1 implementation start.
|
||||
|
||||
**Symptom 1 (cosmetic)**: `tasks/p9/p9-1-tui-library.md` § Public surface declares `pub fn render_library<B: ratatui::backend::Backend>(f: &mut ratatui::Frame, area: Rect, state: &App)`. ratatui 0.28 dropped the backend generic from `Frame` (it's bound at `Terminal` initialisation, not at the render call site). The `<B: Backend>` parameter would be unused on the function and clippy `-D warnings` rejects unused generic parameters.
|
||||
|
||||
**Fix 1**: `render_library(f: &mut Frame, area: Rect, state: &App)` — no generic parameter. The function still works against any backend the `Terminal` was opened with (CrosstermBackend in production, TestBackend in snapshot tests). No call-site impact.
|
||||
|
||||
**Symptom 2 (test seam)**: `LibraryState.inner` is `pub(crate)` per the spec's parallel-safety contract — p9-2/3/4 must not mutate `LibraryState` directly. Snapshot tests in `tests/library.rs` (an integration test, NOT a unit test in the same module) cannot reach `pub(crate)` fields, so they cannot inject docs without going through `kebab-app::list_docs_with_config` (which would stand up a TempDir SQLite KB just to populate three rows).
|
||||
|
||||
**Fix 2**: new `App::populate_library_for_testing(&mut self, Vec<DocSummary>)` marked `#[doc(hidden)]`. Lets snapshot tests inject docs hermetically while keeping the parallel-safety boundary intact for normal callers (the helper is officially "test seam, not part of the UI API"). Same shape as `kebab-app::*_with_config` test seams from P3-5.
|
||||
|
||||
**Amends**:
|
||||
- tasks/p9/p9-1-tui-library.md (`render_library` no longer generic; `populate_library_for_testing` test seam added).
|
||||
|
||||
## 2026-05-02 — P7-3 PDF ingest wiring: chunker_version deviation + storage UNIQUE bug
|
||||
|
||||
**Discovered**: P7-3 implementation start.
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-tui (library view)
|
||||
task_id: p9-1
|
||||
title: "Ratatui library list view + tag filter"
|
||||
status: planned
|
||||
status: completed
|
||||
depends_on: [p1-6]
|
||||
unblocks: [p9-2, p9-3, p9-4]
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
|
||||
Reference in New Issue
Block a user