feat(me): improve dashboard rendering with dynamic layout and table-based activity
Overhaul the `lore me` human-mode renderer for better terminal adaptation and visual clarity: Layout: - Add terminal_width() detection (COLUMNS env -> stderr ioctl -> 80 fallback) - Replace hardcoded column widths with dynamic title_width() that adapts to terminal size, clamped to [20, 80] - Section dividers now span the full terminal width Activity feed: - Replace manual println! formatting with Table-based rendering for proper column alignment across variable-width content - Split event_badge() into activity_badge_label() + activity_badge_style() for table cell compatibility - Add system_event_style() (#555555 dark gray) to visually suppress non-note events (label, assign, status, milestone, review changes) - Own actions use dim styling; others' notes render at full color MR display: - Add humanize_merge_status() to convert GitLab API values like "not_approved" -> "needs approval", "ci_must_pass" -> "CI pending" Table infrastructure (render.rs): - Add Table::columns() for headerless tables - Add Table::indent() for row-level indentation - Add truncate_pad() for fixed-width cell formatting - Table::render() now supports headerless mode (no separator line) Other: - Default activity lookback changed from 30d to 1d (more useful default) - Robot-docs schema added for `me` command - AGENTS.md and CLAUDE.md updated with `lore me` examples Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
use crate::cli::render::{self, GlyphMode, Icons, LoreRenderer, Theme};
|
||||
use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme};
|
||||
|
||||
use super::types::{
|
||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||
};
|
||||
|
||||
// ─── Layout Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// ─── Glyph Mode Helper ──────────────────────────────────────────────────────
|
||||
|
||||
/// Get the current glyph mode, defaulting to Unicode if renderer not initialized.
|
||||
@@ -61,28 +71,55 @@ fn styled_attention(state: &AttentionState) -> String {
|
||||
attention_style(state).render(icon)
|
||||
}
|
||||
|
||||
// ─── Merge Status Labels ────────────────────────────────────────────────────
|
||||
|
||||
/// Convert GitLab's `detailed_merge_status` API values to human-friendly labels.
|
||||
fn humanize_merge_status(status: &str) -> &str {
|
||||
match status {
|
||||
"not_approved" => "needs approval",
|
||||
"requested_changes" => "changes requested",
|
||||
"mergeable" => "ready to merge",
|
||||
"not_open" => "not open",
|
||||
"checking" => "checking",
|
||||
"ci_must_pass" => "CI pending",
|
||||
"ci_still_running" => "CI running",
|
||||
"discussions_not_resolved" => "unresolved threads",
|
||||
"draft_status" => "draft",
|
||||
"need_rebase" => "needs rebase",
|
||||
"conflict" | "has_conflicts" => "has conflicts",
|
||||
"blocked_status" => "blocked",
|
||||
"approvals_syncing" => "syncing approvals",
|
||||
"jira_association_missing" => "missing Jira link",
|
||||
"unchecked" => "unchecked",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event Badges ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Render an activity event badge (colored).
|
||||
fn event_badge(event_type: &ActivityEventType) -> String {
|
||||
let mode = glyph_mode();
|
||||
let (label, style) = match event_type {
|
||||
ActivityEventType::Note => ("note", Theme::info()),
|
||||
ActivityEventType::StatusChange => ("status", Theme::warning()),
|
||||
ActivityEventType::LabelChange => ("label", Theme::accent()),
|
||||
ActivityEventType::Assign | ActivityEventType::Unassign => ("assign", Theme::success()),
|
||||
ActivityEventType::ReviewRequest => ("assign", Theme::success()),
|
||||
ActivityEventType::MilestoneChange => ("milestone", accent_magenta()),
|
||||
};
|
||||
/// Return the badge label text for an activity event type.
|
||||
fn activity_badge_label(event_type: &ActivityEventType) -> String {
|
||||
match event_type {
|
||||
ActivityEventType::Note => "note",
|
||||
ActivityEventType::StatusChange => "status",
|
||||
ActivityEventType::LabelChange => "label",
|
||||
ActivityEventType::Assign | ActivityEventType::Unassign => "assign",
|
||||
ActivityEventType::ReviewRequest => "review",
|
||||
ActivityEventType::MilestoneChange => "milestone",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
match mode {
|
||||
GlyphMode::Ascii => style.render(&format!("[{label}]")),
|
||||
_ => {
|
||||
// For nerd/unicode, use colored bg with dark text where possible.
|
||||
// lipgloss background support is limited, so we use colored text as a
|
||||
// practical fallback that still provides the visual distinction.
|
||||
style.render(&format!(" {label} "))
|
||||
}
|
||||
/// Return the style for an activity event badge.
|
||||
fn activity_badge_style(event_type: &ActivityEventType) -> lipgloss::Style {
|
||||
match event_type {
|
||||
ActivityEventType::Note => Theme::info(),
|
||||
ActivityEventType::StatusChange => Theme::warning(),
|
||||
ActivityEventType::LabelChange => Theme::accent(),
|
||||
ActivityEventType::Assign
|
||||
| ActivityEventType::Unassign
|
||||
| ActivityEventType::ReviewRequest => Theme::success(),
|
||||
ActivityEventType::MilestoneChange => accent_magenta(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +132,15 @@ fn accent_magenta() -> lipgloss::Style {
|
||||
}
|
||||
}
|
||||
|
||||
/// Very dark gray for system events (label, assign, status, milestone, review).
|
||||
fn system_event_style() -> lipgloss::Style {
|
||||
if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) {
|
||||
lipgloss::Style::new().foreground("#555555")
|
||||
} else {
|
||||
lipgloss::Style::new().faint()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Summary Header ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Print the summary header with counts and attention legend (Task #14).
|
||||
@@ -108,7 +154,7 @@ pub fn print_summary_header(summary: &MeSummary, username: &str) {
|
||||
username,
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!("{}", "\u{2500}".repeat(render::terminal_width()));
|
||||
|
||||
// Counts line
|
||||
let needs = if summary.needs_attention_count > 0 {
|
||||
@@ -182,7 +228,7 @@ pub fn print_issues_section(issues: &[MeIssue], single_project: bool) {
|
||||
" {} {} {}{} {}",
|
||||
attn,
|
||||
Theme::issue_ref().render(&ref_str),
|
||||
render::truncate(&issue.title, 40),
|
||||
render::truncate(&issue.title, title_width(43)),
|
||||
Theme::dim().render(&status),
|
||||
Theme::dim().render(&time),
|
||||
);
|
||||
@@ -224,7 +270,7 @@ pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) {
|
||||
.detailed_merge_status
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty() && *s != "not_open")
|
||||
.map(|s| format!(" ({s})"))
|
||||
.map(|s| format!(" ({})", humanize_merge_status(s)))
|
||||
.unwrap_or_default();
|
||||
let time = render::format_relative_time(mr.updated_at);
|
||||
|
||||
@@ -233,7 +279,7 @@ pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) {
|
||||
" {} {} {}{}{} {}",
|
||||
attn,
|
||||
Theme::mr_ref().render(&ref_str),
|
||||
render::truncate(&mr.title, 35),
|
||||
render::truncate(&mr.title, title_width(48)),
|
||||
draft,
|
||||
Theme::dim().render(&merge_status),
|
||||
Theme::dim().render(&time),
|
||||
@@ -282,7 +328,7 @@ pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) {
|
||||
" {} {} {}{}{} {}",
|
||||
attn,
|
||||
Theme::mr_ref().render(&ref_str),
|
||||
render::truncate(&mr.title, 30),
|
||||
render::truncate(&mr.title, title_width(50)),
|
||||
author,
|
||||
draft,
|
||||
Theme::dim().render(&time),
|
||||
@@ -313,68 +359,119 @@ pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool)
|
||||
render::section_divider(&format!("Activity ({})", events.len()))
|
||||
);
|
||||
|
||||
for event in events {
|
||||
let badge = event_badge(&event.event_type);
|
||||
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
||||
let time = render::format_relative_time_compact(event.timestamp);
|
||||
// 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);
|
||||
|
||||
let actor_str = if event.is_own {
|
||||
Theme::dim().render(&format!(
|
||||
"{}(you)",
|
||||
event
|
||||
.actor
|
||||
.as_deref()
|
||||
.map(|a| format!("@{a} "))
|
||||
.unwrap_or_default()
|
||||
))
|
||||
for event in events {
|
||||
let badge_label = activity_badge_label(&event.event_type);
|
||||
let badge_style = activity_badge_style(&event.event_type);
|
||||
|
||||
let ref_text = match event.entity_type.as_str() {
|
||||
"issue" => format!("#{}", event.entity_iid),
|
||||
"mr" => format!("!{}", event.entity_iid),
|
||||
_ => format!("{}:{}", event.entity_type, event.entity_iid),
|
||||
};
|
||||
let is_system = !matches!(event.event_type, ActivityEventType::Note);
|
||||
// System events → very dark gray; own notes → standard dim; else → full color.
|
||||
let subdued = is_system || event.is_own;
|
||||
let subdued_style = || {
|
||||
if is_system {
|
||||
system_event_style()
|
||||
} else {
|
||||
Theme::dim()
|
||||
}
|
||||
};
|
||||
|
||||
let badge_style_final = if subdued {
|
||||
subdued_style()
|
||||
} else {
|
||||
badge_style
|
||||
};
|
||||
|
||||
let ref_style = if subdued {
|
||||
Some(subdued_style())
|
||||
} else {
|
||||
match event.entity_type.as_str() {
|
||||
"issue" => Some(Theme::issue_ref()),
|
||||
"mr" => Some(Theme::mr_ref()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let clean_summary = event.summary.replace('\n', " ");
|
||||
let summary_style: Option<lipgloss::Style> =
|
||||
if subdued { Some(subdued_style()) } else { None };
|
||||
|
||||
let actor_text = if event.is_own {
|
||||
event
|
||||
.actor
|
||||
.as_deref()
|
||||
.map_or("(you)".to_string(), |a| format!("@{a} (you)"))
|
||||
} else {
|
||||
event
|
||||
.actor
|
||||
.as_deref()
|
||||
.map(|a| Theme::username().render(&format!("@{a}")))
|
||||
.unwrap_or_default()
|
||||
.map_or(String::new(), |a| format!("@{a}"))
|
||||
};
|
||||
|
||||
let summary = render::truncate(&event.summary, 40);
|
||||
|
||||
// Dim own actions
|
||||
let summary_styled = if event.is_own {
|
||||
Theme::dim().render(&summary)
|
||||
let actor_style = if subdued {
|
||||
subdued_style()
|
||||
} else {
|
||||
summary
|
||||
Theme::username()
|
||||
};
|
||||
|
||||
// Line 1: badge, entity ref, summary, actor, time
|
||||
println!(
|
||||
" {badge} {entity_ref:7} {summary_styled} {actor_str} {}",
|
||||
Theme::dim().render(&time),
|
||||
);
|
||||
let time = render::format_relative_time_compact(event.timestamp);
|
||||
|
||||
// Line 2: project path (if multi-project) + body preview for notes
|
||||
table.add_row(vec![
|
||||
StyledCell::styled(badge_label, badge_style_final),
|
||||
match ref_style {
|
||||
Some(s) => StyledCell::styled(ref_text, s),
|
||||
None => StyledCell::plain(ref_text),
|
||||
},
|
||||
match summary_style {
|
||||
Some(s) => StyledCell::styled(clean_summary, s),
|
||||
None => StyledCell::plain(clean_summary),
|
||||
},
|
||||
StyledCell::styled(actor_text, actor_style),
|
||||
StyledCell::styled(time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
// Render table rows and interleave per-event detail lines
|
||||
let rendered = table.render();
|
||||
for (line, event) in rendered.lines().zip(events.iter()) {
|
||||
println!("{line}");
|
||||
if !single_project {
|
||||
println!(" {}", Theme::dim().render(&event.project_path),);
|
||||
println!(" {}", Theme::dim().render(&event.project_path));
|
||||
}
|
||||
if let Some(preview) = &event.body_preview
|
||||
&& !preview.is_empty()
|
||||
{
|
||||
let truncated = render::truncate(preview, 60);
|
||||
println!(" {}", Theme::dim().render(&format!("\"{truncated}\"")),);
|
||||
println!(" {}", Theme::dim().render(&format!("\"{truncated}\"")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an entity reference (#N for issues, !N for MRs).
|
||||
/// Format an entity reference (#N for issues, !N for MRs), right-aligned to 6 chars.
|
||||
#[cfg(test)]
|
||||
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||
match entity_type {
|
||||
"issue" => {
|
||||
let s = format!("#{iid}");
|
||||
let s = format!("{:>6}", format!("#{iid}"));
|
||||
Theme::issue_ref().render(&s)
|
||||
}
|
||||
"mr" => {
|
||||
let s = format!("!{iid}");
|
||||
let s = format!("{:>6}", format!("!{iid}"));
|
||||
Theme::mr_ref().render(&s)
|
||||
}
|
||||
_ => format!("{entity_type}:{iid}"),
|
||||
_ => format!("{:>6}", format!("{entity_type}:{iid}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +543,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_badge_returns_nonempty_for_all_types() {
|
||||
fn activity_badge_label_returns_nonempty_for_all_types() {
|
||||
let types = [
|
||||
ActivityEventType::Note,
|
||||
ActivityEventType::StatusChange,
|
||||
@@ -457,7 +554,7 @@ mod tests {
|
||||
ActivityEventType::MilestoneChange,
|
||||
];
|
||||
for t in &types {
|
||||
assert!(!event_badge(t).is_empty(), "empty for {t:?}");
|
||||
assert!(!activity_badge_label(t).is_empty(), "empty for {t:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user