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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user