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:
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user