From 8572f6cc04cde74260d8f505b334b45dc260ca88 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Sat, 14 Feb 2026 10:03:20 -0500 Subject: [PATCH] refactor(cli): polish secondary commands with icons, number formatting, and section dividers Phase 6 of the UX overhaul. Applies consistent visual treatment across the remaining command outputs: stats, doctor, timeline, who, count, and drift. Stats (stats.rs): - Apply render::format_number() to all numeric values (documents, FTS indexed, embedding counts, chunks) for thousand-separator formatting in large databases Doctor (doctor.rs): - Replace Unicode check/warning/cross symbols with Icons::success(), Icons::warning(), Icons::error() for glyph-mode awareness - Add summary line after checks showing "Ready/Not ready" with counts of passed, warnings, and failed checks separated by middle dots - Remove "lore doctor" title header for cleaner output Count (count.rs): - Right-align numeric values with {:>10} format for columnar output in count and state breakdown displays Timeline (timeline.rs): - Add entity icons (issue/MR) before entity references in event rows - Refactor format_event_tag to pad plain text before applying style, preventing ANSI codes from breaking column alignment - Extract style_padded() helper for width-then-style pattern Who (who.rs): - Add Icons::user() before usernames in expert, workload, reviews, and overlap displays - Replace manual bold section headers with render::section_divider() in workload view (Assigned Issues, Authored MRs, Reviewing MRs, Unresolved Discussions) Drift (drift.rs): - Add Icons::error()/success() before drift detection status line - Replace '#' bar character with Unicode full block for similarity curve visualization Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/count.rs | 12 ++++---- src/cli/commands/doctor.rs | 60 ++++++++++++++++++++++++------------ src/cli/commands/drift.rs | 16 +++++++--- src/cli/commands/stats.rs | 39 ++++++++++++++++++----- src/cli/commands/timeline.rs | 48 +++++++++++++++++++---------- src/cli/commands/who.rs | 45 ++++++++++++++------------- 6 files changed, 145 insertions(+), 75 deletions(-) diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 083597a..10095ed 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -333,7 +333,7 @@ pub fn print_count(result: &CountResult) { if let Some(system_count) = result.system_count { println!( - "{}: {} {}", + "{}: {:>10} {}", Theme::info().render(&result.entity), Theme::bold().render(&count_str), Theme::dim().render(&format!( @@ -343,22 +343,22 @@ pub fn print_count(result: &CountResult) { ); } else { println!( - "{}: {}", + "{}: {:>10}", Theme::info().render(&result.entity), Theme::bold().render(&count_str) ); } if let Some(breakdown) = &result.state_breakdown { - println!(" opened: {}", render::format_number(breakdown.opened)); + println!(" opened: {:>10}", render::format_number(breakdown.opened)); if let Some(merged) = breakdown.merged { - println!(" merged: {}", render::format_number(merged)); + println!(" merged: {:>10}", render::format_number(merged)); } - println!(" closed: {}", render::format_number(breakdown.closed)); + println!(" closed: {:>10}", render::format_number(breakdown.closed)); if let Some(locked) = breakdown.locked && locked > 0 { - println!(" locked: {}", render::format_number(locked)); + println!(" locked: {:>10}", render::format_number(locked)); } } } diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs index a9d9957..0b507d7 100644 --- a/src/cli/commands/doctor.rs +++ b/src/cli/commands/doctor.rs @@ -1,4 +1,4 @@ -use crate::cli::render::Theme; +use crate::cli::render::{Icons, Theme}; use serde::Serialize; use crate::core::config::Config; @@ -530,7 +530,7 @@ fn check_logging(config: Option<&Config>) -> LoggingCheck { } pub fn print_doctor_results(result: &DoctorResult) { - println!("\nlore doctor\n"); + println!(); print_check("Config", &result.checks.config.result); print_check("Database", &result.checks.database.result); @@ -539,31 +539,53 @@ pub fn print_doctor_results(result: &DoctorResult) { print_check("Ollama", &result.checks.ollama.result); print_check("Logging", &result.checks.logging.result); + // Count statuses + let checks = [ + &result.checks.config.result, + &result.checks.database.result, + &result.checks.gitlab.result, + &result.checks.projects.result, + &result.checks.ollama.result, + &result.checks.logging.result, + ]; + let passed = checks + .iter() + .filter(|c| c.status == CheckStatus::Ok) + .count(); + let warnings = checks + .iter() + .filter(|c| c.status == CheckStatus::Warning) + .count(); + let failed = checks + .iter() + .filter(|c| c.status == CheckStatus::Error) + .count(); + println!(); + let mut summary_parts = Vec::new(); if result.success { - let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok; - if ollama_ok { - println!("{}", Theme::success().render("Status: Ready")); - } else { - println!( - "{} {}", - Theme::success().render("Status: Ready"), - Theme::warning() - .render("(lexical search available, semantic search requires Ollama)") - ); - } + summary_parts.push(Theme::success().render("Ready")); } else { - println!("{}", Theme::error().render("Status: Not ready")); + summary_parts.push(Theme::error().render("Not ready")); } + summary_parts.push(format!("{passed} passed")); + if warnings > 0 { + summary_parts.push(Theme::warning().render(&format!("{warnings} warning"))); + } + if failed > 0 { + summary_parts.push(Theme::error().render(&format!("{failed} failed"))); + } + println!(" {}", summary_parts.join(" \u{b7} ")); + println!(); } fn print_check(name: &str, result: &CheckResult) { - let symbol = match result.status { - CheckStatus::Ok => Theme::success().render("\u{2713}"), - CheckStatus::Warning => Theme::warning().render("\u{26a0}"), - CheckStatus::Error => Theme::error().render("\u{2717}"), + let icon = match result.status { + CheckStatus::Ok => Theme::success().render(Icons::success()), + CheckStatus::Warning => Theme::warning().render(Icons::warning()), + CheckStatus::Error => Theme::error().render(Icons::error()), }; let message = result.message.as_deref().unwrap_or(""); @@ -573,5 +595,5 @@ fn print_check(name: &str, result: &CheckResult) { CheckStatus::Error => Theme::error().render(message), }; - println!(" {symbol} {:<10} {message_styled}", name); + println!(" {icon} {:<10} {message_styled}", name); } diff --git a/src/cli/commands/drift.rs b/src/cli/commands/drift.rs index a9096ad..de85018 100644 --- a/src/cli/commands/drift.rs +++ b/src/cli/commands/drift.rs @@ -4,7 +4,7 @@ use std::sync::LazyLock; use regex::Regex; use serde::Serialize; -use crate::cli::render::Theme; +use crate::cli::render::{Icons, Theme}; use crate::cli::robot::RobotMeta; use crate::core::config::Config; use crate::core::db::create_connection; @@ -428,7 +428,11 @@ pub fn print_drift_human(response: &DriftResponse) { println!(); if response.drift_detected { - println!("{}", Theme::error().bold().render("DRIFT DETECTED")); + println!( + "{} {}", + Theme::error().render(Icons::error()), + Theme::error().bold().render("DRIFT DETECTED") + ); if let Some(dp) = &response.drift_point { println!( " At note #{} by @{} ({}) - similarity {:.2}", @@ -439,7 +443,11 @@ pub fn print_drift_human(response: &DriftResponse) { println!(" Topics: {}", response.drift_topics.join(", ")); } } else { - println!("{}", Theme::success().render("No drift detected")); + println!( + "{} {}", + Theme::success().render(Icons::success()), + Theme::success().render("No drift detected") + ); } println!(); @@ -450,7 +458,7 @@ pub fn print_drift_human(response: &DriftResponse) { println!("{}", Theme::bold().render("Similarity Curve:")); for pt in &response.similarity_curve { let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize; - let bar: String = "#".repeat(bar_len); + let bar: String = "\u{2588}".repeat(bar_len); println!( " {:>3} {:.2} {} @{}", pt.note_index, pt.similarity, bar, pt.author diff --git a/src/cli/commands/stats.rs b/src/cli/commands/stats.rs index 5906aad..3f97e65 100644 --- a/src/cli/commands/stats.rs +++ b/src/cli/commands/stats.rs @@ -328,26 +328,44 @@ fn section(title: &str) { pub fn print_stats(result: &StatsResult) { section("Documents"); - let mut parts = vec![format!("{} total", result.documents.total)]; + let mut parts = vec![format!( + "{} total", + render::format_number(result.documents.total) + )]; if result.documents.issues > 0 { - parts.push(format!("{} issues", result.documents.issues)); + parts.push(format!( + "{} issues", + render::format_number(result.documents.issues) + )); } if result.documents.merge_requests > 0 { - parts.push(format!("{} MRs", result.documents.merge_requests)); + parts.push(format!( + "{} MRs", + render::format_number(result.documents.merge_requests) + )); } if result.documents.discussions > 0 { - parts.push(format!("{} discussions", result.documents.discussions)); + parts.push(format!( + "{} discussions", + render::format_number(result.documents.discussions) + )); } println!(" {}", parts.join(" \u{b7} ")); if result.documents.truncated > 0 { println!( " {}", - Theme::warning().render(&format!("{} truncated", result.documents.truncated)) + Theme::warning().render(&format!( + "{} truncated", + render::format_number(result.documents.truncated) + )) ); } section("Search Index"); - println!(" {} FTS indexed", result.fts.indexed); + println!( + " {} FTS indexed", + render::format_number(result.fts.indexed) + ); let coverage_color = if result.embeddings.coverage_pct >= 95.0 { Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct)) } else if result.embeddings.coverage_pct >= 50.0 { @@ -357,12 +375,17 @@ pub fn print_stats(result: &StatsResult) { }; println!( " {} embedding coverage ({}/{})", - coverage_color, result.embeddings.embedded_documents, result.documents.total, + coverage_color, + render::format_number(result.embeddings.embedded_documents), + render::format_number(result.documents.total), ); if result.embeddings.total_chunks > 0 { println!( " {}", - Theme::dim().render(&format!("{} chunks", result.embeddings.total_chunks)) + Theme::dim().render(&format!( + "{} chunks", + render::format_number(result.embeddings.total_chunks) + )) ); } diff --git a/src/cli/commands/timeline.rs b/src/cli/commands/timeline.rs index 78284c0..ce473dd 100644 --- a/src/cli/commands/timeline.rs +++ b/src/cli/commands/timeline.rs @@ -1,4 +1,4 @@ -use crate::cli::render::{self, Theme}; +use crate::cli::render::{self, Icons, Theme}; use serde::Serialize; use crate::Config; @@ -202,6 +202,11 @@ pub fn print_timeline(result: &TimelineResult) { fn print_timeline_event(event: &TimelineEvent) { let date = render::format_date(event.timestamp); let tag = format_event_tag(&event.event_type); + let entity_icon = match event.entity_type.as_str() { + "issue" => Icons::issue_opened(), + "merge_request" => Icons::mr_opened(), + _ => "", + }; let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid); let actor = event .actor @@ -211,8 +216,7 @@ fn print_timeline_event(event: &TimelineEvent) { let expanded_marker = if event.is_seed { "" } else { " [expanded]" }; let summary = render::truncate(&event.summary, 50); - let tag_padded = format!("{:<12}", tag); - println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}"); + println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}"); // Show snippet for evidence notes if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type @@ -276,23 +280,33 @@ fn print_timeline_footer(result: &TimelineResult) { println!(); } +/// Format event tag: pad plain text to TAG_WIDTH, then apply style. +const TAG_WIDTH: usize = 10; + fn format_event_tag(event_type: &TimelineEventType) -> String { - match event_type { - TimelineEventType::Created => Theme::success().render("CREATED"), + let (label, style) = match event_type { + TimelineEventType::Created => ("CREATED", Theme::success()), TimelineEventType::StateChanged { state } => match state.as_str() { - "closed" => Theme::error().render("CLOSED"), - "reopened" => Theme::warning().render("REOPENED"), - _ => Theme::dim().render(&state.to_uppercase()), + "closed" => ("CLOSED", Theme::error()), + "reopened" => ("REOPENED", Theme::warning()), + _ => return style_padded(&state.to_uppercase(), TAG_WIDTH, Theme::dim()), }, - TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"), - TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"), - TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"), - TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"), - TimelineEventType::Merged => Theme::info().render("MERGED"), - TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"), - TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"), - TimelineEventType::CrossReferenced { .. } => Theme::dim().render("REF"), - } + TimelineEventType::LabelAdded { .. } => ("LABEL+", Theme::info()), + TimelineEventType::LabelRemoved { .. } => ("LABEL-", Theme::info()), + TimelineEventType::MilestoneSet { .. } => ("MILESTONE+", Theme::accent()), + TimelineEventType::MilestoneRemoved { .. } => ("MILESTONE-", Theme::accent()), + TimelineEventType::Merged => ("MERGED", Theme::info()), + TimelineEventType::NoteEvidence { .. } => ("NOTE", Theme::dim()), + TimelineEventType::DiscussionThread { .. } => ("THREAD", Theme::warning()), + TimelineEventType::CrossReferenced { .. } => ("REF", Theme::dim()), + }; + style_padded(label, TAG_WIDTH, style) +} + +/// Pad text to width, then apply lipgloss style (so ANSI codes don't break alignment). +fn style_padded(text: &str, width: usize, style: lipgloss::Style) -> String { + let padded = format!("{: String { diff --git a/src/cli/commands/who.rs b/src/cli/commands/who.rs index ea526f3..35de8e1 100644 --- a/src/cli/commands/who.rs +++ b/src/cli/commands/who.rs @@ -1,4 +1,4 @@ -use crate::cli::render::{self, Theme}; +use crate::cli::render::{self, Icons, Theme}; use rusqlite::Connection; use serde::Serialize; use std::collections::{HashMap, HashSet}; @@ -1951,7 +1951,7 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) { }; println!( " {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}", - Theme::info().render(&format!("@{}", expert.username)), + Theme::info().render(&format!("{} {}", Icons::user(), expert.username)), expert.score, reviews, notes, @@ -2004,16 +2004,18 @@ fn print_workload_human(r: &WorkloadResult) { println!(); println!( "{}", - Theme::bold().render(&format!("@{} -- Workload Summary", r.username)) + Theme::bold().render(&format!( + "{} {} -- Workload Summary", + Icons::user(), + r.username + )) ); println!("{}", "\u{2500}".repeat(60)); if !r.assigned_issues.is_empty() { - println!(); println!( - " {} ({})", - Theme::bold().render("Assigned Issues"), - r.assigned_issues.len() + "{}", + render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len())) ); for item in &r.assigned_issues { println!( @@ -2032,11 +2034,9 @@ fn print_workload_human(r: &WorkloadResult) { } if !r.authored_mrs.is_empty() { - println!(); println!( - " {} ({})", - Theme::bold().render("Authored MRs"), - r.authored_mrs.len() + "{}", + render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len())) ); for mr in &r.authored_mrs { let draft = if mr.draft { " [draft]" } else { "" }; @@ -2057,11 +2057,9 @@ fn print_workload_human(r: &WorkloadResult) { } if !r.reviewing_mrs.is_empty() { - println!(); println!( - " {} ({})", - Theme::bold().render("Reviewing MRs"), - r.reviewing_mrs.len() + "{}", + render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len())) ); for mr in &r.reviewing_mrs { let author = mr @@ -2086,11 +2084,12 @@ fn print_workload_human(r: &WorkloadResult) { } if !r.unresolved_discussions.is_empty() { - println!(); println!( - " {} ({})", - Theme::bold().render("Unresolved Discussions"), - r.unresolved_discussions.len() + "{}", + render::section_divider(&format!( + "Unresolved Discussions ({})", + r.unresolved_discussions.len() + )) ); for disc in &r.unresolved_discussions { println!( @@ -2128,7 +2127,11 @@ fn print_reviews_human(r: &ReviewsResult) { println!(); println!( "{}", - Theme::bold().render(&format!("@{} -- Review Patterns", r.username)) + Theme::bold().render(&format!( + "{} {} -- Review Patterns", + Icons::user(), + r.username + )) ); println!("{}", "\u{2500}".repeat(60)); println!(); @@ -2289,7 +2292,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) { println!( " {:<16} {:<6} {:>7} {:<12} {}{}", - Theme::info().render(&format!("@{}", user.username)), + Theme::info().render(&format!("{} {}", Icons::user(), user.username)), format_overlap_role(user), user.touch_count, render::format_relative_time(user.last_seen_at),