Files
beads/cmd/bd/graph.go
Ryan Snodgrass cfd1f39e1e 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>
2026-01-08 20:50:56 -08:00

877 lines
24 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
// GraphNode represents a node in the rendered graph
type GraphNode struct {
Issue *types.Issue
Layer int // Horizontal layer (topological order)
Position int // Vertical position within layer
DependsOn []string // IDs this node depends on (blocks dependencies only)
}
// GraphLayout holds the computed graph layout
type GraphLayout struct {
Nodes map[string]*GraphNode
Layers [][]string // Layer index -> node IDs in that layer
MaxLayer int
RootID string
}
var (
graphCompact bool
graphBox bool
graphAll bool
)
var graphCmd = &cobra.Command{
Use: "graph [issue-id]",
GroupID: "deps",
Short: "Display issue dependency graph",
Long: `Display a visualization of an issue's dependency graph.
For epics, shows all children and their dependencies.
For regular issues, shows the issue and its direct dependencies.
With --all, shows all open issues grouped by connected component.
Display formats:
--box (default) ASCII boxes showing layers, more detailed
--compact Tree format, one line per issue, more scannable
The graph shows execution order:
- Layer 0 / leftmost = no dependencies (can start immediately)
- Higher layers depend on lower layers
- Nodes in the same layer can run in parallel
Status icons: ○ open ◐ in_progress ● blocked ✓ closed ❄ deferred`,
Args: cobra.RangeArgs(0, 1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Validate args
if graphAll && len(args) > 0 {
fmt.Fprintf(os.Stderr, "Error: cannot specify issue ID with --all flag\n")
os.Exit(1)
}
if !graphAll && len(args) == 0 {
fmt.Fprintf(os.Stderr, "Error: issue ID required (or use --all for all open issues)\n")
os.Exit(1)
}
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
var err error
store, err = sqlite.New(ctx, dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
defer func() { _ = store.Close() }()
}
if store == nil {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
os.Exit(1)
}
// Handle --all flag: show graph for all open issues
if graphAll {
subgraphs, err := loadAllGraphSubgraphs(ctx, store)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading all issues: %v\n", err)
os.Exit(1)
}
if len(subgraphs) == 0 {
fmt.Println("No open issues found")
return
}
if jsonOutput {
outputJSON(subgraphs)
return
}
// Render all subgraphs
for i, subgraph := range subgraphs {
layout := computeLayout(subgraph)
if graphCompact {
renderGraphCompact(layout, subgraph)
} else {
renderGraph(layout, subgraph)
}
if i < len(subgraphs)-1 {
fmt.Println(strings.Repeat("─", 60))
}
}
return
}
// Single issue mode
var issueID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
os.Exit(1)
}
if err := json.Unmarshal(resp.Data, &issueID); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} else {
var err error
issueID, err = utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0])
os.Exit(1)
}
}
// Load the subgraph
subgraph, err := loadGraphSubgraph(ctx, store, issueID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading graph: %v\n", err)
os.Exit(1)
}
// Compute layout
layout := computeLayout(subgraph)
if jsonOutput {
outputJSON(map[string]interface{}{
"root": subgraph.Root,
"issues": subgraph.Issues,
"layout": layout,
})
return
}
// Render graph - compact tree format or box format (default)
if graphCompact {
renderGraphCompact(layout, subgraph)
} else {
renderGraph(layout, subgraph)
}
},
}
func init() {
graphCmd.Flags().BoolVar(&graphAll, "all", false, "Show graph for all open issues")
graphCmd.Flags().BoolVar(&graphCompact, "compact", false, "Tree format, one line per issue, more scannable")
graphCmd.Flags().BoolVar(&graphBox, "box", true, "ASCII boxes showing layers (default)")
graphCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(graphCmd)
}
// loadGraphSubgraph loads an issue and its subgraph for visualization
// Unlike template loading, this includes ALL dependency types (not just parent-child)
func loadGraphSubgraph(ctx context.Context, s storage.Storage, issueID string) (*TemplateSubgraph, error) {
if s == nil {
return nil, fmt.Errorf("no database connection")
}
// Get the root issue
root, err := s.GetIssue(ctx, issueID)
if err != nil {
return nil, fmt.Errorf("failed to get issue: %w", err)
}
if root == nil {
return nil, fmt.Errorf("issue %s not found", issueID)
}
subgraph := &TemplateSubgraph{
Root: root,
Issues: []*types.Issue{root},
IssueMap: map[string]*types.Issue{root.ID: root},
}
// BFS to find all connected issues (via any dependency type)
// We traverse both directions: dependents and dependencies
queue := []string{root.ID}
visited := map[string]bool{root.ID: true}
for len(queue) > 0 {
currentID := queue[0]
queue = queue[1:]
// Get issues that depend on this one (dependents)
dependents, err := s.GetDependents(ctx, currentID)
if err != nil {
continue
}
for _, dep := range dependents {
if !visited[dep.ID] {
visited[dep.ID] = true
subgraph.Issues = append(subgraph.Issues, dep)
subgraph.IssueMap[dep.ID] = dep
queue = append(queue, dep.ID)
}
}
// Get issues this one depends on (dependencies) - but only for non-root
// to avoid pulling in unrelated upstream issues
if currentID == root.ID {
// For root, we might want to include direct dependencies too
// Skip for now to keep graph focused on "what this issue encompasses"
}
}
// Load all dependencies within the subgraph
for _, issue := range subgraph.Issues {
deps, err := s.GetDependencyRecords(ctx, issue.ID)
if err != nil {
continue
}
for _, dep := range deps {
// Only include dependencies where both ends are in the subgraph
if _, ok := subgraph.IssueMap[dep.DependsOnID]; ok {
subgraph.Dependencies = append(subgraph.Dependencies, dep)
}
}
}
return subgraph, nil
}
// loadAllGraphSubgraphs loads all open issues and groups them by connected component
// Each component is a subgraph of issues that share dependencies
func loadAllGraphSubgraphs(ctx context.Context, s storage.Storage) ([]*TemplateSubgraph, error) {
if s == nil {
return nil, fmt.Errorf("no database connection")
}
// Get all open issues (open, in_progress, blocked)
// We need to make multiple calls since IssueFilter takes a single status
var allIssues []*types.Issue
for _, status := range []types.Status{types.StatusOpen, types.StatusInProgress, types.StatusBlocked} {
statusCopy := status
issues, err := s.SearchIssues(ctx, "", types.IssueFilter{
Status: &statusCopy,
})
if err != nil {
return nil, fmt.Errorf("failed to search issues: %w", err)
}
allIssues = append(allIssues, issues...)
}
if len(allIssues) == 0 {
return nil, nil
}
// Build issue map
issueMap := make(map[string]*types.Issue)
for _, issue := range allIssues {
issueMap[issue.ID] = issue
}
// Load all dependencies between these issues
allDeps := make([]*types.Dependency, 0)
for _, issue := range allIssues {
deps, err := s.GetDependencyRecords(ctx, issue.ID)
if err != nil {
continue
}
for _, dep := range deps {
// Only include deps where both ends are in our issue set
if _, ok := issueMap[dep.DependsOnID]; ok {
allDeps = append(allDeps, dep)
}
}
}
// Build adjacency list for union-find
adj := make(map[string][]string)
for _, dep := range allDeps {
adj[dep.IssueID] = append(adj[dep.IssueID], dep.DependsOnID)
adj[dep.DependsOnID] = append(adj[dep.DependsOnID], dep.IssueID)
}
// Find connected components using BFS
visited := make(map[string]bool)
var components [][]string
for _, issue := range allIssues {
if visited[issue.ID] {
continue
}
// BFS to find all connected issues
var component []string
queue := []string{issue.ID}
visited[issue.ID] = true
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
component = append(component, current)
for _, neighbor := range adj[current] {
if !visited[neighbor] {
visited[neighbor] = true
queue = append(queue, neighbor)
}
}
}
components = append(components, component)
}
// Sort components by size (largest first) and then by priority of first issue
sort.Slice(components, func(i, j int) bool {
// First by size (descending)
if len(components[i]) != len(components[j]) {
return len(components[i]) > len(components[j])
}
// Then by priority of first issue (ascending = higher priority first)
issueI := issueMap[components[i][0]]
issueJ := issueMap[components[j][0]]
return issueI.Priority < issueJ.Priority
})
// Create subgraph for each component
var subgraphs []*TemplateSubgraph
for _, component := range components {
if len(component) == 0 {
continue
}
// Find the best "root" for this component
// Prefer: epics > highest priority > oldest
var root *types.Issue
for _, id := range component {
issue := issueMap[id]
if root == nil {
root = issue
continue
}
// Prefer epics
if issue.IssueType == types.TypeEpic && root.IssueType != types.TypeEpic {
root = issue
continue
}
if root.IssueType == types.TypeEpic && issue.IssueType != types.TypeEpic {
continue
}
// Prefer higher priority (lower number)
if issue.Priority < root.Priority {
root = issue
}
}
subgraph := &TemplateSubgraph{
Root: root,
IssueMap: make(map[string]*types.Issue),
}
for _, id := range component {
issue := issueMap[id]
subgraph.Issues = append(subgraph.Issues, issue)
subgraph.IssueMap[id] = issue
}
// Add dependencies for this component
for _, dep := range allDeps {
if _, inComponent := subgraph.IssueMap[dep.IssueID]; inComponent {
if _, depInComponent := subgraph.IssueMap[dep.DependsOnID]; depInComponent {
subgraph.Dependencies = append(subgraph.Dependencies, dep)
}
}
}
subgraphs = append(subgraphs, subgraph)
}
return subgraphs, nil
}
// computeLayout assigns layers to nodes using topological sort
func computeLayout(subgraph *TemplateSubgraph) *GraphLayout {
layout := &GraphLayout{
Nodes: make(map[string]*GraphNode),
RootID: subgraph.Root.ID,
}
// Build dependency map (only "blocks" dependencies, not parent-child)
dependsOn := make(map[string][]string)
blockedBy := make(map[string][]string)
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks {
// dep.IssueID depends on dep.DependsOnID
dependsOn[dep.IssueID] = append(dependsOn[dep.IssueID], dep.DependsOnID)
blockedBy[dep.DependsOnID] = append(blockedBy[dep.DependsOnID], dep.IssueID)
}
}
// Initialize nodes
for _, issue := range subgraph.Issues {
layout.Nodes[issue.ID] = &GraphNode{
Issue: issue,
Layer: -1, // Unassigned
DependsOn: dependsOn[issue.ID],
}
}
// Assign layers using longest path from sources
// Layer 0 = nodes with no dependencies
changed := true
for changed {
changed = false
for id, node := range layout.Nodes {
if node.Layer >= 0 {
continue // Already assigned
}
deps := dependsOn[id]
if len(deps) == 0 {
// No dependencies - layer 0
node.Layer = 0
changed = true
} else {
// Check if all dependencies have layers assigned
maxDepLayer := -1
allAssigned := true
for _, depID := range deps {
depNode := layout.Nodes[depID]
if depNode == nil || depNode.Layer < 0 {
allAssigned = false
break
}
if depNode.Layer > maxDepLayer {
maxDepLayer = depNode.Layer
}
}
if allAssigned {
node.Layer = maxDepLayer + 1
changed = true
}
}
}
}
// Handle any unassigned nodes (cycles or disconnected)
for _, node := range layout.Nodes {
if node.Layer < 0 {
node.Layer = 0
}
}
// Build layers array
for _, node := range layout.Nodes {
if node.Layer > layout.MaxLayer {
layout.MaxLayer = node.Layer
}
}
layout.Layers = make([][]string, layout.MaxLayer+1)
for id, node := range layout.Nodes {
layout.Layers[node.Layer] = append(layout.Layers[node.Layer], id)
}
// Sort nodes within each layer for consistent ordering
for i := range layout.Layers {
sort.Strings(layout.Layers[i])
}
// Assign vertical positions within layers
for _, layer := range layout.Layers {
for pos, id := range layer {
layout.Nodes[id].Position = pos
}
}
return layout
}
// renderGraph renders the ASCII visualization
func renderGraph(layout *GraphLayout, subgraph *TemplateSubgraph) {
if len(layout.Nodes) == 0 {
fmt.Println("Empty graph")
return
}
fmt.Printf("\n%s Dependency graph for %s:\n\n", ui.RenderAccent("📊"), layout.RootID)
// Calculate box width based on longest title
maxTitleLen := 0
for _, node := range layout.Nodes {
titleLen := len(truncateTitle(node.Issue.Title, 30))
if titleLen > maxTitleLen {
maxTitleLen = titleLen
}
}
boxWidth := maxTitleLen + 4 // padding
// Render each layer
// For simplicity, we'll render layer by layer with arrows between them
// First, show the legend
fmt.Println(" Status: ○ open ◐ in_progress ● blocked ✓ closed")
fmt.Println()
// Build dependency counts from subgraph
blocksCounts, blockedByCounts := computeDependencyCounts(subgraph)
// Render layers left to right
layerBoxes := make([][]string, len(layout.Layers))
for layerIdx, layer := range layout.Layers {
var boxes []string
for _, id := range layer {
node := layout.Nodes[id]
box := renderNodeBoxWithDeps(node, boxWidth, blocksCounts[id], blockedByCounts[id])
boxes = append(boxes, box)
}
layerBoxes[layerIdx] = boxes
}
// Find max height per layer
maxHeight := 0
for _, boxes := range layerBoxes {
h := len(boxes) * 4 // Each box is ~3 lines + 1 gap
if h > maxHeight {
maxHeight = h
}
}
// Render horizontally (simplified - just show boxes with arrows)
for layerIdx, boxes := range layerBoxes {
// Print layer header
fmt.Printf(" Layer %d", layerIdx)
if layerIdx == 0 {
fmt.Print(" (ready)")
}
fmt.Println()
for _, box := range boxes {
fmt.Println(box)
}
// Print arrows to next layer if not last
if layerIdx < len(layerBoxes)-1 {
fmt.Println(" │")
fmt.Println(" ▼")
}
fmt.Println()
}
// Show dependency summary
if len(subgraph.Dependencies) > 0 {
blocksDeps := 0
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks {
blocksDeps++
}
}
if blocksDeps > 0 {
fmt.Printf(" Dependencies: %d blocking relationships\n", blocksDeps)
}
}
// Show summary
fmt.Printf(" Total: %d issues across %d layers\n\n", len(layout.Nodes), len(layout.Layers))
}
// renderGraphCompact renders the graph in compact tree format
// One line per issue, more scannable, uses tree connectors (├──, └──, │)
func renderGraphCompact(layout *GraphLayout, subgraph *TemplateSubgraph) {
if len(layout.Nodes) == 0 {
fmt.Println("Empty graph")
return
}
fmt.Printf("\n%s Dependency graph for %s (%d issues, %d layers)\n\n",
ui.RenderAccent("📊"), layout.RootID, len(layout.Nodes), len(layout.Layers))
// Legend
fmt.Println(" Status: ○ open ◐ in_progress ● blocked ✓ closed ❄ deferred")
fmt.Println()
// Build parent-child map from subgraph dependencies
children := make(map[string][]string) // parent -> children
childSet := make(map[string]bool) // track which issues are children
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepParentChild {
children[dep.DependsOnID] = append(children[dep.DependsOnID], dep.IssueID)
childSet[dep.IssueID] = true
}
}
// Sort children by priority then ID for consistent output
for parentID := range children {
sort.Slice(children[parentID], func(i, j int) bool {
nodeI := layout.Nodes[children[parentID][i]]
nodeJ := layout.Nodes[children[parentID][j]]
if nodeI.Issue.Priority != nodeJ.Issue.Priority {
return nodeI.Issue.Priority < nodeJ.Issue.Priority
}
return nodeI.Issue.ID < nodeJ.Issue.ID
})
}
// Render by layer with tree structure
for layerIdx, layer := range layout.Layers {
// Layer header
layerHeader := fmt.Sprintf("LAYER %d", layerIdx)
if layerIdx == 0 {
layerHeader += " (ready)"
}
fmt.Printf(" %s\n", ui.RenderAccent(layerHeader))
for i, id := range layer {
node := layout.Nodes[id]
isLast := i == len(layer)-1
// Format node line
line := formatCompactNode(node)
// Tree connector
connector := "├── "
if isLast {
connector = "└── "
}
fmt.Printf(" %s%s\n", connector, line)
// Render children (if this issue has children in the subgraph)
if childIDs, ok := children[id]; ok && len(childIDs) > 0 {
childPrefix := "│ "
if isLast {
childPrefix = " "
}
renderCompactChildren(layout, childIDs, children, childPrefix, 1)
}
}
fmt.Println()
}
}
// renderCompactChildren recursively renders children in tree format
func renderCompactChildren(layout *GraphLayout, childIDs []string, children map[string][]string, prefix string, depth int) {
for i, childID := range childIDs {
node := layout.Nodes[childID]
if node == nil {
continue
}
isLast := i == len(childIDs)-1
connector := "├── "
if isLast {
connector = "└── "
}
line := formatCompactNode(node)
fmt.Printf(" %s%s%s\n", prefix, connector, line)
// Recurse for nested children
if grandchildren, ok := children[childID]; ok && len(grandchildren) > 0 {
childPrefix := prefix
if isLast {
childPrefix += " "
} else {
childPrefix += "│ "
}
renderCompactChildren(layout, grandchildren, children, childPrefix, depth+1)
}
}
}
// formatCompactNode formats a single node for compact output
// Format: STATUS_ICON ID PRIORITY Title
func formatCompactNode(node *GraphNode) string {
status := string(node.Issue.Status)
// Use shared status icon with semantic color
statusIcon := ui.RenderStatusIcon(status)
// Priority with icon
priorityTag := ui.RenderPriority(node.Issue.Priority)
// Title - truncate if too long
title := truncateTitle(node.Issue.Title, 50)
// Build line - apply status style to entire line for closed issues
style := ui.GetStatusStyle(status)
if node.Issue.Status == types.StatusClosed {
return fmt.Sprintf("%s %s %s %s",
statusIcon,
style.Render(node.Issue.ID),
style.Render(fmt.Sprintf("● P%d", node.Issue.Priority)),
style.Render(title))
}
return fmt.Sprintf("%s %s %s %s", statusIcon, node.Issue.ID, priorityTag, title)
}
// renderNodeBox renders a single node as an ASCII box
// Uses semantic status styles from ui package for consistency
func renderNodeBox(node *GraphNode, width int) string {
title := truncateTitle(node.Issue.Title, width-4)
paddedTitle := padRight(title, width-4)
status := string(node.Issue.Status)
// Use shared status icon and style
statusIcon := ui.RenderStatusIcon(status)
style := ui.GetStatusStyle(status)
// Apply style to title for actionable statuses
var titleStr string
if node.Issue.Status == types.StatusOpen {
titleStr = paddedTitle // no color for open - available but not urgent
} else {
titleStr = style.Render(paddedTitle)
}
id := node.Issue.ID
// Build the box
topBottom := " ┌" + strings.Repeat("─", width) + "┐"
middle := fmt.Sprintf(" │ %s %s │", statusIcon, titleStr)
idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2)))
bottom := " └" + strings.Repeat("─", width) + "┘"
return topBottom + "\n" + middle + "\n" + idLine + "\n" + bottom
}
// truncateTitle truncates a title to max length (rune-safe)
func truncateTitle(title string, maxLen int) string {
runes := []rune(title)
if len(runes) <= maxLen {
return title
}
return string(runes[:maxLen-1]) + "…"
}
// padRight pads a string to the right with spaces (rune-safe)
func padRight(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return string(runes[:width])
}
return s + strings.Repeat(" ", width-len(runes))
}
// computeDependencyCounts calculates how many issues each issue blocks and is blocked by
// Excludes parent-child relationships and the root issue from counts to reduce cognitive noise
func computeDependencyCounts(subgraph *TemplateSubgraph) (blocks map[string]int, blockedBy map[string]int) {
blocks = make(map[string]int)
blockedBy = make(map[string]int)
if subgraph == nil {
return blocks, blockedBy
}
rootID := ""
if subgraph.Root != nil {
rootID = subgraph.Root.ID
}
for _, dep := range subgraph.Dependencies {
// Only count "blocks" dependencies (not parent-child, related, etc.)
if dep.Type != types.DepBlocks {
continue
}
// Skip if the blocker is the root issue - this is obvious from graph structure
// and showing "needs:1" when it's just the parent epic is cognitive noise
if dep.DependsOnID == rootID {
continue
}
// dep.DependsOnID blocks dep.IssueID
// So dep.DependsOnID "blocks" count increases
blocks[dep.DependsOnID]++
// And dep.IssueID "blocked by" count increases
blockedBy[dep.IssueID]++
}
return blocks, blockedBy
}
// renderNodeBoxWithDeps renders a node box with dependency information
// Uses semantic status styles from ui package for consistency across commands
// Design principle: only actionable states get color, closed items fade
func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedByCount int) string {
title := truncateTitle(node.Issue.Title, width-4)
paddedTitle := padRight(title, width-4)
status := string(node.Issue.Status)
// Use shared status icon and style from ui package
statusIcon := ui.RenderStatusIcon(status)
style := ui.GetStatusStyle(status)
// Apply style to title for actionable statuses
var titleStr string
if node.Issue.Status == types.StatusOpen {
titleStr = paddedTitle // no color for open - available but not urgent
} else {
titleStr = style.Render(paddedTitle)
}
id := node.Issue.ID
// Build dependency info string - only show if meaningful counts exist
// Note: we build the plain text version first for padding, then apply colors
var depInfoPlain string
var depInfoStyled string
if blocksCount > 0 || blockedByCount > 0 {
plainParts := []string{}
styledParts := []string{}
if blocksCount > 0 {
plainText := fmt.Sprintf("blocks:%d", blocksCount)
plainParts = append(plainParts, plainText)
// Use semantic color for blocks indicator - attention-grabbing
styledParts = append(styledParts, ui.StatusBlockedStyle.Render(plainText))
}
if blockedByCount > 0 {
plainText := fmt.Sprintf("needs:%d", blockedByCount)
plainParts = append(plainParts, plainText)
// Use muted color for needs indicator - informational
styledParts = append(styledParts, ui.MutedStyle.Render(plainText))
}
depInfoPlain = strings.Join(plainParts, " ")
depInfoStyled = strings.Join(styledParts, " ")
}
// Build the box
topBottom := " ┌" + strings.Repeat("─", width) + "┐"
middle := fmt.Sprintf(" │ %s %s │", statusIcon, titleStr)
idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2)))
var result string
if depInfoPlain != "" {
// Pad based on plain text length, then render with styled version
padding := width - 2 - len([]rune(depInfoPlain))
if padding < 0 {
padding = 0
}
depLine := fmt.Sprintf(" │ %s%s │", depInfoStyled, strings.Repeat(" ", padding))
bottom := " └" + strings.Repeat("─", width) + "┘"
result = topBottom + "\n" + middle + "\n" + idLine + "\n" + depLine + "\n" + bottom
} else {
bottom := " └" + strings.Repeat("─", width) + "┘"
result = topBottom + "\n" + middle + "\n" + idLine + "\n" + bottom
}
return result
}