diff --git a/Cargo.lock b/Cargo.lock index 92ef438..a5f7f49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 440b084..0f48073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/kebab-eval", "crates/kebab-parse-image", "crates/kebab-parse-pdf", + "crates/kebab-tui", ] [workspace.package] diff --git a/crates/kebab-cli/Cargo.toml b/crates/kebab-cli/Cargo.toml index abd9da6..e7eed98 100644 --- a/crates/kebab-cli/Cargo.toml +++ b/crates/kebab-cli/Cargo.toml @@ -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"] } diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index e2df18a..76e0e02 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -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, diff --git a/crates/kebab-tui/Cargo.toml b/crates/kebab-tui/Cargo.toml new file mode 100644 index 0000000..c73c02d --- /dev/null +++ b/crates/kebab-tui/Cargo.toml @@ -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 } diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs new file mode 100644 index 0000000..271e1a4 --- /dev/null +++ b/crates/kebab-tui/src/app.rs @@ -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` 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, + /// Populated by p9-3. + pub ask: Option, + /// Populated by p9-4. + pub inspect: Option, + /// 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, + /// 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 { + 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, + ) { + 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)); + } + } +} diff --git a/crates/kebab-tui/src/error_popup.rs b/crates/kebab-tui/src/error_popup.rs new file mode 100644 index 0000000..9a89d36 --- /dev/null +++ b/crates/kebab-tui/src/error_popup.rs @@ -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, +} + +impl ErrorOverlay { + pub fn from_anyhow(err: &anyhow::Error) -> Self { + let chain: Vec = err.chain().map(|c| c.to_string()).collect(); + Self { + title: "error".to_string(), + chain, + } + } + + pub fn from_message(title: impl Into, msg: impl Into) -> 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 = 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) +} diff --git a/crates/kebab-tui/src/lib.rs b/crates/kebab-tui/src/lib.rs new file mode 100644 index 0000000..3fdc4b3 --- /dev/null +++ b/crates/kebab-tui/src/lib.rs @@ -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}; diff --git a/crates/kebab-tui/src/library.rs b/crates/kebab-tui/src/library.rs new file mode 100644 index 0000000..ea74534 --- /dev/null +++ b/crates/kebab-tui/src/library.rs @@ -0,0 +1,379 @@ +//! 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` 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, + 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, + /// 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 = 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("?"); + // `` is std::fmt's named-arg width form (`title_w` is the + // named arg below; `$` says "use it as the padding width"). See + // https://doc.rust-lang.org/std/fmt/#width §"Width via named + // parameters". + format!( + "{title: 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) + } + } +} diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs new file mode 100644 index 0000000..7cec1a0 --- /dev/null +++ b/crates/kebab-tui/src/run.rs @@ -0,0 +1,158 @@ +//! 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, the non-Library + // panes accept only `q` / `Esc` to return — + // anything else is a no-op. The footer hint + // tells the user the pane is unimplemented. + Pane::Search | Pane::Ask | Pane::Inspect | Pane::Jobs => { + handle_key_unimplemented_pane(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(()) +} + +/// Stub key handler for panes whose authoring task has not landed +/// yet. `q` / `Esc` returns to Library; everything else is a no-op. +/// Does NOT delegate to `handle_key_library` because that would let +/// `j` / `k` / `f` mutate Library state while focus says otherwise — +/// confusing UX. +fn handle_key_unimplemented_pane( + app: &mut App, + key: crossterm::event::KeyEvent, +) -> KeyOutcome { + use crossterm::event::KeyCode; + if app.error_overlay.is_some() { + app.error_overlay = None; + return KeyOutcome::Continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => KeyOutcome::SwitchPane(Pane::Library), + _ => KeyOutcome::Continue, + } +} + +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) { + // p9-2/3/4 가 머지되기 전에는 SwitchPane(Search/Ask/Inspect) 가 + // focus 만 바꾸고 본문은 Library 가 그려지는 절뚝거림이 사용자에게 + // 보임. footer 에서 \"미구현\" 을 명시해 거짓말 안 함. + 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" + } + } + Pane::Search => "Search pane not yet implemented (lands with p9-2) — q to return", + Pane::Ask => "Ask pane not yet implemented (lands with p9-3) — q to return", + Pane::Inspect => "Inspect pane not yet implemented (lands with p9-4) — q to return", + Pane::Jobs => "Jobs pane not yet implemented — q to return", + }; + 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, + ); +} diff --git a/crates/kebab-tui/src/terminal.rs b/crates/kebab-tui/src/terminal.rs new file mode 100644 index 0000000..1a9285c --- /dev/null +++ b/crates/kebab-tui/src/terminal.rs @@ -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>, +} + +impl TuiTerminal { + pub fn enter() -> Result { + 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); + } +} diff --git a/crates/kebab-tui/tests/library.rs b/crates/kebab-tui/tests/library.rs new file mode 100644 index 0000000..5143c7e --- /dev/null +++ b/crates/kebab-tui/tests/library.rs @@ -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::())), + 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) -> 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::() + }) + .collect::>() + .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::() + }) + .collect::>() + .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); +} diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index f8e49b8..6c836c9 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -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(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 `` 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)` 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. diff --git a/tasks/p9/p9-1-tui-library.md b/tasks/p9/p9-1-tui-library.md index 4ff3ade..0bf8f1f 100644 --- a/tasks/p9/p9-1-tui-library.md +++ b/tasks/p9/p9-1-tui-library.md @@ -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