Merge pull request 'feat(kebab-tui): P9-1 Ratatui shell + Library pane' (#42) from feat/p9-1-tui-library into main

Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-05-02 13:36:49 +00:00
14 changed files with 1234 additions and 5 deletions

153
Cargo.lock generated
View File

@@ -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"

View File

@@ -21,6 +21,7 @@ members = [
"crates/kebab-eval",
"crates/kebab-parse-image",
"crates/kebab-parse-pdf",
"crates/kebab-tui",
]
[workspace.package]

View File

@@ -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"] }

View File

@@ -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,

View 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
View 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));
}
}
}

View 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)
}

View 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};

View File

@@ -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<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("?");
// `<width$>` 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:<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)
}
}
}

158
crates/kebab-tui/src/run.rs Normal file
View File

@@ -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,
);
}

View 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);
}
}

View 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);
}

View File

@@ -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.

View File

@@ -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