refactor(cli): adopt flex-width rendering, remove data-layer truncation

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).
This commit is contained in:
teernisse
2026-03-13 11:01:17 -04:00
parent ef8a316372
commit 6d85474052
15 changed files with 59 additions and 66 deletions

View File

@@ -378,17 +378,10 @@ fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
// Description excerpt helper // Description excerpt helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn truncate_description(desc: Option<&str>, max_len: usize) -> String { fn truncate_description(desc: Option<&str>) -> String {
match desc { match desc {
None | Some("") => "(no description)".to_string(), None | Some("") => "(no description)".to_string(),
Some(s) => { Some(s) => s.to_string(),
if s.len() <= max_len {
s.to_string()
} else {
let boundary = s.floor_char_boundary(max_len);
format!("{}...", &s[..boundary])
}
}
} }
} }
@@ -413,7 +406,7 @@ pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result<ExplainR
}; };
let description_excerpt = if should_include(&params.sections, "description") { let description_excerpt = if should_include(&params.sections, "description") {
Some(truncate_description(description.as_deref(), 500)) Some(truncate_description(description.as_deref()))
} else { } else {
None None
}; };
@@ -537,8 +530,10 @@ const DECISION_WINDOW_MS: i64 = 60 * 60 * 1000;
/// Maximum length (in bytes, snapped to a char boundary) for the /// Maximum length (in bytes, snapped to a char boundary) for the
/// `context_note` field in a `KeyDecision`. /// `context_note` field in a `KeyDecision`.
#[allow(dead_code)]
const NOTE_TRUNCATE_LEN: usize = 500; const NOTE_TRUNCATE_LEN: usize = 500;
#[allow(dead_code)]
fn truncate_note(text: &str, max_len: usize) -> String { fn truncate_note(text: &str, max_len: usize) -> String {
if text.len() <= max_len { if text.len() <= max_len {
text.to_string() text.to_string()
@@ -682,7 +677,7 @@ pub fn extract_key_decisions(
timestamp: ms_to_iso(event.created_at), timestamp: ms_to_iso(event.created_at),
actor: event.actor.clone(), actor: event.actor.clone(),
action: event.description.clone(), action: event.description.clone(),
context_note: truncate_note(&note.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)) Theme::dim().render(&to_relative(&t.last_note_at))
); );
if let Some(ref excerpt) = t.first_note_excerpt { 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 // Show first line only in human output
if let Some(line) = preview.lines().next() { if let Some(line) = preview.lines().next() {
println!(" {}", Theme::muted().render(line)); println!(" {}", Theme::muted().render(line));
@@ -1283,7 +1278,7 @@ pub fn print_explain(result: &ExplainResult) {
" {} {}{} {}", " {} {}{} {}",
Icons::success(), Icons::success(),
Theme::mr_ref().render(&format!("!{}", mr.iid)), 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)) mr_state.render(&format!("[{}]", mr.state))
); );
} }
@@ -1305,7 +1300,7 @@ pub fn print_explain(result: &ExplainResult) {
println!( println!(
" {arrow} {} {}{state_str} ({})", " {arrow} {} {}{state_str} ({})",
ref_style.render(&format!("{ref_prefix}{}", ri.iid)), 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) Theme::dim().render(&ri.reference_type)
); );
} }
@@ -1596,14 +1591,13 @@ mod tests {
#[test] #[test]
fn test_truncate_description() { fn test_truncate_description() {
assert_eq!(truncate_description(None, 500), "(no description)"); assert_eq!(truncate_description(None), "(no description)");
assert_eq!(truncate_description(Some(""), 500), "(no description)"); assert_eq!(truncate_description(Some("")), "(no description)");
assert_eq!(truncate_description(Some("short"), 500), "short"); assert_eq!(truncate_description(Some("short")), "short");
let long = "a".repeat(600); let long = "a".repeat(600);
let truncated = truncate_description(Some(&long), 500); let result = truncate_description(Some(&long));
assert!(truncated.ends_with("...")); assert_eq!(result, long); // no truncation — full description preserved
assert!(truncated.len() <= 504); // 500 + "..."
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------

View File

@@ -359,7 +359,7 @@ pub fn print_file_history(result: &FileHistoryResult) {
" {} {} {} {} @{} {} {}", " {} {} {} {} @{} {} {}",
icon, icon,
Theme::accent().render(&format!("!{}", mr.iid)), 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), state_style.render(&mr.state),
mr.author_username, mr.author_username,
date, date,

View File

@@ -359,10 +359,10 @@ pub fn print_list_issues(result: &ListResult) {
} }
headers.extend(["Assignee", "Labels", "Disc", "Updated"]); 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 { 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 relative_time = render::format_relative_time_compact(issue.updated_at);
let labels = render::format_labels_bare(&issue.labels, 2); let labels = render::format_labels_bare(&issue.labels, 2);
let assignee = format_assignees(&issue.assignees); let assignee = format_assignees(&issue.assignees);

View File

@@ -329,17 +329,18 @@ pub fn print_list_mrs(result: &MrListResult) {
.headers(&[ .headers(&[
"IID", "Title", "State", "Author", "Branches", "Disc", "Updated", "IID", "Title", "State", "Author", "Branches", "Disc", "Updated",
]) ])
.align(0, Align::Right); .align(0, Align::Right)
.flex_col(1);
for mr in &result.mrs { for mr in &result.mrs {
let title = if mr.draft { let title = if mr.draft {
format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42)) format!("{} {}", Icons::mr_draft(), mr.title)
} else { } else {
render::truncate(&mr.title, 45) mr.title.clone()
}; };
let relative_time = render::format_relative_time_compact(mr.updated_at); 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 discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
let (icon, style) = match mr.state.as_str() { let (icon, style) = match mr.state.as_str() {
@@ -356,7 +357,7 @@ pub fn print_list_mrs(result: &MrListResult) {
StyledCell::plain(title), StyledCell::plain(title),
state_cell, state_cell,
StyledCell::styled( StyledCell::styled(
format!("@{}", render::truncate(&mr.author_username, 12)), format!("@{}", mr.author_username),
Theme::accent(), Theme::accent(),
), ),
StyledCell::styled(branches, Theme::info()), StyledCell::styled(branches, Theme::info()),

View File

@@ -9,9 +9,7 @@ use crate::core::path_resolver::escape_like as note_escape_like;
use crate::core::project::resolve_project; use crate::core::project::resolve_project;
use crate::core::time::{iso_to_ms, ms_to_iso, parse_since}; use crate::core::time::{iso_to_ms, ms_to_iso, parse_since};
use super::render_helpers::{ use super::render_helpers::{format_note_parent, format_note_path, format_note_type};
format_note_parent, format_note_path, format_note_type, truncate_body,
};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct NoteListRow { pub struct NoteListRow {
@@ -161,13 +159,14 @@ pub fn print_list_notes(result: &NoteListResult) {
"Parent", "Parent",
"Created", "Created",
]) ])
.align(0, Align::Right); .align(0, Align::Right)
.flex_col(3);
for note in &result.notes { for note in &result.notes {
let body = note let body = note
.body .body
.as_deref() .as_deref()
.map(|b| truncate_body(b, 60)) .map(std::borrow::ToOwned::to_owned)
.unwrap_or_default(); .unwrap_or_default();
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); 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); 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![ table.add_row(vec![
StyledCell::styled(note.gitlab_id.to_string(), Theme::info()), StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
StyledCell::styled( StyledCell::styled(
format!("@{}", render::truncate(&note.author_username, 12)), format!("@{}", note.author_username),
Theme::accent(), Theme::accent(),
), ),
StyledCell::plain(note_type), StyledCell::plain(note_type),

View File

@@ -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 { pub(crate) fn format_assignees(assignees: &[String]) -> String {
if assignees.is_empty() { if assignees.is_empty() {
@@ -9,7 +9,7 @@ pub(crate) fn format_assignees(assignees: &[String]) -> String {
let shown: Vec<String> = assignees let shown: Vec<String> = assignees
.iter() .iter()
.take(max_shown) .take(max_shown)
.map(|s| format!("@{}", render::truncate(s, 10))) .map(|s| format!("@{s}"))
.collect(); .collect();
let overflow = assignees.len().saturating_sub(max_shown); 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 { pub(crate) fn format_branches(target: &str, source: &str) -> String {
let full = format!("{} <- {}", target, source); format!("{} <- {}", target, source)
render::truncate(&full, max_width)
} }
#[cfg(test)]
pub(crate) fn truncate_body(body: &str, max_len: usize) -> String { pub(crate) fn truncate_body(body: &str, max_len: usize) -> String {
if body.chars().count() <= max_len { if body.chars().count() <= max_len {
body.to_string() body.to_string()

View File

@@ -10,9 +10,7 @@ use super::types::{
/// Compute the title/summary column width for a section given its fixed overhead. /// Compute the title/summary column width for a section given its fixed overhead.
/// Returns a width clamped to [20, 80]. /// Returns a width clamped to [20, 80].
fn title_width(overhead: usize) -> usize { fn title_width(overhead: usize) -> usize {
render::terminal_width() render::flex_width(overhead, 20)
.saturating_sub(overhead)
.clamp(20, 80)
} }
// ─── Glyph Mode Helper ────────────────────────────────────────────────────── // ─── Glyph Mode Helper ──────────────────────────────────────────────────────
@@ -416,13 +414,12 @@ pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool)
// Columns: badge | ref | summary | actor | time // Columns: badge | ref | summary | actor | time
// Table handles alignment, padding, and truncation automatically. // Table handles alignment, padding, and truncation automatically.
let summary_max = title_width(46);
let mut table = Table::new() let mut table = Table::new()
.columns(5) .columns(5)
.indent(4) .indent(4)
.align(1, Align::Right) .align(1, Align::Right)
.align(4, Align::Right) .align(4, Align::Right)
.max_width(2, summary_max); .flex_col(2);
for event in events { for event in events {
let badge_label = activity_badge_label(&event.event_type); 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 if let Some(preview) = &event.body_preview
&& !preview.is_empty() && !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}\""))); 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 // Sub-events as indented rows
let summary_max = title_width(42);
let mut table = Table::new() let mut table = Table::new()
.columns(3) .columns(3)
.indent(6) .indent(6)
.align(2, Align::Right) .align(2, Align::Right)
.max_width(1, summary_max); .flex_col(1);
for event in &group.events { for event in &group.events {
let badge = activity_badge_label(&event.event_type); 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 if let Some(preview) = &event.body_preview
&& !preview.is_empty() && !preview.is_empty()
{ {
let truncated = render::truncate(preview, 60); let truncated = render::truncate(preview, render::flex_width(10, 30));
println!( println!(
" {}", " {}",
Theme::dim().render(&format!("\"{truncated}\"")) Theme::dim().render(&format!("\"{truncated}\""))

View File

@@ -235,8 +235,9 @@ fn print_timeline_event(event: &TimelineEvent) {
.unwrap_or_default(); .unwrap_or_default();
let expanded_marker = if event.is_seed { "" } else { " [expanded]" }; let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
let summary = render::truncate(&event.summary, 50); let summary_width = render::flex_width(40, 20);
println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}"); 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 // Show snippet for evidence notes
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type

View File

@@ -263,7 +263,7 @@ pub(super) fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
println!( println!(
" {} {} {} {} notes {}", " {} {} {} {} notes {}",
Theme::info().render(&format!("{prefix}{}", disc.entity_iid)), 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)), Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
disc.note_count, disc.note_count,
Theme::dim().render(&disc.project_path), Theme::dim().render(&disc.project_path),

View File

@@ -769,11 +769,12 @@ pub(super) fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
} else { } else {
String::new() String::new()
}; };
let title_budget = render::flex_width(55, 20);
println!( println!(
" {:<3} {:<30} {:>30} {:>10} {}", " {:<3} {} {} {:>10} {}",
Theme::dim().render(&d.role), Theme::dim().render(&d.role),
d.mr_ref, d.mr_ref,
render::truncate(&format!("\"{}\"", d.title), 30), render::truncate(&format!("\"{}\"", d.title), title_budget),
notes_str, notes_str,
Theme::dim().render(&render::format_relative_time(d.last_activity_ms)), Theme::dim().render(&render::format_relative_time(d.last_activity_ms)),
); );

View File

@@ -217,7 +217,7 @@ pub(super) fn print_workload_human(r: &WorkloadResult) {
println!( println!(
" {} {} {}", " {} {} {}",
Theme::info().render(&item.ref_), 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)), Theme::dim().render(&render::format_relative_time(item.updated_at)),
); );
} }
@@ -239,7 +239,7 @@ pub(super) fn print_workload_human(r: &WorkloadResult) {
println!( println!(
" {} {}{} {}", " {} {}{} {}",
Theme::info().render(&mr.ref_), 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(draft),
Theme::dim().render(&render::format_relative_time(mr.updated_at)), Theme::dim().render(&render::format_relative_time(mr.updated_at)),
); );
@@ -266,7 +266,7 @@ pub(super) fn print_workload_human(r: &WorkloadResult) {
println!( println!(
" {} {}{} {}", " {} {}{} {}",
Theme::info().render(&mr.ref_), 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(&author),
Theme::dim().render(&render::format_relative_time(mr.updated_at)), 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::dim().render(&disc.entity_type),
Theme::info().render(&disc.ref_), 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)), Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
); );
} }

View File

@@ -3,8 +3,8 @@ use rusqlite::Connection;
use std::collections::HashSet; use std::collections::HashSet;
use super::types::{ use super::types::{
EntityRef, ExpandedEntityRef, MatchedDiscussion, THREAD_MAX_NOTES, THREAD_NOTE_MAX_CHARS, EntityRef, ExpandedEntityRef, MatchedDiscussion, THREAD_MAX_NOTES, ThreadNote, TimelineEvent,
ThreadNote, TimelineEvent, TimelineEventType, truncate_to_chars, TimelineEventType,
}; };
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
@@ -440,7 +440,7 @@ fn collect_discussion_threads(
let mut notes = Vec::new(); let mut notes = Vec::new();
for row_result in rows { for row_result in rows {
let (note_id, author, body, created_at) = row_result?; 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 { notes.push(ThreadNote {
note_id, note_id,
author, author,

View File

@@ -5,7 +5,7 @@ use tracing::debug;
use super::types::{ use super::types::{
EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_by_iid, EntityRef, MatchedDiscussion, TimelineEvent, TimelineEventType, resolve_entity_by_iid,
resolve_entity_ref, truncate_to_chars, resolve_entity_ref,
}; };
use crate::core::error::Result; use crate::core::error::Result;
use crate::embedding::ollama::OllamaClient; use crate::embedding::ollama::OllamaClient;
@@ -337,7 +337,7 @@ fn find_evidence_notes(
proj_id, proj_id,
) = row_result?; ) = 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 entity_ref = resolve_entity_ref(conn, &parent_type, parent_entity_id, Some(proj_id))?;
let (iid, project_path) = match entity_ref { let (iid, project_path) = match entity_ref {

View File

@@ -553,9 +553,10 @@ fn test_collect_discussion_thread_body_truncation() {
.unwrap(); .unwrap();
if let TimelineEventType::DiscussionThread { notes, .. } = &thread.event_type { if let TimelineEventType::DiscussionThread { notes, .. } = &thread.event_type {
assert!( assert_eq!(
notes[0].body.chars().count() <= crate::timeline::THREAD_NOTE_MAX_CHARS, notes[0].body.chars().count(),
"Body should be truncated to THREAD_NOTE_MAX_CHARS" 10_000,
"Body should preserve full text without truncation"
); );
} else { } else {
panic!("Expected DiscussionThread"); panic!("Expected DiscussionThread");

View File

@@ -288,7 +288,7 @@ fn test_seed_evidence_snippet_truncated() {
if let TimelineEventType::NoteEvidence { snippet, .. } = if let TimelineEventType::NoteEvidence { snippet, .. } =
&result.evidence_notes[0].event_type &result.evidence_notes[0].event_type
{ {
assert!(snippet.chars().count() <= 200); assert_eq!(snippet.chars().count(), 500, "snippet should preserve full body text");
} else { } else {
panic!("Expected NoteEvidence"); panic!("Expected NoteEvidence");
} }