feat(ux): visual improvements for list tree, graph, and show commands

bd list --tree:
- Use actual parent-child dependencies instead of dotted ID hierarchy
- Treat epic dependencies as parent-child relationships
- Sort children by priority (P0 first)
- Fix tree display in daemon mode with read-only store access

bd graph:
- Add --all flag to show dependency graph of all open issues
- Add --compact flag for tree-style rendering (reduces 44+ lines to 13)
- Fix "needs:N" cognitive noise by using semantic colors
- Add blocks:N indicator with semantic red coloring

bd show:
- Tufte-aligned header with status icon, priority, and type badges
- Add glamour markdown rendering with auto light/dark mode detection
- Cap markdown line width at 100 chars for readability
- Mute entire row for closed dependencies (work done, no attention needed)

Design system:
- Add shared status icons (○ ◐ ● ✓ ❄) with semantic colors
- Implement priority colors: P0 red, P1 orange, P2 muted gold, P3-P4 neutral
- Add TrueColor profile for distinct hex color rendering
- Type badges for epic (purple) and bug (red)

Design principles:
- Semantic colors only for actionable items
- Closed items fade (muted gray)
- Icons > text labels for better scanability

Co-Authored-By: SageOx <ox@sageox.ai>
This commit is contained in:
Ryan Snodgrass
2026-01-08 20:49:09 -08:00
parent 7e70de1f6d
commit cfd1f39e1e
11 changed files with 1064 additions and 319 deletions

54
internal/ui/markdown.go Normal file
View File

@@ -0,0 +1,54 @@
// Package ui provides terminal styling for beads CLI output.
package ui
import (
"os"
"github.com/charmbracelet/glamour"
"golang.org/x/term"
)
// RenderMarkdown renders markdown text using glamour with beads theme colors.
// Returns the rendered markdown or the original text if rendering fails.
// Word wraps at terminal width (or 80 columns if width can't be detected).
func RenderMarkdown(markdown string) string {
// Skip glamour in agent mode to keep output clean for parsing
if IsAgentMode() {
return markdown
}
// Skip glamour if colors are disabled
if !ShouldUseColor() {
return markdown
}
// Detect terminal width for word wrap
// Cap at 100 chars for readability - wider lines cause eye-tracking fatigue
// Typography research suggests 50-75 chars optimal, 80-100 comfortable max
const maxReadableWidth = 100
wrapWidth := 80 // default if terminal size unavailable
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
wrapWidth = w
}
if wrapWidth > maxReadableWidth {
wrapWidth = maxReadableWidth
}
// Create renderer with auto-detected style (respects terminal light/dark mode)
renderer, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(wrapWidth),
)
if err != nil {
// fallback to raw markdown on error
return markdown
}
rendered, err := renderer.Render(markdown)
if err != nil {
// fallback to raw markdown on error
return markdown
}
return rendered
}

View File

