feat(search): overhaul search output formatting (GIT-5)

Phase 1: Add source_entity_iid to search results via CASE subquery on
hydrate_results() for all 4 source types (issue, MR, discussion, note).
Phase 2: Fix visual alignment - compute indent from prefix visible width.
Phase 3: Show compact relative time on title line.
Phase 4: Add drill-down hint footer (lore issues <iid>).
Phase 5: Move labels to --explain mode, limit snippets to 2 terminal lines.
Phase 6: Use section_divider() for results header.

Also: promote strip_ansi/visible_width to public render utils, update
robot mode --fields minimal search preset with source_entity_iid.
This commit is contained in:
teernisse
2026-03-11 10:37:38 -04:00
parent 60075cd400
commit 44431667e8
4 changed files with 170 additions and 60 deletions

View File

@@ -1469,7 +1469,7 @@ async fn handle_search(
if robot_mode { if robot_mode {
print_search_results_json(&response, elapsed_ms, args.fields.as_deref()); print_search_results_json(&response, elapsed_ms, args.fields.as_deref());
} else { } else {
print_search_results(&response); print_search_results(&response, explain);
} }
Ok(()) Ok(())
} }

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::cli::render::Theme; use crate::cli::render::{self, Theme};
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
@@ -20,11 +20,16 @@ use crate::search::{
pub struct SearchResultDisplay { pub struct SearchResultDisplay {
pub document_id: i64, pub document_id: i64,
pub source_type: String, pub source_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_entity_iid: Option<i64>,
pub title: String, pub title: String,
pub url: Option<String>, pub url: Option<String>,
pub author: Option<String>, pub author: Option<String>,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
/// Raw epoch ms for human rendering; not serialized to JSON.
#[serde(skip)]
pub updated_at_ms: Option<i64>,
pub project_path: String, pub project_path: String,
pub labels: Vec<String>, pub labels: Vec<String>,
pub paths: Vec<String>, pub paths: Vec<String>,
@@ -216,11 +221,13 @@ pub async fn run_search(
results.push(SearchResultDisplay { results.push(SearchResultDisplay {
document_id: row.document_id, document_id: row.document_id,
source_type: row.source_type.clone(), source_type: row.source_type.clone(),
source_entity_iid: row.source_entity_iid,
title: row.title.clone().unwrap_or_default(), title: row.title.clone().unwrap_or_default(),
url: row.url.clone(), url: row.url.clone(),
author: row.author.clone(), author: row.author.clone(),
created_at: row.created_at.map(ms_to_iso), created_at: row.created_at.map(ms_to_iso),
updated_at: row.updated_at.map(ms_to_iso), updated_at: row.updated_at.map(ms_to_iso),
updated_at_ms: row.updated_at,
project_path: row.project_path.clone(), project_path: row.project_path.clone(),
labels: row.labels.clone(), labels: row.labels.clone(),
paths: row.paths.clone(), paths: row.paths.clone(),
@@ -242,6 +249,7 @@ pub async fn run_search(
struct HydratedRow { struct HydratedRow {
document_id: i64, document_id: i64,
source_type: String, source_type: String,
source_entity_iid: Option<i64>,
title: Option<String>, title: Option<String>,
url: Option<String>, url: Option<String>,
author: Option<String>, author: Option<String>,
@@ -268,7 +276,26 @@ fn hydrate_results(conn: &rusqlite::Connection, document_ids: &[i64]) -> Result<
(SELECT json_group_array(dl.label_name) (SELECT json_group_array(dl.label_name)
FROM document_labels dl WHERE dl.document_id = d.id) AS labels_json, FROM document_labels dl WHERE dl.document_id = d.id) AS labels_json,
(SELECT json_group_array(dp.path) (SELECT json_group_array(dp.path)
FROM document_paths dp WHERE dp.document_id = d.id) AS paths_json FROM document_paths dp WHERE dp.document_id = d.id) AS paths_json,
CASE d.source_type
WHEN 'issue' THEN
(SELECT i.iid FROM issues i WHERE i.id = d.source_id)
WHEN 'merge_request' THEN
(SELECT m.iid FROM merge_requests m WHERE m.id = d.source_id)
WHEN 'discussion' THEN
(SELECT COALESCE(
(SELECT i.iid FROM issues i WHERE i.id = disc.issue_id),
(SELECT m.iid FROM merge_requests m WHERE m.id = disc.merge_request_id)
) FROM discussions disc WHERE disc.id = d.source_id)
WHEN 'note' THEN
(SELECT COALESCE(
(SELECT i.iid FROM issues i WHERE i.id = disc.issue_id),
(SELECT m.iid FROM merge_requests m WHERE m.id = disc.merge_request_id)
) FROM notes n
JOIN discussions disc ON disc.id = n.discussion_id
WHERE n.id = d.source_id)
ELSE NULL
END AS source_entity_iid
FROM json_each(?1) AS j FROM json_each(?1) AS j
JOIN documents d ON d.id = j.value JOIN documents d ON d.id = j.value
JOIN projects p ON p.id = d.project_id JOIN projects p ON p.id = d.project_id
@@ -293,6 +320,7 @@ fn hydrate_results(conn: &rusqlite::Connection, document_ids: &[i64]) -> Result<
project_path: row.get(8)?, project_path: row.get(8)?,
labels: parse_json_array(&labels_json), labels: parse_json_array(&labels_json),
paths: parse_json_array(&paths_json), paths: parse_json_array(&paths_json),
source_entity_iid: row.get(11)?,
}) })
})? })?
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
@@ -326,7 +354,7 @@ fn render_snippet(snippet: &str) -> String {
result result
} }
pub fn print_search_results(response: &SearchResponse) { pub fn print_search_results(response: &SearchResponse, explain: bool) {
if !response.warnings.is_empty() { if !response.warnings.is_empty() {
for w in &response.warnings { for w in &response.warnings {
eprintln!("{} {}", Theme::warning().render("Warning:"), w); eprintln!("{} {}", Theme::warning().render("Warning:"), w);
@@ -341,11 +369,13 @@ pub fn print_search_results(response: &SearchResponse) {
return; return;
} }
// Phase 6: section divider header
println!( println!(
"\n {} results for '{}' {}", "{}",
Theme::bold().render(&response.total_results.to_string()), render::section_divider(&format!(
Theme::bold().render(&response.query), "{} results for '{}' {}",
Theme::muted().render(&response.mode) response.total_results, response.query, response.mode
))
); );
for (i, result) in response.results.iter().enumerate() { for (i, result) in response.results.iter().enumerate() {
@@ -359,22 +389,76 @@ pub fn print_search_results(response: &SearchResponse) {
_ => Theme::muted().render(&format!("{:>5}", &result.source_type)), _ => Theme::muted().render(&format!("{:>5}", &result.source_type)),
}; };
// Title line: rank, type badge, title // Phase 1: entity ref (e.g. #42 or !99)
println!( let entity_ref = result
" {:>3}. {} {}", .source_entity_iid
Theme::muted().render(&(i + 1).to_string()), .map(|iid| match result.source_type.as_str() {
type_badge, "issue" | "discussion" | "note" => Theme::issue_ref().render(&format!("#{iid}")),
Theme::bold().render(&result.title) "merge_request" => Theme::mr_ref().render(&format!("!{iid}")),
); _ => String::new(),
});
// Metadata: project, author, labels — compact middle-dot line // Phase 3: relative time
let time_str = result
.updated_at_ms
.map(|ms| Theme::dim().render(&render::format_relative_time_compact(ms)));
// Phase 2: build prefix, compute indent from its visible width
let prefix = format!(" {:>3}. {} ", i + 1, type_badge);
let indent = " ".repeat(render::visible_width(&prefix));
// Title line: rank, type badge, entity ref, title, relative time
let mut title_line = prefix;
if let Some(ref eref) = entity_ref {
title_line.push_str(eref);
title_line.push_str(" ");
}
title_line.push_str(&Theme::bold().render(&result.title));
if let Some(ref time) = time_str {
title_line.push_str(" ");
title_line.push_str(time);
}
println!("{title_line}");
// Metadata: project, author — compact middle-dot line
let sep = Theme::muted().render(" \u{b7} "); let sep = Theme::muted().render(" \u{b7} ");
let mut meta_parts: Vec<String> = Vec::new(); let mut meta_parts: Vec<String> = Vec::new();
meta_parts.push(Theme::muted().render(&result.project_path)); meta_parts.push(Theme::muted().render(&result.project_path));
if let Some(ref author) = result.author { if let Some(ref author) = result.author {
meta_parts.push(Theme::username().render(&format!("@{author}"))); meta_parts.push(Theme::username().render(&format!("@{author}")));
} }
if !result.labels.is_empty() { println!("{indent}{}", meta_parts.join(&sep));
// Phase 5: limit snippet to ~2 terminal lines
let max_snippet_width =
render::terminal_width().saturating_sub(render::visible_width(&indent));
let max_snippet_chars = max_snippet_width.saturating_mul(2);
let snippet = if result.snippet.chars().count() > max_snippet_chars && max_snippet_chars > 3
{
let truncated: String = result.snippet.chars().take(max_snippet_chars - 3).collect();
format!("{truncated}...")
} else {
result.snippet.clone()
};
let rendered = render_snippet(&snippet);
println!("{indent}{rendered}");
if let Some(ref explain_data) = result.explain {
let mut explain_line = format!(
"{indent}{} vec={} fts={} rrf={:.4}",
Theme::accent().render("explain"),
explain_data
.vector_rank
.map(|r| r.to_string())
.unwrap_or_else(|| "-".into()),
explain_data
.fts_rank
.map(|r| r.to_string())
.unwrap_or_else(|| "-".into()),
explain_data.rrf_score
);
// Phase 5: labels shown only in explain mode
if explain && !result.labels.is_empty() {
let label_str = if result.labels.len() <= 3 { let label_str = if result.labels.len() <= 3 {
result.labels.join(", ") result.labels.join(", ")
} else { } else {
@@ -384,27 +468,26 @@ pub fn print_search_results(response: &SearchResponse) {
result.labels.len() - 2 result.labels.len() - 2
) )
}; };
meta_parts.push(Theme::muted().render(&label_str)); explain_line.push_str(&format!(" {}", Theme::muted().render(&label_str)));
}
println!("{explain_line}");
}
} }
println!(" {}", meta_parts.join(&sep));
// Snippet with highlight styling // Phase 4: drill-down hint footer
let rendered = render_snippet(&result.snippet); if let Some(first) = response.results.first()
println!(" {rendered}"); && let Some(iid) = first.source_entity_iid
{
if let Some(ref explain) = result.explain { let cmd = match first.source_type.as_str() {
"issue" | "discussion" | "note" => Some(format!("lore issues {iid}")),
"merge_request" => Some(format!("lore mrs {iid}")),
_ => None,
};
if let Some(cmd) = cmd {
println!( println!(
" {} vec={} fts={} rrf={:.4}", "\n {} {}",
Theme::accent().render("explain"), Theme::dim().render("Tip:"),
explain Theme::dim().render(&format!("{cmd} for details"))
.vector_rank
.map(|r| r.to_string())
.unwrap_or_else(|| "-".into()),
explain
.fts_rank
.map(|r| r.to_string())
.unwrap_or_else(|| "-".into()),
explain.rrf_score
); );
} }
} }

View File

@@ -569,6 +569,32 @@ pub fn terminal_width() -> usize {
80 80
} }
/// Strip ANSI escape codes (SGR sequences) from a string.
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
// Consume `[`, then digits/semicolons, then the final letter
if chars.next() == Some('[') {
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
} else {
out.push(c);
}
}
out
}
/// Compute the visible width of a string that may contain ANSI escape sequences.
pub fn visible_width(s: &str) -> usize {
strip_ansi(s).chars().count()
}
/// Truncate a string to `max` characters, appending "..." if truncated. /// Truncate a string to `max` characters, appending "..." if truncated.
pub fn truncate(s: &str, max: usize) -> String { pub fn truncate(s: &str, max: usize) -> String {
if max < 4 { if max < 4 {
@@ -1459,24 +1485,19 @@ mod tests {
// ── helpers ── // ── helpers ──
/// Strip ANSI escape codes (SGR sequences) for content assertions. /// Delegate to the public `strip_ansi` for test assertions.
fn strip_ansi(s: &str) -> String { fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len()); super::strip_ansi(s)
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
// Consume `[`, then digits/semicolons, then the final letter
if chars.next() == Some('[') {
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
} }
#[test]
fn visible_width_strips_ansi() {
let styled = "\x1b[1mhello\x1b[0m".to_string();
assert_eq!(super::visible_width(&styled), 5);
} }
}
} else { #[test]
out.push(c); fn visible_width_plain_string() {
} assert_eq!(super::visible_width("hello"), 5);
}
out
} }
} }

View File

@@ -56,7 +56,13 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
.iter() .iter()
.map(|s| (*s).to_string()) .map(|s| (*s).to_string())
.collect(), .collect(),
"search" => ["document_id", "title", "source_type", "score"] "search" => [
"document_id",
"title",
"source_type",
"source_entity_iid",
"score",
]
.iter() .iter()
.map(|s| (*s).to_string()) .map(|s| (*s).to_string())
.collect(), .collect(),