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:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user