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>
This commit is contained in:
teernisse
2026-02-25 10:02:13 -05:00
parent eac640225f
commit ce5621f3ed
8 changed files with 714 additions and 13 deletions

View File

@@ -2,6 +2,7 @@ use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
SinceLastCheck,
};
// ─── Layout Helpers ─────────────────────────────────────────────────────────
@@ -475,10 +476,113 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
}
}
// ─── 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);
@@ -495,6 +599,9 @@ pub fn print_me_dashboard_filtered(
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 {