From 08f52c07a4aaebeed500e30f489760eff513e5cb Mon Sep 17 00:00:00 2001 From: Karn Date: Sun, 19 Jul 2026 01:48:36 +0530 Subject: [PATCH] fix: render search snippets on one row and align columns by character width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two display-only defects in `autophagy search` human output: - Multiline snippets (codex-adapter events carry real embedded newlines/tabs outside the extracted command field) spilled a single result across several physical lines, breaking the aligned one-row-per-result table. clean_snippet now collapses every run of whitespace into a single space via a shared collapse_whitespace helper, on both the command-extraction and JSON-stripping paths. JSON output is untouched — raw snippets remain under --output json. - Column widths were computed from byte length. Rust's formatter pads &str by character count, so a byte-length budget over-counts multibyte cells and pads the column wider than its displayed content. Width is now measured with chars().count() to match the formatter's own measure. Adds unit tests for snippet whitespace collapsing and the width computation. Co-Authored-By: Claude Fable 5 --- crates/autophagy-cli/src/main.rs | 92 ++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index 8d5f747..cc00017 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -1347,17 +1347,38 @@ fn match_kind_label(kind: autophagy_store::RetrievalMatchKind) -> &'static str { /// framing characters are stripped so plain metadata text remains. fn clean_snippet(snippet: &str) -> String { if let Some(command) = extract_command_value(snippet) { - let trimmed = command.trim(); - if !trimmed.is_empty() { - return trimmed.to_owned(); + let collapsed = collapse_whitespace(&command); + if !collapsed.is_empty() { + return collapsed; } } let stripped: String = snippet - .trim() .chars() .filter(|character| !matches!(character, '{' | '}' | '"')) .collect(); - stripped.trim().to_owned() + collapse_whitespace(&stripped) +} + +/// Collapse every run of whitespace — including embedded newlines and tabs — +/// into a single space and trim the ends, so a cleaned snippet always renders on +/// exactly one row. Codex-adapter events carry multiline snippets that would +/// otherwise break the aligned single-row search layout. Display-only: the raw +/// snippet is preserved under `--output json`. +fn collapse_whitespace(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut pending_space = false; + for character in text.chars() { + if character.is_whitespace() { + pending_space = true; + } else { + if pending_space && !result.is_empty() { + result.push(' '); + } + pending_space = false; + result.push(character); + } + } + result } /// Extract the `command` field's string value from a (possibly truncated) FTS @@ -3073,11 +3094,26 @@ fn write_search_hits(writer: &mut impl Write, hits: &[RetrievalHit]) -> io::Resu }) .collect(); - let column_width = |column: &[String]| column.iter().map(String::len).max().unwrap_or(0); + // Measure width in characters, matching how the formatter pads: `{: io: #[cfg(test)] mod tests { use super::{ - MIN_SHORT_PREFIX, clean_snippet, percent, project_basename, resolve_stored_id, short_id, - shorten_project, truncate_words, + MIN_SHORT_PREFIX, clean_snippet, collapse_whitespace, percent, project_basename, + resolve_stored_id, short_id, shorten_project, truncate_words, }; const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -3627,6 +3663,44 @@ mod tests { assert_eq!(clean_snippet("cargo [test] failed"), "cargo [test] failed"); } + #[test] + fn clean_snippet_collapses_multiline_content_to_one_row() { + // Codex-adapter snippets embed literal newlines and tabs; they must + // collapse to single spaces so the result stays on one row. + let raw = "first line\n\tsecond line\r\nthird"; + let cleaned = clean_snippet(raw); + assert_eq!(cleaned, "first line second line third"); + assert!(!cleaned.contains(['\n', '\r', '\t'])); + } + + #[test] + fn clean_snippet_collapses_newlines_inside_command_value() { + let raw = "{\"command\":\"echo one\necho [two]\"}"; + assert_eq!(clean_snippet(raw), "echo one echo [two]"); + } + + #[test] + fn collapse_whitespace_trims_and_squeezes_runs() { + assert_eq!(collapse_whitespace(" a \n\n b\t\tc "), "a b c"); + assert_eq!(collapse_whitespace("\n\t "), ""); + assert_eq!(collapse_whitespace("solo"), "solo"); + } + + #[test] + fn column_width_counts_characters_not_bytes() { + // `format!` pads a `&str` by character count, so the width budget the + // renderer computes must too. A byte-length budget over-counts a + // multibyte cell and pads the column wider than its displayed content; + // a character-count budget equal to the value's own width adds nothing. + let value = "café—münchen".to_owned(); + let char_width = value.chars().count(); + assert!(char_width < value.len(), "sample must be multibyte"); + assert_eq!(format!("{value: