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:
54
internal/ui/markdown.go
Normal file
54
internal/ui/markdown.go
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user