Files
gitlore/src/cli/commands/me/render_human.rs
teernisse ce5621f3ed feat(me): add "since last check" cursor-based inbox to dashboard
Implements a cursor-based notification inbox that surfaces actionable
events from others since the user's last `lore me` invocation. This
addresses the core UX need: "what happened while I was away?"

Event Sources (three-way UNION query):
1. Others' comments on user's open issues/MRs
2. @mentions on ANY item (not restricted to owned items)
3. Assignment/review-request system notes mentioning user

Mention Detection:
- SQL LIKE pre-filter for performance, then regex validation
- Word-boundary-aware: rejects "alice" in "@alice-bot" or "alice@corp.com"
- Domain rejection: "@alice.com" not matched (prevents email false positives)
- Punctuation tolerance: "@alice," "@alice." "(@ alice)" all match

Cursor Watermark Pattern:
- Global watermark computed from ALL projects before --project filtering
- Ensures --project display filter doesn't permanently skip events
- Cursor advances only after successful render (no data loss on errors)
- First run establishes baseline (no inbox shown), subsequent runs show delta

Output:
- Human: color-coded event badges, grouped by entity, actor + timestamp
- Robot: standard envelope with since_last_check object containing
  cursor_iso, total_event_count, and groups array with nested events

CLI additions:
- --reset-cursor flag: clears cursor (next run shows no new events)
- Autocorrect: --reset-cursor added to known me command flags

Tests cover:
- Mention with trailing comma/period/parentheses (should match)
- Email-like text "@alice.com" (should NOT match)  
- Domain-like text "@alice.example" (should NOT match)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 10:02:31 -05:00

668 lines
23 KiB
Rust

