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
// ---------------------------------------------------------------------------
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<ExplainR
};
let description_excerpt = if should_include(&params.sections, "description") {
Some(truncate_description(description.as_deref(), 500))
Some(truncate_description(description.as_deref()))
} else {
None
};
@@ -537,8 +530,10 @@ const DECISION_WINDOW_MS: i64 = 60 * 60 * 1000;
/// Maximum length (in bytes, snapped to a char boundary) for the
/// `context_note` field in a `KeyDecision`.
#[allow(dead_code)]
const NOTE_TRUNCATE_LEN: usize = 500;
#[allow(dead_code)]
fn truncate_note(text: &str, max_len: usize) -> 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(&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))
);
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
}
// -----------------------------------------------------------------------

View File

@@ -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,

View File

@@ -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);

View File

@@ -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()),

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::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(&note.author_username, 12)),
format!("@{}", note.author_username),
Theme::accent(),
),
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 {
if assignees.is_empty() {
@@ -9,7 +9,7 @@ pub(crate) fn format_assignees(assignees: &[String]) -> String {
let shown: Vec<String> = 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()

View File

@@ -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}\""))

View File

@@ -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

View File

@@ -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),

View File

@@ -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)),
);

View File

@@ -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)),
);
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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");
}