feat(kebab-tui): p9-fb-24 task 6 — render_status_bar (version + pane + docs + idle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 16:53:00 +00:00
parent c3dbe64903
commit d5a4348041
3 changed files with 166 additions and 0 deletions

View File

@@ -62,6 +62,9 @@ pub use run::mode_intercept;
// for integration tests + future TUI consumers.
pub use cheatsheet::render_cheatsheet;
pub use run::cheatsheet_intercept;
// p9-fb-24: expose the status bar render fn so integration tests can
// pin its content without standing up the full run loop.
pub use run::render_status_bar;
// p9-fb-13 follow-up: expose footer_hints so integration tests can
// pin the verb-form per (pane, mode) without standing up the run loop.
pub use run::footer_hints;

View File

@@ -327,6 +327,87 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(Paragraph::new(line), area);
}
/// p9-fb-24: always-visible status bar. Layout (left → right):
///
/// ```text
/// kebab v0.1.0 │ <pane> │ <docs> docs │ [conv_<8hex>… │ ]<state>
/// ```
///
/// `<state>` is one of `streaming…` / `searching…` / `indexing N/M (P%)` / `idle`,
/// chosen via the priority cascade:
/// 1. Ask streaming → `streaming…`
/// 2. Search worker active → `searching…`
/// 3. Ingest worker active (or terminal-line still on hold) → ingest `status_line`
/// 4. fallback → `idle`
///
/// `<conv_…>` only appears when `app.focus == Ask` AND the pane has
/// either an in-flight question or at least one completed turn — the
/// signal that "this Ask session has context".
pub fn render_status_bar(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 doc_count = app.library.inner.docs.len();
let dynamic = dynamic_status(app);
let sep = "";
let mut line_text = format!(
"kebab v{}{sep}{}{sep}{} docs{sep}",
env!("CARGO_PKG_VERSION"),
pane_label,
doc_count,
);
if let Some(conv) = ask_conv_id_short(app) {
line_text.push_str(&conv);
line_text.push_str(sep);
}
line_text.push_str(&dynamic);
let line = Line::from(Span::styled(
line_text,
app.theme.style(crate::theme::Role::Hint),
));
f.render_widget(Paragraph::new(line), area);
}
/// Priority-cascade dynamic state for the status bar. See
/// `render_status_bar` for the priority order.
fn dynamic_status(app: &App) -> String {
if app.ask.as_ref().map(|s| s.streaming).unwrap_or(false) {
return "streaming…".to_string();
}
if app.search.as_ref().map(|s| s.searching).unwrap_or(false) {
return "searching…".to_string();
}
if let Some(state) = app.ingest_state.as_ref() {
return crate::ingest_progress::status_line(state);
}
"idle".to_string()
}
/// Short form of the Ask `conversation_id` for the status bar
/// (`conv_<first 8 hex chars>…`). Returns `None` when not in Ask, or
/// when the Ask pane has no context (no in-flight question and no
/// completed turns).
fn ask_conv_id_short(app: &App) -> Option<String> {
if app.focus != Pane::Ask {
return None;
}
let s = app.ask.as_ref()?;
let has_context = s.current_question.is_some() || !s.turns.is_empty();
if !has_context {
return None;
}
let id = s.conversation_id.as_deref()?;
let hex = id.strip_prefix("conv_").unwrap_or(id);
let head: String = hex.chars().take(8).collect();
Some(format!("conv_{head}"))
}
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
let hints = footer_hints(app.focus, app.mode, app.library.inner.filter_edit.is_some());
let line = Line::from(Span::styled(

View File

@@ -0,0 +1,82 @@
//! p9-fb-24: integration tests for the always-visible status bar.
use kebab_config::Config;
use kebab_tui::{App, Pane};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
fn fresh_app(focus: Pane) -> App {
let mut config = Config::defaults();
config.storage.data_dir = "/tmp/kebab-tui-status-bar-tests-noop".to_string();
config.workspace.root = "/tmp/kebab-tui-status-bar-tests-noop/workspace".to_string();
let mut app = App::new(config).expect("App::new");
app.focus = focus;
app
}
fn render_to_string(app: &App, width: u16) -> String {
let backend = TestBackend::new(width, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| kebab_tui::render_status_bar(f, Rect::new(0, 0, width, 1), app))
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn status_bar_shows_kebab_version_first() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
let expected = format!("kebab v{}", env!("CARGO_PKG_VERSION"));
assert!(
rendered.contains(&expected),
"version not in status bar: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_shows_pane_label() {
for (focus, expected) in [
(Pane::Library, "Library"),
(Pane::Search, "Search"),
(Pane::Ask, "Ask"),
(Pane::Inspect, "Inspect"),
(Pane::Jobs, "Jobs"),
] {
let app = fresh_app(focus);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains(expected),
"pane label '{expected}' not visible for focus={focus:?}: rendered=\n{rendered}"
);
}
}
#[test]
fn status_bar_shows_doc_count() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("0 docs"),
"doc count missing: rendered=\n{rendered}"
);
}
#[test]
fn status_bar_idle_when_no_dynamic_state() {
let app = fresh_app(Pane::Library);
let rendered = render_to_string(&app, 100);
assert!(
rendered.contains("idle"),
"idle marker missing: rendered=\n{rendered}"
);
}