use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme};
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
SinceLastCheck,
};
// ─── 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.
fn glyph_mode() -> GlyphMode {
LoreRenderer::try_get().map_or(GlyphMode::Unicode, LoreRenderer::glyph_mode)
}
// ─── Attention Icons ─────────────────────────────────────────────────────────
/// Return the attention icon for the current glyph mode.
fn attention_icon(state: &AttentionState) -> &'static str {
let mode = glyph_mode();
match state {
AttentionState::NeedsAttention => match mode {
GlyphMode::Nerd => "\u{f0f3}", // bell
GlyphMode::Unicode => "\u{25c6}", // diamond
GlyphMode::Ascii => "[!]",
},
AttentionState::NotStarted => match mode {
GlyphMode::Nerd => "\u{f005}", // star
GlyphMode::Unicode => "\u{2605}", // black star
GlyphMode::Ascii => "[*]",
},
AttentionState::AwaitingResponse => match mode {
GlyphMode::Nerd => "\u{f017}", // clock
GlyphMode::Unicode => "\u{25f7}", // white circle with upper right quadrant
GlyphMode::Ascii => "[~]",
},
AttentionState::Stale => match mode {
GlyphMode::Nerd => "\u{f54c}", // skull
GlyphMode::Unicode => "\u{2620}", // skull and crossbones
GlyphMode::Ascii => "[x]",
},
AttentionState::NotReady => match mode {
GlyphMode::Nerd => "\u{f040}", // pencil
GlyphMode::Unicode => "\u{270e}", // lower right pencil
GlyphMode::Ascii => "[D]",
},
}
}
/// Style for an attention state.
fn attention_style(state: &AttentionState) -> lipgloss::Style {
match state {
AttentionState::NeedsAttention => Theme::warning(),
AttentionState::NotStarted => Theme::info(),
AttentionState::AwaitingResponse | AttentionState::Stale => Theme::dim(),
AttentionState::NotReady => Theme::state_draft(),
}
}
/// Render the styled attention icon for an item.
fn styled_attention(state: &AttentionState) -> String {
let icon = attention_icon(state);
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 ────────────────────────────────────────────────────────────
/// 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()
}
/// 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(),
}
}
/// Magenta accent for milestone badges.
fn accent_magenta() -> lipgloss::Style {
if LoreRenderer::try_get().is_some_and(LoreRenderer::colors_enabled) {
lipgloss::Style::new().foreground("#d946ef")
} else {
lipgloss::Style::new()
}
}
/// 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).
pub fn print_summary_header(summary: &MeSummary, username: &str) {
println!();
println!(
"{}",
Theme::bold().render(&format!(
"{} {} -- Personal Dashboard",
Icons::user(),
username,
))
);
println!("{}", "\u{2500}".repeat(render::terminal_width()));
// Counts line
let needs = if summary.needs_attention_count > 0 {
Theme::warning().render(&format!("{} need attention", summary.needs_attention_count))
} else {
Theme::dim().render("0 need attention")
};
println!(
" {} projects {} issues {} authored MRs {} reviewing MRs {}",
summary.project_count,
summary.open_issue_count,
summary.authored_mr_count,
summary.reviewing_mr_count,
needs,
);
// Attention legend
print_attention_legend();
}
/// Print the attention icon legend.
fn print_attention_legend() {
println!();
let states = [
(AttentionState::NeedsAttention, "needs attention"),
(AttentionState::NotStarted, "not started"),
(AttentionState::AwaitingResponse, "awaiting response"),
(AttentionState::Stale, "stale (30d+)"),
(AttentionState::NotReady, "draft (not ready)"),
];
let legend: Vec<String> = states
.iter()
.map(|(state, label)| format!("{} {}", styled_attention(state), Theme::dim().render(label)))
.collect();
println!(" {}", legend.join(" "));
}
// ─── Open Issues Section ─────────────────────────────────────────────────────
/// Print the open issues section (Task #15).
pub fn print_issues_section(issues: &[MeIssue], single_project: bool) {
if issues.is_empty() {
println!("{}", render::section_divider("Open Issues (0)"));
println!(
" {}",
Theme::dim().render("No open issues assigned to you.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Open Issues ({})", issues.len()))
);
for issue in issues {
let attn = styled_attention(&issue.attention_state);
let ref_str = format!("#{}", issue.iid);
let status = issue
.status_name
.as_deref()
.map(|s| format!(" [{s}]"))
.unwrap_or_default();
let time = render::format_relative_time(issue.updated_at);
// Line 1: attention icon, issue ref, title, status, relative time
println!(
" {} {} {}{} {}",
attn,
Theme::issue_ref().render(&ref_str),
render::truncate(&issue.title, title_width(43)),
Theme::dim().render(&status),
Theme::dim().render(&time),
);
// Line 2: project path (suppressed in single-project mode)
if !single_project {
println!(" {}", Theme::dim().render(&issue.project_path),);
}
}
}
// ─── MR Sections ─────────────────────────────────────────────────────────────
/// Print the authored MRs section (Task #16).
pub fn print_authored_mrs_section(mrs: &[MeMr], single_project: bool) {
if mrs.is_empty() {
println!("{}", render::section_divider("Authored MRs (0)"));
println!(
" {}",
Theme::dim().render("No open MRs authored by you.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Authored MRs ({})", mrs.len()))
);
for mr in mrs {
let attn = styled_attention(&mr.attention_state);
let ref_str = format!("!{}", mr.iid);
let draft = if mr.draft {
Theme::state_draft().render(" [draft]")
} else {
String::new()
};
let merge_status = mr
.detailed_merge_status
.as_deref()
.filter(|s| !s.is_empty() && *s != "not_open")
.map(|s| format!(" ({})", humanize_merge_status(s)))
.unwrap_or_default();
let time = render::format_relative_time(mr.updated_at);
// Line 1: attention, MR ref, title, draft, merge status, time
println!(
" {} {} {}{}{} {}",
attn,
Theme::mr_ref().render(&ref_str),
render::truncate(&mr.title, title_width(48)),
draft,
Theme::dim().render(&merge_status),
Theme::dim().render(&time),
);
// Line 2: project path
if !single_project {
println!(" {}", Theme::dim().render(&mr.project_path),);
}
}
}
/// Print the reviewing MRs section (Task #16).
pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) {
if mrs.is_empty() {
println!("{}", render::section_divider("Reviewing MRs (0)"));
println!(
" {}",
Theme::dim().render("No open MRs awaiting your review.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Reviewing MRs ({})", mrs.len()))
);
for mr in mrs {
let attn = styled_attention(&mr.attention_state);
let ref_str = format!("!{}", mr.iid);
let author = mr
.author_username
.as_deref()
.map(|a| format!(" by {}", Theme::username().render(&format!("@{a}"))))
.unwrap_or_default();
let draft = if mr.draft {
Theme::state_draft().render(" [draft]")
} else {
String::new()
};
let time = render::format_relative_time(mr.updated_at);
// Line 1: attention, MR ref, title, author, draft, time
println!(
" {} {} {}{}{} {}",
attn,
Theme::mr_ref().render(&ref_str),
render::truncate(&mr.title, title_width(50)),
author,
draft,
Theme::dim().render(&time),
);
// Line 2: project path
if !single_project {
println!(" {}", Theme::dim().render(&mr.project_path),);
}
}
}
// ─── Activity Feed ───────────────────────────────────────────────────────────
/// Print the activity feed section (Task #17).
pub fn print_activity_section(events: &[MeActivityEvent], single_project: bool) {
if events.is_empty() {
println!("{}", render::section_divider("Activity (0)"));
println!(
" {}",
Theme::dim().render("No recent activity on your items.")
);
return;
}
println!(
"{}",
render::section_divider(&format!("Activity ({})", events.len()))
);
// 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);
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_or(String::new(), |a| format!("@{a}"))
};
let actor_style = if subdued {
subdued_style()
} else {
Theme::username()
};
let time = render::format_relative_time_compact(event.timestamp);
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));
}
if let Some(preview) = &event.body_preview
&& !preview.is_empty()
{
let truncated = render::truncate(preview, 60);
println!(" {}", Theme::dim().render(&format!("\"{truncated}\"")));
}
}
}
/// 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!("{:>6}", format!("#{iid}"));
Theme::issue_ref().render(&s)
}
"mr" => {
let s = format!("{:>6}", format!("!{iid}"));
Theme::mr_ref().render(&s)
}
_ => format!("{:>6}", format!("{entity_type}:{iid}")),
}
}
// ─── Since Last Check ────────────────────────────────────────────────────────
/// Print the "since last check" section at the top of the dashboard.
pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bool) {
let relative = render::format_relative_time(since.cursor_ms);
if since.groups.is_empty() {
println!(
"\n {}",
Theme::dim().render(&format!(
"No new events since {} ({relative})",
render::format_datetime(since.cursor_ms),
))
);
return;
}
println!(
"{}",
render::section_divider(&format!("Since Last Check ({relative})"))
);
for group in &since.groups {
// Entity header: !247 Fix race condition...
let ref_str = match group.entity_type.as_str() {
"issue" => format!("#{}", group.entity_iid),
"mr" => format!("!{}", group.entity_iid),
_ => format!("{}:{}", group.entity_type, group.entity_iid),
};
let ref_style = match group.entity_type.as_str() {
"issue" => Theme::issue_ref(),
"mr" => Theme::mr_ref(),
_ => Theme::bold(),
};
println!();
println!(
" {} {}",
ref_style.render(&ref_str),
Theme::bold().render(&render::truncate(&group.entity_title, title_width(20))),
);
if !single_project {
println!(" {}", Theme::dim().render(&group.project_path));
}
// 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);
for event in &group.events {
let badge = activity_badge_label(&event.event_type);
let badge_style = activity_badge_style(&event.event_type);
let actor_prefix = event
.actor
.as_deref()
.map(|a| format!("@{a} "))
.unwrap_or_default();
let clean_summary = event.summary.replace('\n', " ");
let summary_text = format!("{actor_prefix}{clean_summary}");
let time = render::format_relative_time_compact(event.timestamp);
table.add_row(vec![
StyledCell::styled(badge, badge_style),
StyledCell::plain(summary_text),
StyledCell::styled(time, Theme::dim()),
]);
}
let rendered = table.render();
for (line, event) in rendered.lines().zip(group.events.iter()) {
println!("{line}");
if let Some(preview) = &event.body_preview
&& !preview.is_empty()
{
let truncated = render::truncate(preview, 60);
println!(
" {}",
Theme::dim().render(&format!("\"{truncated}\""))
);
}
}
}
// Footer
println!(
"\n {}",
Theme::dim().render(&format!(
"{} events across {} items",
since.total_event_count,
since.groups.len()
))
);
}
// ─── Full Dashboard ──────────────────────────────────────────────────────────
/// Render the complete human-mode dashboard.
pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
if let Some(ref since) = dashboard.since_last_check {
print_since_last_check_section(since, single_project);
}
print_summary_header(&dashboard.summary, &dashboard.username);
print_issues_section(&dashboard.open_issues, single_project);
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
print_activity_section(&dashboard.activity, single_project);
println!();
}
/// Render a filtered dashboard (only requested sections).
pub fn print_me_dashboard_filtered(
dashboard: &MeDashboard,
single_project: bool,
show_issues: bool,
show_mrs: bool,
show_activity: bool,
) {
if let Some(ref since) = dashboard.since_last_check {
print_since_last_check_section(since, single_project);
}
print_summary_header(&dashboard.summary, &dashboard.username);
if show_issues {
print_issues_section(&dashboard.open_issues, single_project);
}
if show_mrs {
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
}
if show_activity {
print_activity_section(&dashboard.activity, single_project);
}
println!();
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attention_icon_returns_nonempty_for_all_states() {
let states = [
AttentionState::NeedsAttention,
AttentionState::NotStarted,
AttentionState::AwaitingResponse,
AttentionState::Stale,
AttentionState::NotReady,
];
for state in &states {
assert!(!attention_icon(state).is_empty(), "empty for {state:?}");
}
}
#[test]
fn format_entity_ref_issue() {
let result = format_entity_ref("issue", 42);
assert!(result.contains("42"), "got: {result}");
}
#[test]
fn format_entity_ref_mr() {
let result = format_entity_ref("mr", 99);
assert!(result.contains("99"), "got: {result}");
}
#[test]
fn activity_badge_label_returns_nonempty_for_all_types() {
let types = [
ActivityEventType::Note,
ActivityEventType::StatusChange,
ActivityEventType::LabelChange,
ActivityEventType::Assign,
ActivityEventType::Unassign,
ActivityEventType::ReviewRequest,
ActivityEventType::MilestoneChange,
];
for t in &types {
assert!(!activity_badge_label(t).is_empty(), "empty for {t:?}");
}
}
}