From 1f39b6bc2cfae9476df49074601791ed852bc81a Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sat, 9 May 2026 02:43:05 +0900
Subject: [PATCH] feat(tui): [STALE] Warning-styled badge on search/inspect/ask
(fb-32)
insta filter pattern '[indexed_at]' applied where snapshots
otherwise capture time-dependent RFC3339 strings.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
crates/kebab-tui/src/ask.rs | 23 +++++--
crates/kebab-tui/src/inspect.rs | 57 +++++++++++++++--
crates/kebab-tui/src/search.rs | 16 ++++-
crates/kebab-tui/tests/ask.rs | 102 ++++++++++++++++++++++++++++++
crates/kebab-tui/tests/inspect.rs | 75 ++++++++++++++++++++++
crates/kebab-tui/tests/search.rs | 94 +++++++++++++++++++++++++++
6 files changed, 354 insertions(+), 13 deletions(-)
diff --git a/crates/kebab-tui/src/ask.rs b/crates/kebab-tui/src/ask.rs
index b6f1a2d..dd20917 100644
--- a/crates/kebab-tui/src/ask.rs
+++ b/crates/kebab-tui/src/ask.rs
@@ -284,13 +284,22 @@ fn render_citations_or_explain(f: &mut Frame, area: Rect, s: &AskState, theme: &
.iter()
.map(|c| {
let marker = c.marker.as_deref().unwrap_or("?");
- Line::from(vec![
- Span::styled(
- format!("[{marker}] "),
- theme.style(crate::theme::Role::CitationMarker),
- ),
- Span::raw(c.citation.to_uri()),
- ])
+ // p9-fb-32: when `c.stale`, prepend a Warning-styled
+ // `[STALE] ` Span between the citation marker and the
+ // path so the user sees the staleness signal as text
+ // (not just color — fb-14 accessibility).
+ let mut spans = vec![Span::styled(
+ format!("[{marker}] "),
+ theme.style(crate::theme::Role::CitationMarker),
+ )];
+ if c.stale {
+ spans.push(Span::styled(
+ "[STALE] ",
+ theme.style(crate::theme::Role::Warning),
+ ));
+ }
+ spans.push(Span::raw(c.citation.to_uri()));
+ Line::from(spans)
})
.collect(),
};
diff --git a/crates/kebab-tui/src/inspect.rs b/crates/kebab-tui/src/inspect.rs
index 39aa90f..b1ee369 100644
--- a/crates/kebab-tui/src/inspect.rs
+++ b/crates/kebab-tui/src/inspect.rs
@@ -47,8 +47,14 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) {
f.render_widget(block, area);
return;
}
+ // p9-fb-32: compute staleness against the configured threshold so
+ // the inspect header can carry a `[STALE]` badge alongside the
+ // doc_path. Threshold = 0 short-circuits in `compute_stale`.
+ let threshold_days = state.config.search.stale_threshold_days;
match (&s.target, &s.doc, &s.chunk) {
- (Some(InspectTarget::Doc(_)), Some(doc), _) => render_doc(f, area, s, doc, &state.theme),
+ (Some(InspectTarget::Doc(_)), Some(doc), _) => {
+ render_doc(f, area, s, doc, &state.theme, threshold_days)
+ }
(Some(InspectTarget::Chunk(_)), _, Some(chunk)) => {
render_chunk(f, area, s, chunk, &state.theme)
}
@@ -67,8 +73,15 @@ pub fn render_inspect(f: &mut Frame, area: Rect, state: &App) {
}
}
-fn render_doc(f: &mut Frame, area: Rect, s: &InspectState, doc: &CanonicalDocument, theme: &crate::theme::Theme) {
- let lines = build_doc_lines(s, doc, theme);
+fn render_doc(
+ f: &mut Frame,
+ area: Rect,
+ s: &InspectState,
+ doc: &CanonicalDocument,
+ theme: &crate::theme::Theme,
+ threshold_days: u32,
+) {
+ let lines = build_doc_lines(s, doc, theme, threshold_days);
let block = RBlock::default()
.title(format!(
"Inspect Doc — {}",
@@ -97,15 +110,27 @@ fn render_chunk(f: &mut Frame, area: Rect, s: &InspectState, chunk: &Chunk, them
/// Build the wrapped Lines for a doc inspect view. Pure function so
/// snapshot tests can compare a stable prefix of lines.
+///
+/// p9-fb-32: when `now - doc.metadata.updated_at > threshold_days`,
+/// the `doc_path` header line is preceded by a Warning-styled
+/// `[STALE] ` Span. Threshold 0 short-circuits to never-stale.
pub(crate) fn build_doc_lines<'a>(
s: &InspectState,
doc: &'a CanonicalDocument,
theme: &crate::theme::Theme,
+ threshold_days: u32,
) -> Vec> {
let mut lines: Vec = Vec::new();
// Header
+ let now = time::OffsetDateTime::now_utc();
+ let stale = kebab_app::compute_stale(doc.metadata.updated_at, now, threshold_days);
lines.push(header_kv("title", &doc.title, theme));
- lines.push(header_kv("doc_path", &doc.workspace_path.0, theme));
+ lines.push(header_kv_with_stale(
+ "doc_path",
+ &doc.workspace_path.0,
+ stale,
+ theme,
+ ));
lines.push(header_kv("doc_id", &doc.doc_id.0, theme));
lines.push(header_kv("lang", &doc.lang.0, theme));
lines.push(header_kv(
@@ -283,6 +308,30 @@ fn header_kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> {
])
}
+/// p9-fb-32: same as `header_kv` but prepends `[STALE] ` (Warning-
+/// styled) before the value when `stale == true`. The `[STALE]` text
+/// is plain ASCII so monochrome readers still get the signal (fb-14
+/// accessibility note).
+fn header_kv_with_stale(
+ k: &str,
+ v: &str,
+ stale: bool,
+ theme: &crate::theme::Theme,
+) -> Line<'static> {
+ let mut spans = vec![Span::styled(
+ format!("{k:>16}: "),
+ theme.style(crate::theme::Role::Heading),
+ )];
+ if stale {
+ spans.push(Span::styled(
+ "[STALE] ",
+ theme.style(crate::theme::Role::Warning),
+ ));
+ }
+ spans.push(Span::raw(v.to_string()));
+ Line::from(spans)
+}
+
fn kv(k: &str, v: &str, theme: &crate::theme::Theme) -> Line<'static> {
Line::from(vec![
Span::styled(
diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs
index 14dc816..cd1fb99 100644
--- a/crates/kebab-tui/src/search.rs
+++ b/crates/kebab-tui/src/search.rs
@@ -130,10 +130,14 @@ fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState, theme: &crate:
}
/// §1.5 dense format — 4 lines per hit:
-/// 1. `. `
+/// 1. `. [STALE]?`
/// 2. ` | section_label?`
/// 3. snippet line 1
/// 4. snippet line 2 (or trailing blank for layout symmetry)
+///
+/// p9-fb-32: when `h.stale == true` the rank/score header line is
+/// preceded by a Warning-styled `[STALE] ` Span — text + color so a
+/// monochrome reader still gets the signal (fb-14 accessibility note).
fn format_hit_lines(h: &SearchHit, theme: &crate::theme::Theme) -> Vec> {
let header = format!(
"{}. {:.4} {}",
@@ -155,8 +159,16 @@ fn format_hit_lines(h: &SearchHit, theme: &crate::theme::Theme) -> Vec()
+ })
+ .collect::>()
+ .join("\n");
+ assert!(
+ rendered.contains("[STALE]"),
+ "[STALE] badge must render somewhere on the citations panel: {rendered}"
+ );
+ let stale_line = rendered
+ .lines()
+ .find(|l| l.contains("notes/old.md"))
+ .expect("stale citation row must render");
+ assert!(
+ stale_line.contains("[STALE]"),
+ "stale citation row must carry [STALE] badge: {stale_line}"
+ );
+ let fresh_line = rendered
+ .lines()
+ .find(|l| l.contains("notes/new.md"))
+ .expect("fresh citation row must render");
+ assert!(
+ !fresh_line.contains("[STALE]"),
+ "fresh citation row must NOT carry [STALE] badge: {fresh_line}"
+ );
+ // Color side: the `[` of `[STALE]` must be Yellow (Warning role).
+ let mut stale_yellow_found = false;
+ for y in 0..buffer.area.height {
+ for x in 0..buffer.area.width {
+ let cell = &buffer[(x, y)];
+ if cell.symbol() == "["
+ && x + 1 < buffer.area.width
+ && buffer[(x + 1, y)].symbol() == "S"
+ {
+ if let ratatui::style::Color::Yellow = cell.fg {
+ stale_yellow_found = true;
+ }
+ }
+ }
+ }
+ assert!(
+ stale_yellow_found,
+ "[STALE] badge in citations must use Yellow (Warning) fg"
+ );
+}
+
#[test]
fn explain_toggle_changes_panel_title() {
let mut app = fresh_app();
diff --git a/crates/kebab-tui/tests/inspect.rs b/crates/kebab-tui/tests/inspect.rs
index e16f337..7fb3413 100644
--- a/crates/kebab-tui/tests/inspect.rs
+++ b/crates/kebab-tui/tests/inspect.rs
@@ -325,6 +325,81 @@ fn chunk_view_renders_text_and_block_ids() {
);
}
+/// p9-fb-32: when a doc's `metadata.updated_at` is older than the
+/// configured `stale_threshold_days`, the Inspect pane prefixes the
+/// `doc_path` value with a Warning-styled `[STALE] ` Span. Threshold
+/// 0 (the staleness feature off) must NOT render the badge.
+#[test]
+fn inspect_doc_header_shows_stale_badge_when_threshold_exceeded() {
+ let mut app = fresh_app();
+ // Force a non-zero threshold so the staleness post-process can fire.
+ app.config.search.stale_threshold_days = 30;
+ {
+ let s = app.inspect.as_mut().unwrap();
+ s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
+ let mut doc = make_doc();
+ // Backdate updated_at by 60 days so 60d > 30d threshold.
+ doc.metadata.updated_at =
+ OffsetDateTime::now_utc() - time::Duration::days(60);
+ s.doc = Some(doc);
+ }
+ let rendered = render_to_string(&app, 100, 40);
+ assert!(
+ rendered.contains("[STALE]"),
+ "[STALE] badge must render on stale doc header: {rendered}"
+ );
+ // Same line carrying the doc_path value must show the badge.
+ let path_line = rendered
+ .lines()
+ .find(|l| l.contains("notes/test.md"))
+ .expect("doc_path line must render");
+ assert!(
+ path_line.contains("[STALE]"),
+ "doc_path row must carry [STALE] badge: {path_line}"
+ );
+}
+
+#[test]
+fn inspect_doc_header_omits_stale_badge_when_fresh() {
+ let mut app = fresh_app();
+ app.config.search.stale_threshold_days = 30;
+ {
+ let s = app.inspect.as_mut().unwrap();
+ s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
+ let mut doc = make_doc();
+ // 1 day old — under the 30d threshold.
+ doc.metadata.updated_at =
+ OffsetDateTime::now_utc() - time::Duration::days(1);
+ s.doc = Some(doc);
+ }
+ let rendered = render_to_string(&app, 100, 40);
+ assert!(
+ !rendered.contains("[STALE]"),
+ "fresh doc must NOT carry [STALE] badge: {rendered}"
+ );
+}
+
+#[test]
+fn inspect_doc_header_omits_stale_badge_when_threshold_zero() {
+ let mut app = fresh_app();
+ // Threshold 0 = staleness feature disabled.
+ app.config.search.stale_threshold_days = 0;
+ {
+ let s = app.inspect.as_mut().unwrap();
+ s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
+ let mut doc = make_doc();
+ // Even a year-old doc must not get [STALE] when threshold = 0.
+ doc.metadata.updated_at =
+ OffsetDateTime::now_utc() - time::Duration::days(365);
+ s.doc = Some(doc);
+ }
+ let rendered = render_to_string(&app, 100, 40);
+ assert!(
+ !rendered.contains("[STALE]"),
+ "threshold = 0 must disable [STALE] badge regardless of age: {rendered}"
+ );
+}
+
#[test]
fn no_inspect_state_returns_to_library() {
let mut config = Config::defaults();
diff --git a/crates/kebab-tui/tests/search.rs b/crates/kebab-tui/tests/search.rs
index b76416c..468ac2c 100644
--- a/crates/kebab-tui/tests/search.rs
+++ b/crates/kebab-tui/tests/search.rs
@@ -252,6 +252,100 @@ fn render_search_with_hits_shows_input_and_path() {
assert!(rendered.contains("notes/dyn.md"), "second hit path rendered");
}
+/// p9-fb-32: Search pane prefixes the rank/score header line with a
+/// Warning-styled `[STALE] ` Span when `hit.stale == true`. Pin the
+/// text-level signal (color is exercised via the cell scan below).
+#[test]
+fn search_pane_shows_stale_badge_for_old_doc() {
+ let mut app = fresh_app();
+ {
+ let s = app.search.as_mut().unwrap();
+ s.input.push_str("rust");
+ s.mode = SearchMode::Hybrid;
+ let mut stale_hit = make_hit(
+ 1,
+ "notes/old.md",
+ "ancient trait dispatch\nstill relevant",
+ line_citation("notes/old.md", 7),
+ );
+ // Synthesize an indexed_at well past any threshold; combined
+ // with `stale: true` this matches the post-process output of
+ // `kebab_app::mark_stale_in_place`.
+ stale_hit.indexed_at = time::OffsetDateTime::UNIX_EPOCH;
+ stale_hit.stale = true;
+ let fresh_hit = make_hit(
+ 2,
+ "notes/new.md",
+ "modern dispatch\nvtable",
+ line_citation("notes/new.md", 3),
+ );
+ s.hits = vec![stale_hit, fresh_hit];
+ s.selected_hit = 0;
+ }
+ let backend = TestBackend::new(80, 24);
+ let mut terminal = Terminal::new(backend).unwrap();
+ terminal
+ .draw(|f| {
+ let area = Rect::new(0, 0, 80, 24);
+ render_search(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("[STALE]"),
+ "[STALE] badge must render as text on stale hit: {rendered}"
+ );
+ // The badge appears on the same line that begins with rank `1.`
+ // — the stale hit. The fresh `notes/new.md` row must NOT carry
+ // the badge.
+ let stale_line = rendered
+ .lines()
+ .find(|l| l.contains("notes/old.md"))
+ .expect("stale hit's header line must render");
+ assert!(
+ stale_line.contains("[STALE]"),
+ "stale row must carry [STALE] badge: {stale_line}"
+ );
+ let fresh_line = rendered
+ .lines()
+ .find(|l| l.contains("notes/new.md"))
+ .expect("fresh hit's header line must render");
+ assert!(
+ !fresh_line.contains("[STALE]"),
+ "fresh row must NOT carry [STALE] badge: {fresh_line}"
+ );
+ // Color side: the `[` of `[STALE]` must be Yellow (Warning role,
+ // dark palette default).
+ let mut stale_yellow_found = false;
+ for y in 0..buffer.area.height {
+ for x in 0..buffer.area.width {
+ let cell = &buffer[(x, y)];
+ if cell.symbol() == "[" {
+ // The cell to the right should be 'S' if this is the
+ // start of `[STALE]` — narrow check to avoid the
+ // rank/score `[` cells (there shouldn't be any there).
+ if x + 1 < buffer.area.width && buffer[(x + 1, y)].symbol() == "S" {
+ if let ratatui::style::Color::Yellow = cell.fg {
+ stale_yellow_found = true;
+ }
+ }
+ }
+ }
+ }
+ assert!(
+ stale_yellow_found,
+ "[STALE] badge must be rendered with Yellow (Warning role) fg"
+ );
+}
+
#[test]
fn empty_state_renders_without_panic() {
let app = fresh_app();