From 6d8547405215538201f89a7faf828eeedd646950 Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 13 Mar 2026 11:01:17 -0400 Subject: [PATCH] refactor(cli): adopt flex-width rendering, remove data-layer truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded truncation widths across CLI commands with render::flex_width() calls that adapt to terminal size. Remove server-side truncate_to_chars() in timeline collect/seed stages so full text is preserved through the pipeline — truncation now happens only at the presentation layer where terminal width is known. Affected commands: explain, file-history, list (issues/mrs/notes), me, timeline, who (active/expert/workload). --- src/cli/commands/explain.rs | 34 ++++++++++--------------- src/cli/commands/file_history.rs | 2 +- src/cli/commands/list/issues.rs | 4 +-- src/cli/commands/list/mrs.rs | 11 ++++---- src/cli/commands/list/notes.rs | 11 ++++---- src/cli/commands/list/render_helpers.rs | 10 ++++---- src/cli/commands/me/render_human.rs | 14 ++++------ src/cli/commands/timeline.rs | 5 ++-- src/cli/commands/who/active.rs | 2 +- src/cli/commands/who/expert.rs | 5 ++-- src/cli/commands/who/workload.rs | 8 +++--- src/timeline/collect.rs | 6 ++--- src/timeline/seed.rs | 4 +-- src/timeline/timeline_collect_tests.rs | 7 ++--- src/timeline/timeline_seed_tests.rs | 2 +- 15 files changed, 59 insertions(+), 66 deletions(-) diff --git a/src/cli/commands/explain.rs b/src/cli/commands/explain.rs index 1e763fd..3d97a54 100644 --- a/src/cli/commands/explain.rs +++ b/src/cli/commands/explain.rs @@ -378,17 +378,10 @@ fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result> { // Description excerpt helper // --------------------------------------------------------------------------- -fn truncate_description(desc: Option<&str>, max_len: usize) -> String { +fn truncate_description(desc: Option<&str>) -> String { match desc { None | Some("") => "(no description)".to_string(), - Some(s) => { - if s.len() <= max_len { - s.to_string() - } else { - let boundary = s.floor_char_boundary(max_len); - format!("{}...", &s[..boundary]) - } - } + Some(s) => s.to_string(), } } @@ -413,7 +406,7 @@ pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result String { if text.len() <= max_len { text.to_string() @@ -682,7 +677,7 @@ pub fn extract_key_decisions( timestamp: ms_to_iso(event.created_at), actor: event.actor.clone(), action: event.description.clone(), - context_note: truncate_note(¬e.body, NOTE_TRUNCATE_LEN), + context_note: note.body.clone(), }); } } @@ -1257,7 +1252,7 @@ pub fn print_explain(result: &ExplainResult) { Theme::dim().render(&to_relative(&t.last_note_at)) ); if let Some(ref excerpt) = t.first_note_excerpt { - let preview = render::truncate(excerpt, 100); + let preview = render::truncate(excerpt, render::flex_width(8, 30)); // Show first line only in human output if let Some(line) = preview.lines().next() { println!(" {}", Theme::muted().render(line)); @@ -1283,7 +1278,7 @@ pub fn print_explain(result: &ExplainResult) { " {} {} — {} {}", Icons::success(), Theme::mr_ref().render(&format!("!{}", mr.iid)), - render::truncate(&mr.title, 60), + render::truncate(&mr.title, render::flex_width(25, 20)), mr_state.render(&format!("[{}]", mr.state)) ); } @@ -1305,7 +1300,7 @@ pub fn print_explain(result: &ExplainResult) { println!( " {arrow} {} {}{state_str} ({})", ref_style.render(&format!("{ref_prefix}{}", ri.iid)), - render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), 50), + render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), render::flex_width(30, 20)), Theme::dim().render(&ri.reference_type) ); } @@ -1596,14 +1591,13 @@ mod tests { #[test] fn test_truncate_description() { - assert_eq!(truncate_description(None, 500), "(no description)"); - assert_eq!(truncate_description(Some(""), 500), "(no description)"); - assert_eq!(truncate_description(Some("short"), 500), "short"); + assert_eq!(truncate_description(None), "(no description)"); + assert_eq!(truncate_description(Some("")), "(no description)"); + assert_eq!(truncate_description(Some("short")), "short"); let long = "a".repeat(600); - let truncated = truncate_description(Some(&long), 500); - assert!(truncated.ends_with("...")); - assert!(truncated.len() <= 504); // 500 + "..." + let result = truncate_description(Some(&long)); + assert_eq!(result, long); // no truncation — full description preserved } // ----------------------------------------------------------------------- diff --git a/src/cli/commands/file_history.rs b/src/cli/commands/file_history.rs index 6bbec0e..bbf6293 100644 --- a/src/cli/commands/file_history.rs +++ b/src/cli/commands/file_history.rs @@ -359,7 +359,7 @@ pub fn print_file_history(result: &FileHistoryResult) { " {} {} {} {} @{} {} {}", icon, Theme::accent().render(&format!("!{}", mr.iid)), - render::truncate(&mr.title, 50), + render::truncate(&mr.title, render::flex_width(45, 20)), state_style.render(&mr.state), mr.author_username, date, diff --git a/src/cli/commands/list/issues.rs b/src/cli/commands/list/issues.rs index 6b1ffa2..f2ced89 100644 --- a/src/cli/commands/list/issues.rs +++ b/src/cli/commands/list/issues.rs @@ -359,10 +359,10 @@ pub fn print_list_issues(result: &ListResult) { } headers.extend(["Assignee", "Labels", "Disc", "Updated"]); - let mut table = LoreTable::new().headers(&headers).align(0, Align::Right); + let mut table = LoreTable::new().headers(&headers).align(0, Align::Right).flex_col(1); for issue in &result.issues { - let title = render::truncate(&issue.title, 45); + let title = issue.title.clone(); let relative_time = render::format_relative_time_compact(issue.updated_at); let labels = render::format_labels_bare(&issue.labels, 2); let assignee = format_assignees(&issue.assignees); diff --git a/src/cli/commands/list/mrs.rs b/src/cli/commands/list/mrs.rs index c182405..fa06aa3 100644 --- a/src/cli/commands/list/mrs.rs +++ b/src/cli/commands/list/mrs.rs @@ -329,17 +329,18 @@ pub fn print_list_mrs(result: &MrListResult) { .headers(&[ "IID", "Title", "State", "Author", "Branches", "Disc", "Updated", ]) - .align(0, Align::Right); + .align(0, Align::Right) + .flex_col(1); for mr in &result.mrs { let title = if mr.draft { - format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42)) + format!("{} {}", Icons::mr_draft(), mr.title) } else { - render::truncate(&mr.title, 45) + mr.title.clone() }; let relative_time = render::format_relative_time_compact(mr.updated_at); - let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); + let branches = format_branches(&mr.target_branch, &mr.source_branch); let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); let (icon, style) = match mr.state.as_str() { @@ -356,7 +357,7 @@ pub fn print_list_mrs(result: &MrListResult) { StyledCell::plain(title), state_cell, StyledCell::styled( - format!("@{}", render::truncate(&mr.author_username, 12)), + format!("@{}", mr.author_username), Theme::accent(), ), StyledCell::styled(branches, Theme::info()), diff --git a/src/cli/commands/list/notes.rs b/src/cli/commands/list/notes.rs index d24e38f..981f3c9 100644 --- a/src/cli/commands/list/notes.rs +++ b/src/cli/commands/list/notes.rs @@ -9,9 +9,7 @@ use crate::core::path_resolver::escape_like as note_escape_like; use crate::core::project::resolve_project; use crate::core::time::{iso_to_ms, ms_to_iso, parse_since}; -use super::render_helpers::{ - format_note_parent, format_note_path, format_note_type, truncate_body, -}; +use super::render_helpers::{format_note_parent, format_note_path, format_note_type}; #[derive(Debug, Serialize)] pub struct NoteListRow { @@ -161,13 +159,14 @@ pub fn print_list_notes(result: &NoteListResult) { "Parent", "Created", ]) - .align(0, Align::Right); + .align(0, Align::Right) + .flex_col(3); for note in &result.notes { let body = note .body .as_deref() - .map(|b| truncate_body(b, 60)) + .map(std::borrow::ToOwned::to_owned) .unwrap_or_default(); let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid); @@ -177,7 +176,7 @@ pub fn print_list_notes(result: &NoteListResult) { table.add_row(vec![ StyledCell::styled(note.gitlab_id.to_string(), Theme::info()), StyledCell::styled( - format!("@{}", render::truncate(¬e.author_username, 12)), + format!("@{}", note.author_username), Theme::accent(), ), StyledCell::plain(note_type), diff --git a/src/cli/commands/list/render_helpers.rs b/src/cli/commands/list/render_helpers.rs index e0449e1..fb8f808 100644 --- a/src/cli/commands/list/render_helpers.rs +++ b/src/cli/commands/list/render_helpers.rs @@ -1,4 +1,4 @@ -use crate::cli::render::{self, StyledCell, Theme}; +use crate::cli::render::{StyledCell, Theme}; pub(crate) fn format_assignees(assignees: &[String]) -> String { if assignees.is_empty() { @@ -9,7 +9,7 @@ pub(crate) fn format_assignees(assignees: &[String]) -> String { let shown: Vec = assignees .iter() .take(max_shown) - .map(|s| format!("@{}", render::truncate(s, 10))) + .map(|s| format!("@{s}")) .collect(); let overflow = assignees.len().saturating_sub(max_shown); @@ -34,11 +34,11 @@ pub(crate) fn format_discussions(total: i64, unresolved: i64) -> StyledCell { } } -pub(crate) fn format_branches(target: &str, source: &str, max_width: usize) -> String { - let full = format!("{} <- {}", target, source); - render::truncate(&full, max_width) +pub(crate) fn format_branches(target: &str, source: &str) -> String { + format!("{} <- {}", target, source) } +#[cfg(test)] pub(crate) fn truncate_body(body: &str, max_len: usize) -> String { if body.chars().count() <= max_len { body.to_string() diff --git a/src/cli/commands/me/render_human.rs b/src/cli/commands/me/render_human.rs index be2f260..9a0739e 100644 --- a/src/cli/commands/me/render_human.rs +++ b/src/cli/commands/me/render_human.rs @@ -10,9 +10,7 @@ use super::types::{ /// Compute the title/summary column width for a section given its fixed overhead. /// Returns a width clamped to [20, 80]. fn title_width(overhead: usize) -> usize { - render::terminal_width() - .saturating_sub(overhead) - .clamp(20, 80) + render::flex_width(overhead, 20) } // ─── Glyph Mode Helper ────────────────────────────────────────────────────── @@ -416,13 +414,12 @@ pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool) // Columns: badge | ref | summary | actor | time // Table handles alignment, padding, and truncation automatically. - let summary_max = title_width(46); let mut table = Table::new() .columns(5) .indent(4) .align(1, Align::Right) .align(4, Align::Right) - .max_width(2, summary_max); + .flex_col(2); for event in events { let badge_label = activity_badge_label(&event.event_type); @@ -508,7 +505,7 @@ pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool) if let Some(preview) = &event.body_preview && !preview.is_empty() { - let truncated = render::truncate(preview, 60); + let truncated = render::truncate(preview, render::flex_width(8, 30)); println!(" {}", Theme::dim().render(&format!("\"{truncated}\""))); } } @@ -576,12 +573,11 @@ pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bo } // Sub-events as indented rows - let summary_max = title_width(42); let mut table = Table::new() .columns(3) .indent(6) .align(2, Align::Right) - .max_width(1, summary_max); + .flex_col(1); for event in &group.events { let badge = activity_badge_label(&event.event_type); @@ -610,7 +606,7 @@ pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bo if let Some(preview) = &event.body_preview && !preview.is_empty() { - let truncated = render::truncate(preview, 60); + let truncated = render::truncate(preview, render::flex_width(10, 30)); println!( " {}", Theme::dim().render(&format!("\"{truncated}\"")) diff --git a/src/cli/commands/timeline.rs b/src/cli/commands/timeline.rs index 5c8e8f0..190d16c 100644 --- a/src/cli/commands/timeline.rs +++ b/src/cli/commands/timeline.rs @@ -235,8 +235,9 @@ fn print_timeline_event(event: &TimelineEvent) { .unwrap_or_default(); let expanded_marker = if event.is_seed { "" } else { " [expanded]" }; - let summary = render::truncate(&event.summary, 50); - println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}"); + let summary_width = render::flex_width(40, 20); + let summary = render::truncate(&event.summary, summary_width); + println!("{date} {tag} {entity_icon}{entity_ref:7} {summary} {actor}{expanded_marker}"); // Show snippet for evidence notes if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type diff --git a/src/cli/commands/who/active.rs b/src/cli/commands/who/active.rs index 53debbd..2bd46a0 100644 --- a/src/cli/commands/who/active.rs +++ b/src/cli/commands/who/active.rs @@ -263,7 +263,7 @@ pub(super) fn print_active_human(r: &ActiveResult, project_path: Option<&str>) { println!( " {} {} {} {} notes {}", Theme::info().render(&format!("{prefix}{}", disc.entity_iid)), - render::truncate(&disc.entity_title, 40), + render::truncate(&disc.entity_title, render::flex_width(30, 20)), Theme::dim().render(&render::format_relative_time(disc.last_note_at)), disc.note_count, Theme::dim().render(&disc.project_path), diff --git a/src/cli/commands/who/expert.rs b/src/cli/commands/who/expert.rs index e275c88..22b0bb4 100644 --- a/src/cli/commands/who/expert.rs +++ b/src/cli/commands/who/expert.rs @@ -769,11 +769,12 @@ pub(super) fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) { } else { String::new() }; + let title_budget = render::flex_width(55, 20); println!( - " {:<3} {:<30} {:>30} {:>10} {}", + " {:<3} {} {} {:>10} {}", Theme::dim().render(&d.role), d.mr_ref, - render::truncate(&format!("\"{}\"", d.title), 30), + render::truncate(&format!("\"{}\"", d.title), title_budget), notes_str, Theme::dim().render(&render::format_relative_time(d.last_activity_ms)), ); diff --git a/src/cli/commands/who/workload.rs b/src/cli/commands/who/workload.rs index 7e46fd2..c535330 100644 --- a/src/cli/commands/who/workload.rs +++ b/src/cli/commands/who/workload.rs @@ -217,7 +217,7 @@ pub(super) fn print_workload_human(r: &WorkloadResult) { println!( " {} {} {}", Theme::info().render(&item.ref_), - render::truncate(&item.title, 40), + render::truncate(&item.title, render::flex_width(25, 20)), Theme::dim().render(&render::format_relative_time(item.updated_at)), ); } @@ -239,7 +239,7 @@ pub(super) fn print_workload_human(r: &WorkloadResult) { println!( " {} {}{} {}", Theme::info().render(&mr.ref_), - render::truncate(&mr.title, 35), + render::truncate(&mr.title, render::flex_width(33, 20)), Theme::dim().render(draft), Theme::dim().render(&render::format_relative_time(mr.updated_at)), ); @@ -266,7 +266,7 @@ pub(super) fn print_workload_human(r: &WorkloadResult) { println!( " {} {}{} {}", Theme::info().render(&mr.ref_), - render::truncate(&mr.title, 30), + render::truncate(&mr.title, render::flex_width(40, 20)), Theme::dim().render(&author), Theme::dim().render(&render::format_relative_time(mr.updated_at)), ); @@ -292,7 +292,7 @@ pub(super) fn print_workload_human(r: &WorkloadResult) { " {} {} {} {}", Theme::dim().render(&disc.entity_type), Theme::info().render(&disc.ref_), - render::truncate(&disc.entity_title, 35), + render::truncate(&disc.entity_title, render::flex_width(30, 20)), Theme::dim().render(&render::format_relative_time(disc.last_note_at)), ); } diff --git a/src/timeline/collect.rs b/src/timeline/collect.rs index 3c6837d..026598a 100644 --- a/src/timeline/collect.rs +++ b/src/timeline/collect.rs @@ -3,8 +3,8 @@ use rusqlite::Connection; use std::collections::HashSet; use super::types::{ - EntityRef, ExpandedEntityRef, MatchedDiscussion, THREAD_MAX_NOTES, THREAD_NOTE_MAX_CHARS, - ThreadNote, TimelineEvent, TimelineEventType, truncate_to_chars, + EntityRef, ExpandedEntityRef, MatchedDiscussion, THREAD_MAX_NOTES, ThreadNote, TimelineEvent, + TimelineEventType, }; use crate::core::error::{LoreError, Result}; @@ -440,7 +440,7 @@ fn collect_discussion_threads( let mut notes = Vec::new(); for row_result in rows { let (note_id, author, body, created_at) = row_result?; - let body = truncate_to_chars(body.as_deref().unwrap_or(""), THREAD_NOTE_MAX_CHARS); + let body = body.as_deref().unwrap_or("").to_owned(); notes.push(ThreadNote { note_id, author, diff --git a/src/timeline/seed.rs b/src/timeline/seed.rs index 561ac4e..fd48513 100644 --- a/src/timeline/seed.rs +++ b/src/timeline/seed.rs @@ -5,7 +5,7 @@ use tracing::debug; use super::types::{ EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_by_iid, - resolve_entity_ref, truncate_to_chars, + resolve_entity_ref, }; use crate::core::error::Result; use crate::embedding::ollama::OllamaClient; @@ -337,7 +337,7 @@ fn find_evidence_notes( proj_id, ) = row_result?; - let snippet = truncate_to_chars(body.as_deref().unwrap_or(""), 200); + let snippet = body.as_deref().unwrap_or("").to_owned(); let entity_ref = resolve_entity_ref(conn, &parent_type, parent_entity_id, Some(proj_id))?; let (iid, project_path) = match entity_ref { diff --git a/src/timeline/timeline_collect_tests.rs b/src/timeline/timeline_collect_tests.rs index 6f95d17..18c0cde 100644 --- a/src/timeline/timeline_collect_tests.rs +++ b/src/timeline/timeline_collect_tests.rs @@ -553,9 +553,10 @@ fn test_collect_discussion_thread_body_truncation() { .unwrap(); if let TimelineEventType::DiscussionThread { notes, .. } = &thread.event_type { - assert!( - notes[0].body.chars().count() <= crate::timeline::THREAD_NOTE_MAX_CHARS, - "Body should be truncated to THREAD_NOTE_MAX_CHARS" + assert_eq!( + notes[0].body.chars().count(), + 10_000, + "Body should preserve full text without truncation" ); } else { panic!("Expected DiscussionThread"); diff --git a/src/timeline/timeline_seed_tests.rs b/src/timeline/timeline_seed_tests.rs index 0e0c849..bcd24de 100644 --- a/src/timeline/timeline_seed_tests.rs +++ b/src/timeline/timeline_seed_tests.rs @@ -288,7 +288,7 @@ fn test_seed_evidence_snippet_truncated() { if let TimelineEventType::NoteEvidence { snippet, .. } = &result.evidence_notes[0].event_type { - assert!(snippet.chars().count() <= 200); + assert_eq!(snippet.chars().count(), 500, "snippet should preserve full body text"); } else { panic!("Expected NoteEvidence"); }