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:
teernisse
2026-02-21 09:20:25 -05:00
parent 7e9a23cc0f
commit 6e487532aa
7 changed files with 344 additions and 99 deletions

View File

@@ -28,7 +28,15 @@ fn insert_project(conn: &Connection, id: i64, path: &str) {
}
fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
insert_issue_with_state(conn, id, project_id, iid, author, "opened");
insert_issue_with_status(
conn,
id,
project_id,
iid,
author,
"opened",
Some("In Progress"),
);
}
fn insert_issue_with_state(
@@ -38,11 +46,30 @@ fn insert_issue_with_state(
iid: i64,
author: &str,
state: &str,
) {
// For closed issues, don't set status_name (they won't appear in dashboard anyway)
let status_name = if state == "opened" {
Some("In Progress")
} else {
None
};
insert_issue_with_status(conn, id, project_id, iid, author, state, status_name);
}
#[allow(clippy::too_many_arguments)]
fn insert_issue_with_status(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
status_name: Option<&str>,
) {
let ts = now_ms();
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, status_name, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
id,
id * 10,
@@ -50,6 +77,7 @@ fn insert_issue_with_state(
iid,
format!("Issue {iid}"),
state,
status_name,
author,
ts,
ts,
@@ -552,7 +580,9 @@ fn activity_since_filter() {
let since = now_ms() - 50_000;
let results = query_activity(&conn, "alice", &[], since).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].body_preview, Some("new comment".to_string()));
// Notes no longer duplicate body into body_preview (summary carries the content)
assert_eq!(results[0].body_preview, None);
assert_eq!(results[0].summary, "new comment");
}
#[test]

View File

@@ -18,8 +18,8 @@ use crate::core::time::parse_since;
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs};
use self::types::{AttentionState, MeDashboard, MeSummary};
/// Default activity lookback: 30 days in milliseconds (AC-2.3).
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 30;
/// Default activity lookback: 1 day in milliseconds.
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
/// Resolve the effective username from CLI flag or config.
@@ -96,7 +96,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let project_ids = resolve_project_scope(&conn, args, config)?;
let single_project = project_ids.len() == 1;
// 5. Parse --since (default 30d for activity feed, AC-2.3)
// 5. Parse --since (default 1d for activity feed)
let since_ms = match args.since.as_deref() {
Some(raw) => parse_since(raw).ok_or_else(|| {
LoreError::Other(format!(

View File

@@ -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:?}");
}
}
}

View File

@@ -532,6 +532,43 @@ pub fn format_datetime(ms: i64) -> String {
.unwrap_or_else(|| "unknown".to_string())
}
/// Detect terminal width. Checks `COLUMNS` env, then stderr ioctl, falls back to 80.
pub fn terminal_width() -> usize {
// 1. Explicit COLUMNS env (set by some shells, resized terminals)
if let Ok(val) = std::env::var("COLUMNS")
&& let Ok(w) = val.parse::<usize>()
&& w > 0
{
return w;
}
// 2. ioctl on stderr (works even when stdout is piped)
#[cfg(unix)]
{
use std::mem::MaybeUninit;
#[allow(non_camel_case_types)]
#[repr(C)]
struct winsize {
ws_row: libc::c_ushort,
ws_col: libc::c_ushort,
ws_xpixel: libc::c_ushort,
ws_ypixel: libc::c_ushort,
}
let mut ws = MaybeUninit::<winsize>::uninit();
// SAFETY: ioctl with TIOCGWINSZ writes into the winsize struct.
// stderr (fd 2) is used because stdout may be piped.
if unsafe { libc::ioctl(2, libc::TIOCGWINSZ, ws.as_mut_ptr()) } == 0 {
let ws = unsafe { ws.assume_init() };
let w = ws.ws_col as usize;
if w > 0 {
return w;
}
}
}
80
}
/// Truncate a string to `max` characters, appending "..." if truncated.
pub fn truncate(s: &str, max: usize) -> String {
if max < 4 {
@@ -545,6 +582,17 @@ pub fn truncate(s: &str, max: usize) -> String {
}
}
/// Truncate and right-pad to exactly `width` visible characters.
pub fn truncate_pad(s: &str, width: usize) -> String {
let t = truncate(s, width);
let count = t.chars().count();
if count < width {
format!("{t}{}", " ".repeat(width - count))
} else {
t
}
}
/// Word-wrap text to `width`, prepending `indent` to continuation lines.
/// Returns a single string with embedded newlines.
pub fn wrap_indent(text: &str, width: usize, indent: &str) -> String {
@@ -603,7 +651,10 @@ pub fn wrap_lines(text: &str, width: usize) -> Vec<String> {
/// Render a section divider: `── Title ──────────────────────`
pub fn section_divider(title: &str) -> String {
let rule_len = 40_usize.saturating_sub(title.len() + 4);
// prefix: 2 indent + 2 box-drawing + 1 space = 5
// suffix: 1 space + trailing box-drawing
let used = 5 + title.len() + 1;
let rule_len = terminal_width().saturating_sub(used);
format!(
"\n {} {} {}",
Theme::dim().render("\u{2500}\u{2500}"),
@@ -734,6 +785,8 @@ pub struct Table {
rows: Vec<Vec<StyledCell>>,
alignments: Vec<Align>,
max_widths: Vec<Option<usize>>,
col_count: usize,
indent: usize,
}
impl Table {
@@ -744,9 +797,23 @@ impl Table {
/// Set column headers.
pub fn headers(mut self, h: &[&str]) -> Self {
self.headers = h.iter().map(|s| (*s).to_string()).collect();
// Initialize alignments and max_widths to match column count
self.alignments.resize(self.headers.len(), Align::Left);
self.max_widths.resize(self.headers.len(), None);
self.col_count = self.headers.len();
self.alignments.resize(self.col_count, Align::Left);
self.max_widths.resize(self.col_count, None);
self
}
/// Set column count without headers (headerless table).
pub fn columns(mut self, n: usize) -> Self {
self.col_count = n;
self.alignments.resize(n, Align::Left);
self.max_widths.resize(n, None);
self
}
/// Set indent (number of spaces) prepended to each row.
pub fn indent(mut self, spaces: usize) -> Self {
self.indent = spaces;
self
}
@@ -773,15 +840,20 @@ impl Table {
/// Render the table to a string.
pub fn render(&self) -> String {
if self.headers.is_empty() {
let col_count = self.col_count;
if col_count == 0 {
return String::new();
}
let col_count = self.headers.len();
let gap = " "; // 2-space gap between columns
let indent_str = " ".repeat(self.indent);
// Compute column widths from content
let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
// Compute column widths from headers (if any) and all row cells
let mut widths: Vec<usize> = if self.headers.is_empty() {
vec![0; col_count]
} else {
self.headers.iter().map(|h| h.chars().count()).collect()
};
for row in &self.rows {
for (i, cell) in row.iter().enumerate() {
@@ -802,29 +874,32 @@ impl Table {
let mut out = String::new();
// Header row (bold)
let header_parts: Vec<String> = self
.headers
.iter()
.enumerate()
.map(|(i, h)| {
let w = widths.get(i).copied().unwrap_or(0);
let text = truncate(h, w);
pad_cell(
&text,
w,
self.alignments.get(i).copied().unwrap_or(Align::Left),
)
})
.collect();
out.push_str(&Theme::header().render(&header_parts.join(gap)));
out.push('\n');
// Header row + separator (only when headers are set)
if !self.headers.is_empty() {
let header_parts: Vec<String> = self
.headers
.iter()
.enumerate()
.map(|(i, h)| {
let w = widths.get(i).copied().unwrap_or(0);
let text = truncate(h, w);
pad_cell(
&text,
w,
self.alignments.get(i).copied().unwrap_or(Align::Left),
)
})
.collect();
out.push_str(&indent_str);
out.push_str(&Theme::header().render(&header_parts.join(gap)));
out.push('\n');
// Separator
let total_width: usize =
widths.iter().sum::<usize>() + gap.len() * col_count.saturating_sub(1);
out.push_str(&Theme::dim().render(&"\u{2500}".repeat(total_width)));
out.push('\n');
let total_width: usize =
widths.iter().sum::<usize>() + gap.len() * col_count.saturating_sub(1);
out.push_str(&indent_str);
out.push_str(&Theme::dim().render(&"\u{2500}".repeat(total_width)));
out.push('\n');
}
// Data rows
for row in &self.rows {
@@ -856,6 +931,7 @@ impl Table {
parts.push(" ".repeat(w));
}
}
out.push_str(&indent_str);
out.push_str(&parts.join(gap));
out.push('\n');
}

View File

@@ -2955,6 +2955,35 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"meta": {"elapsed_ms": "int"}
}
},
"me": {
"description": "Personal work dashboard: open issues, authored/reviewing MRs, activity feed with computed attention states",
"flags": ["--issues", "--mrs", "--activity", "--since <period>", "-p/--project <path>", "--all", "--user <username>", "--fields <list|minimal>"],
"example": "lore --robot me",
"response_schema": {
"ok": "bool",
"data": {
"username": "string",
"since_iso": "string?",
"summary": {"project_count": "int", "open_issue_count": "int", "authored_mr_count": "int", "reviewing_mr_count": "int", "needs_attention_count": "int"},
"open_issues": "[{project:string, iid:int, title:string, state:string, attention_state:string, status_name:string?, labels:[string], updated_at_iso:string, web_url:string?}]",
"open_mrs_authored": "[{project:string, iid:int, title:string, state:string, attention_state:string, draft:bool, detailed_merge_status:string?, author_username:string?, labels:[string], updated_at_iso:string, web_url:string?}]",
"reviewing_mrs": "[same as open_mrs_authored]",
"activity": "[{timestamp_iso:string, event_type:string, entity_type:string, entity_iid:int, project:string, actor:string?, is_own:bool, summary:string, body_preview:string?}]"
},
"meta": {"elapsed_ms": "int"}
},
"fields_presets": {
"me_items_minimal": ["iid", "title", "attention_state", "updated_at_iso"],
"me_activity_minimal": ["timestamp_iso", "event_type", "entity_iid", "actor"]
},
"notes": {
"attention_states": "needs_attention | not_started | awaiting_response | stale | not_ready",
"event_types": "note | status_change | label_change | assign | unassign | review_request | milestone_change",
"section_flags": "If none of --issues/--mrs/--activity specified, all sections returned",
"since_default": "1d for activity feed",
"issue_filter": "Only In Progress / In Review status issues shown"
}
},
"robot-docs": {
"description": "This command (agent self-discovery manifest)",
"flags": ["--brief"],
@@ -2983,7 +3012,8 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"count: Entity counts with state breakdowns",
"embed: Generate vector embeddings for semantic search via Ollama",
"cron: Automated sync scheduling (Unix)",
"token: Secure token management with masked display"
"token: Secure token management with masked display",
"me: Personal work dashboard with attention states, activity feed, and needs-attention triage"
],
"read_write_split": "lore = ALL reads (issues, MRs, search, who, timeline, intelligence). glab = ALL writes (create, update, approve, merge, CI/CD)."
});