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:
@@ -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(¶ms.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(¬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
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}\""))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user