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();