@@ -12,9 +12,12 @@ import (
)
func init() {
// Disable colors when not appropriate (non-TTY, NO_COLOR, etc.)
if !ShouldUseColor() {
// Disable colors when not appropriate (non-TTY, NO_COLOR, etc.)
lipgloss.SetColorProfile(termenv.Ascii)
} else {
// Use TrueColor for distinct priority/status colors in modern terminals
lipgloss.SetColorProfile(termenv.TrueColor)
}
}
@@ -90,25 +93,26 @@ var (
}
// === Priority Colors ===
// Only P0/P1 get color - P2/P3/P4 match standard text
// Only P0/P1 get color - they need attention
// P2/P3/P4 are neutral (medium/low/backlog don't need visual urgency)
ColorPriorityP0 = lipgloss.AdaptiveColor{
Light: "#f07171", // bright red - critical
Light: "#f07171", // bright red - critical, demands attention
Dark: "#f07178",
}
ColorPriorityP1 = lipgloss.AdaptiveColor{
Light: "#ff8f40", // orange - high urgency
Light: "#ff8f40", // orange - high priority, needs attention soon
Dark: "#ff8f40",
}
ColorPriorityP2 = lipgloss.AdaptiveColor{
Light: "", // standard text color
Dark: "",
Light: "#e6b450", // muted gold - medium priority, visible but calm
Dark: "#e6b450",
}
ColorPriorityP3 = lipgloss.AdaptiveColor{
Light: "", // standard text color
Light: "", // neutral - low priority
Dark: "",
}
ColorPriorityP4 = lipgloss.AdaptiveColor{
Light: "", // standard text color
Light: "", // neutral - backlog
Dark: "",
}
@@ -199,6 +203,87 @@ const (
IconInfo = ""
)
// Issue status icons - used consistently across all commands
// Design principle: icons > text labels for scannability
// IMPORTANT: Use small Unicode symbols, NOT emoji-style icons (🔴🟠 etc.)
// Emoji blobs cause cognitive overload and break visual consistency
const (
StatusIconOpen = "○" // available to work (hollow circle)
StatusIconInProgress = "◐" // active work (half-filled)
StatusIconBlocked = "●" // needs attention (filled circle)
StatusIconClosed = "✓" // completed (checkmark)
StatusIconDeferred = "❄" // scheduled for later (snowflake)
StatusIconPinned = "📌" // elevated priority
)
// Priority icon - small filled circle, colored by priority level
// IMPORTANT: Use this small circle, NOT emoji blobs (🔴🟠🟡🔵⚪)
const PriorityIcon = "●"
// RenderStatusIcon returns the appropriate icon for a status with semantic coloring
// This is the canonical source for status icon rendering - use this everywhere
func RenderStatusIcon(status string) string {
switch status {
case "open":
return StatusIconOpen // no color - available but not urgent
case "in_progress":
return StatusInProgressStyle.Render(StatusIconInProgress)
case "blocked":
return StatusBlockedStyle.Render(StatusIconBlocked)
case "closed":
return StatusClosedStyle.Render(StatusIconClosed)
case "deferred":
return MutedStyle.Render(StatusIconDeferred)
case "pinned":
return StatusPinnedStyle.Render(StatusIconPinned)
default:
return "?" // unknown status
}
}
// GetStatusIcon returns just the icon character without styling
// Useful when you need to apply custom styling or for non-TTY output
func GetStatusIcon(status string) string {
switch status {
case "open":
return StatusIconOpen
case "in_progress":
return StatusIconInProgress
case "blocked":
return StatusIconBlocked
case "closed":
return StatusIconClosed
case "deferred":
return StatusIconDeferred
case "pinned":
return StatusIconPinned
default:
return "?"
}
}
// GetStatusStyle returns the lipgloss style for a given status
// Use this when you need to apply the semantic color to custom text
// Example: ui.GetStatusStyle("in_progress").Render(myCustomText)
func GetStatusStyle(status string) lipgloss.Style {
switch status {
case "in_progress":
return StatusInProgressStyle
case "blocked":
return StatusBlockedStyle
case "closed":
return StatusClosedStyle
case "deferred":
return MutedStyle
case "pinned":
return StatusPinnedStyle
case "hooked":
return StatusHookedStyle
default: // open and others - no special styling
return lipgloss.NewStyle()
}
}
// Tree characters for hierarchical display
const (
TreeChild = "⎿ " // child indicator
@@ -299,8 +384,29 @@ func RenderStatus(status string) string {
}
// RenderPriority renders a priority level with semantic styling
// Format: ● P0 (icon + label)
// P0/P1 get color; P2/P3/P4 use standard text
func RenderPriority(priority int) string {
label := fmt.Sprintf("%s P%d", PriorityIcon, priority)
switch priority {
case 0:
return PriorityP0Style.Render(label)
case 1:
return PriorityP1Style.Render(label)
case 2:
return PriorityP2Style.Render(label)
case 3:
return PriorityP3Style.Render(label)
case 4:
return PriorityP4Style.Render(label)
default:
return label
}
}
// RenderPriorityCompact renders just the priority label without icon
// Use when space is constrained or icon would be redundant
func RenderPriorityCompact(priority int) string {
label := fmt.Sprintf("P%d", priority)
switch priority {
case 0:

View File

@@ -57,16 +57,17 @@ func TestRenderStatusAndPriority(t *testing.T) {
}
}
// RenderPriority now includes the priority icon (●)
priorityCases := []struct {
priority int
want string
}{
{0, PriorityP0Style.Render("P0")},
{1, PriorityP1Style.Render("P1")},
{2, PriorityP2Style.Render("P2")},
{3, PriorityP3Style.Render("P3")},
{4, PriorityP4Style.Render("P4")},
{5, "P5"},
{0, PriorityP0Style.Render(PriorityIcon + " P0")},
{1, PriorityP1Style.Render(PriorityIcon + " P1")},
{2, PriorityP2Style.Render(PriorityIcon + " P2")},
{3, PriorityP3Style.Render(PriorityIcon + " P3")},
{4, PriorityP4Style.Render(PriorityIcon + " P4")},
{5, PriorityIcon + " P5"},
}
for _, tc := range priorityCases {
if got := RenderPriority(tc.priority); got != tc.want {
@@ -74,6 +75,11 @@ func TestRenderStatusAndPriority(t *testing.T) {
}
}
// RenderPriorityCompact returns just "P0" without icon
if got := RenderPriorityCompact(0); !strings.Contains(got, "P0") {
t.Fatalf("compact priority should contain P0, got %q", got)
}
if got := RenderPriorityForStatus(0, "closed"); got != "P0" {
t.Fatalf("closed priority should be plain text, got %q", got)
}