From 6e487532aa84730c5ff01325329c78a5fa450ed9 Mon Sep 17 00:00:00 2001 From: teernisse Date: Sat, 21 Feb 2026 09:20:25 -0500 Subject: [PATCH] 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 --- AGENTS.md | 6 + CLAUDE.md | 6 + src/cli/commands/me/me_tests.rs | 38 ++++- src/cli/commands/me/mod.rs | 6 +- src/cli/commands/me/render_human.rs | 219 ++++++++++++++++++++-------- src/cli/render.rs | 136 +++++++++++++---- src/main.rs | 32 +++- 7 files changed, 344 insertions(+), 99 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b42a6e5..9cf9830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -623,6 +623,12 @@ lore --robot generate-docs # Generate vector embeddings via Ollama lore --robot embed +# Personal work dashboard +lore --robot me +lore --robot me --issues +lore --robot me --activity --since 7d +lore --robot me --fields minimal + # Agent self-discovery manifest (all commands, flags, exit codes, response schemas) lore robot-docs diff --git a/CLAUDE.md b/CLAUDE.md index 712c6cc..e29edfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -642,6 +642,12 @@ lore --robot generate-docs # Generate vector embeddings via Ollama lore --robot embed +# Personal work dashboard +lore --robot me +lore --robot me --issues +lore --robot me --activity --since 7d +lore --robot me --fields minimal + # Agent self-discovery manifest (all commands, flags, exit codes, response schemas) lore robot-docs diff --git a/src/cli/commands/me/me_tests.rs b/src/cli/commands/me/me_tests.rs index afcf255..b37ef70 100644 --- a/src/cli/commands/me/me_tests.rs +++ b/src/cli/commands/me/me_tests.rs @@ -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] diff --git a/src/cli/commands/me/mod.rs b/src/cli/commands/me/mod.rs index a0328a0..1b4c32e 100644 --- a/src/cli/commands/me/mod.rs +++ b/src/cli/commands/me/mod.rs @@ -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!( diff --git a/src/cli/commands/me/render_human.rs b/src/cli/commands/me/render_human.rs index c75634d..e4517e1 100644 --- a/src/cli/commands/me/render_human.rs +++ b/src/cli/commands/me/render_human.rs @@ -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 = + 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:?}"); } } } diff --git a/src/cli/render.rs b/src/cli/render.rs index a0ae400..7dfdcb1 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -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::() + && 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::::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 { /// 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>, alignments: Vec, max_widths: Vec>, + 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 = self.headers.iter().map(|h| h.chars().count()).collect(); + // Compute column widths from headers (if any) and all row cells + let mut widths: Vec = 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 = 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 = 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::() + 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::() + 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'); } diff --git a/src/main.rs b/src/main.rs index cea5583..fe60a97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2955,6 +2955,35 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box", "-p/--project ", "--all", "--user ", "--fields "], + "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