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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-09 02:43:05 +09:00
parent aeee7ed771
commit 1f39b6bc2c
6 changed files with 354 additions and 13 deletions

View File

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

View File

@@ -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<Line<'a>> {
let mut lines: Vec<Line> = 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(

View File

@@ -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. `<rank>. <fusion_score> <path#frag>`
/// 1. `<rank>. <fusion_score> [STALE]?<path#frag>`
/// 2. `<heading_path joined by " / "> | 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<Line<'static>> {
let header = format!(
"{}. {:.4} {}",
@@ -155,8 +159,16 @@ fn format_hit_lines(h: &SearchHit, theme: &crate::theme::Theme) -> Vec<Line<'sta
let mut snippet_lines = h.snippet.lines();
let s1 = snippet_lines.next().unwrap_or("").to_string();
let s2 = snippet_lines.next().unwrap_or("").to_string();
let header_line = if h.stale {
Line::from(vec![
Span::styled("[STALE] ", theme.style(crate::theme::Role::Warning)),
Span::styled(header, theme.style(crate::theme::Role::Title)),
])
} else {
Line::from(Span::styled(header, theme.style(crate::theme::Role::Title)))
};
vec![
Line::from(Span::styled(header, theme.style(crate::theme::Role::Title))),
header_line,
Line::from(Span::styled(path_line, theme.style(crate::theme::Role::Path))),
Line::from(format!(" {s1}")),
Line::from(format!(" {s2}")),

View File

@@ -377,6 +377,108 @@ fn render_refusal_score_gate_shows_status_without_citation_index_panic() {
assert!(rendered.contains("score_gate"), "refusal reason surfaced");
}
/// p9-fb-32: when `AnswerCitation.stale == true`, the Ask pane's
/// citations panel inserts a Warning-styled `[STALE] ` Span between
/// the marker and the path URI.
#[test]
fn ask_citations_show_stale_badge_for_stale_citation() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
let mut ans = make_answer(true, None, "answer body [1] [2].");
// Replace fixture's single fresh citation with two — one stale
// (notes/old.md) and one fresh (notes/new.md) — so the test
// can assert the badge attaches to one row only.
ans.citations = vec![
AnswerCitation {
marker: Some("1".into()),
citation: Citation::Line {
path: WorkspacePath::new("notes/old.md".into()).unwrap(),
start: 1,
end: 1,
section: None,
},
indexed_at: OffsetDateTime::UNIX_EPOCH,
stale: true,
},
AnswerCitation {
marker: Some("2".into()),
citation: Citation::Line {
path: WorkspacePath::new("notes/new.md".into()).unwrap(),
start: 5,
end: 5,
section: None,
},
indexed_at: OffsetDateTime::UNIX_EPOCH,
stale: false,
},
];
s.turns.push(Turn {
question: "test".into(),
answer: ans.answer.clone(),
citations: ans.citations.clone(),
created_at: ans.created_at,
});
s.last_answer = Some(ans);
}
let backend = TestBackend::new(120, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 120, 24);
render_ask(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("[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();

View File

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

View File

@@ -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::<String>()
})
.collect::<Vec<_>>()
.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();