diff --git a/src/app/handlers.rs b/src/app/handlers.rs index 71a2e01..052f84c 100644 --- a/src/app/handlers.rs +++ b/src/app/handlers.rs @@ -1469,7 +1469,7 @@ async fn handle_search( if robot_mode { print_search_results_json(&response, elapsed_ms, args.fields.as_deref()); } else { - print_search_results(&response); + print_search_results(&response, explain); } Ok(()) } diff --git a/src/cli/commands/search.rs b/src/cli/commands/search.rs index f9e5d4f..4a1e9b5 100644 --- a/src/cli/commands/search.rs +++ b/src/cli/commands/search.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::cli::render::Theme; +use crate::cli::render::{self, Theme}; use serde::Serialize; use crate::Config; @@ -20,11 +20,16 @@ use crate::search::{ pub struct SearchResultDisplay { pub document_id: i64, pub source_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_entity_iid: Option, pub title: String, pub url: Option, pub author: Option, pub created_at: Option, pub updated_at: Option, + /// Raw epoch ms for human rendering; not serialized to JSON. + #[serde(skip)] + pub updated_at_ms: Option, pub project_path: String, pub labels: Vec, pub paths: Vec, @@ -216,11 +221,13 @@ pub async fn run_search( results.push(SearchResultDisplay { document_id: row.document_id, source_type: row.source_type.clone(), + source_entity_iid: row.source_entity_iid, title: row.title.clone().unwrap_or_default(), url: row.url.clone(), author: row.author.clone(), created_at: row.created_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(), labels: row.labels.clone(), paths: row.paths.clone(), @@ -242,6 +249,7 @@ pub async fn run_search( struct HydratedRow { document_id: i64, source_type: String, + source_entity_iid: Option, title: Option, url: Option, author: Option, @@ -268,7 +276,26 @@ fn hydrate_results(conn: &rusqlite::Connection, document_ids: &[i64]) -> Result< (SELECT json_group_array(dl.label_name) FROM document_labels dl WHERE dl.document_id = d.id) AS labels_json, (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 JOIN documents d ON d.id = j.value 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)?, labels: parse_json_array(&labels_json), paths: parse_json_array(&paths_json), + source_entity_iid: row.get(11)?, }) })? .collect::, _>>()?; @@ -326,7 +354,7 @@ fn render_snippet(snippet: &str) -> String { result } -pub fn print_search_results(response: &SearchResponse) { +pub fn print_search_results(response: &SearchResponse, explain: bool) { if !response.warnings.is_empty() { for w in &response.warnings { eprintln!("{} {}", Theme::warning().render("Warning:"), w); @@ -341,11 +369,13 @@ pub fn print_search_results(response: &SearchResponse) { return; } + // Phase 6: section divider header println!( - "\n {} results for '{}' {}", - Theme::bold().render(&response.total_results.to_string()), - Theme::bold().render(&response.query), - Theme::muted().render(&response.mode) + "{}", + render::section_divider(&format!( + "{} results for '{}' {}", + response.total_results, response.query, response.mode + )) ); for (i, result) in response.results.iter().enumerate() { @@ -359,52 +389,105 @@ pub fn print_search_results(response: &SearchResponse) { _ => Theme::muted().render(&format!("{:>5}", &result.source_type)), }; - // Title line: rank, type badge, title - println!( - " {:>3}. {} {}", - Theme::muted().render(&(i + 1).to_string()), - type_badge, - Theme::bold().render(&result.title) - ); + // Phase 1: entity ref (e.g. #42 or !99) + let entity_ref = result + .source_entity_iid + .map(|iid| match result.source_type.as_str() { + "issue" | "discussion" | "note" => Theme::issue_ref().render(&format!("#{iid}")), + "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 mut meta_parts: Vec = Vec::new(); meta_parts.push(Theme::muted().render(&result.project_path)); if let Some(ref author) = result.author { meta_parts.push(Theme::username().render(&format!("@{author}"))); } - if !result.labels.is_empty() { - let label_str = if result.labels.len() <= 3 { - result.labels.join(", ") - } else { - format!( - "{} +{}", - result.labels[..2].join(", "), - result.labels.len() - 2 - ) - }; - meta_parts.push(Theme::muted().render(&label_str)); - } - println!(" {}", meta_parts.join(&sep)); + println!("{indent}{}", meta_parts.join(&sep)); - // Snippet with highlight styling - let rendered = render_snippet(&result.snippet); - println!(" {rendered}"); + // 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) = result.explain { - println!( - " {} vec={} fts={} rrf={:.4}", + if let Some(ref explain_data) = result.explain { + let mut explain_line = format!( + "{indent}{} vec={} fts={} rrf={:.4}", Theme::accent().render("explain"), - explain + explain_data .vector_rank .map(|r| r.to_string()) .unwrap_or_else(|| "-".into()), - explain + explain_data .fts_rank .map(|r| r.to_string()) .unwrap_or_else(|| "-".into()), - explain.rrf_score + 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 { + result.labels.join(", ") + } else { + format!( + "{} +{}", + result.labels[..2].join(", "), + result.labels.len() - 2 + ) + }; + explain_line.push_str(&format!(" {}", Theme::muted().render(&label_str))); + } + println!("{explain_line}"); + } + } + + // Phase 4: drill-down hint footer + if let Some(first) = response.results.first() + && let Some(iid) = first.source_entity_iid + { + 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!( + "\n {} {}", + Theme::dim().render("Tip:"), + Theme::dim().render(&format!("{cmd} for details")) ); } } diff --git a/src/cli/render.rs b/src/cli/render.rs index 7dfdcb1..97bf896 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -569,6 +569,32 @@ pub fn terminal_width() -> usize { 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. pub fn truncate(s: &str, max: usize) -> String { if max < 4 { @@ -1459,24 +1485,19 @@ mod tests { // ── 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 { - 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 + super::strip_ansi(s) + } + + #[test] + fn visible_width_strips_ansi() { + let styled = "\x1b[1mhello\x1b[0m".to_string(); + assert_eq!(super::visible_width(&styled), 5); + } + + #[test] + fn visible_width_plain_string() { + assert_eq!(super::visible_width("hello"), 5); } } diff --git a/src/cli/robot.rs b/src/cli/robot.rs index 421c38a..6987a89 100644 --- a/src/cli/robot.rs +++ b/src/cli/robot.rs @@ -56,10 +56,16 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec { .iter() .map(|s| (*s).to_string()) .collect(), - "search" => ["document_id", "title", "source_type", "score"] - .iter() - .map(|s| (*s).to_string()) - .collect(), + "search" => [ + "document_id", + "title", + "source_type", + "source_entity_iid", + "score", + ] + .iter() + .map(|s| (*s).to_string()) + .collect(), "timeline" => ["timestamp", "type", "entity_iid", "detail"] .iter() .map(|s| (*s).to_string())