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

View File

@@ -33,51 +33,44 @@ type GraphLayout struct {
RootID string
}
var (
graphCompact bool
graphBox bool
graphAll bool
)
var graphCmd = &cobra.Command{
Use: "graph <issue-id>",
Use: "graph [issue-id]",
GroupID: "deps",
Short: "Display issue dependency graph",
Long: `Display an ASCII visualization of an issue's 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.
The graph shows execution order left-to-right:
- Leftmost nodes have no dependencies (can start immediately)
- Rightmost nodes depend on everything to their left
- Nodes in the same column can run in parallel
With --all, shows all open issues grouped by connected component.
Colors indicate status:
- White: open (ready to work)
- Yellow: in progress
- Red: blocked
- Green: closed`,
Args: cobra.ExactArgs(1),
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
var issueID string
// Resolve the issue ID
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 if store != nil {
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)
}
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
// 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)
}
@@ -92,6 +85,66 @@ Colors indicate status:
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 {
@@ -111,12 +164,19 @@ Colors indicate status:
return
}
// Render ASCII graph
renderGraph(layout, subgraph)
// 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)
}
@@ -191,6 +251,157 @@ func loadGraphSubgraph(ctx context.Context, s storage.Storage, issueID string) (
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{
@@ -379,33 +590,155 @@ func renderGraph(layout *GraphLayout, subgraph *TemplateSubgraph) {
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 {
// Status indicator
var statusIcon string
var titleStr string
title := truncateTitle(node.Issue.Title, width-4)
paddedTitle := padRight(title, width-4)
status := string(node.Issue.Status)
switch node.Issue.Status {
case types.StatusOpen:
statusIcon = "○"
titleStr = padRight(title, width-4)
case types.StatusInProgress:
statusIcon = "◐"
titleStr = ui.RenderWarn(padRight(title, width-4))
case types.StatusBlocked:
statusIcon = "●"
titleStr = ui.RenderFail(padRight(title, width-4))
case types.StatusDeferred:
statusIcon = "❄"
titleStr = ui.RenderAccent(padRight(title, width-4))
case types.StatusClosed:
statusIcon = "✓"
titleStr = ui.RenderPass(padRight(title, width-4))
default:
statusIcon = "?"
titleStr = padRight(title, width-4)
// 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
@@ -438,6 +771,7 @@ func padRight(s string, width int) string {
}
// 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)
@@ -446,61 +780,76 @@ func computeDependencyCounts(subgraph *TemplateSubgraph) (blocks map[string]int,
return blocks, blockedBy
}
rootID := ""
if subgraph.Root != nil {
rootID = subgraph.Root.ID
}
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks {
// dep.DependsOnID blocks dep.IssueID
// So dep.DependsOnID "blocks" count increases
blocks[dep.DependsOnID]++
// And dep.IssueID "blocked by" count increases
blockedBy[dep.IssueID]++
// 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 {
// Status indicator
var statusIcon string
var titleStr string
title := truncateTitle(node.Issue.Title, width-4)
paddedTitle := padRight(title, width-4)
status := string(node.Issue.Status)
switch node.Issue.Status {
case types.StatusOpen:
statusIcon = "○"
titleStr = padRight(title, width-4)
case types.StatusInProgress:
statusIcon = "◐"
titleStr = ui.RenderWarn(padRight(title, width-4))
case types.StatusBlocked:
statusIcon = "●"
titleStr = ui.RenderFail(padRight(title, width-4))
case types.StatusDeferred:
statusIcon = "❄"
titleStr = ui.RenderAccent(padRight(title, width-4))
case types.StatusClosed:
statusIcon = "✓"
titleStr = ui.RenderPass(padRight(title, width-4))
default:
statusIcon = "?"
titleStr = padRight(title, width-4)
// 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
var depInfo string
// 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 {
parts := []string{}
plainParts := []string{}
styledParts := []string{}
if blocksCount > 0 {
parts = append(parts, fmt.Sprintf("blocks:%d", blocksCount))
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 {
parts = append(parts, fmt.Sprintf("needs:%d", blockedByCount))
plainText := fmt.Sprintf("needs:%d", blockedByCount)
plainParts = append(plainParts, plainText)
// Use muted color for needs indicator - informational
styledParts = append(styledParts, ui.MutedStyle.Render(plainText))
}
depInfo = strings.Join(parts, " ")
depInfoPlain = strings.Join(plainParts, " ")
depInfoStyled = strings.Join(styledParts, " ")
}
// Build the box
@@ -509,8 +858,13 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB
idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2)))
var result string
if depInfo != "" {
depLine := fmt.Sprintf(" │ %s │", ui.RenderAccent(padRight(depInfo, width-2)))
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 {

View File

@@ -20,6 +20,7 @@ import (
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/timeparsing"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
@@ -42,76 +43,131 @@ func pinIndicator(issue *types.Issue) string {
return ""
}
// Priority symbols for pretty output (GH#654)
var prioritySymbols = map[int]string{
0: "🔴", // P0 - Critical
1: "🟠", // P1 - High
2: "🟡", // P2 - Medium (default)
3: "🔵", // P3 - Low
4: "⚪", // P4 - Lowest
// Priority tags for pretty output - simple text, semantic colors applied via ui package
// Design principle: only P0/P1 get color for attention, P2-P4 are neutral
func renderPriorityTag(priority int) string {
return ui.RenderPriority(priority)
}
// Status symbols for pretty output (GH#654)
var statusSymbols = map[types.Status]string{
"open": "○",
"in_progress": "◐",
"blocked": "⊗",
"deferred": "◇",
"closed": "●",
// renderStatusIcon returns the status icon with semantic coloring applied
// Delegates to the shared ui.RenderStatusIcon for consistency across commands
func renderStatusIcon(status types.Status) string {
return ui.RenderStatusIcon(string(status))
}
// formatPrettyIssue formats a single issue for pretty output
// Uses semantic colors: status icon colored, priority P0/P1 colored, rest neutral
func formatPrettyIssue(issue *types.Issue) string {
prioritySym := prioritySymbols[issue.Priority]
if prioritySym == "" {
prioritySym = "⚪"
}
statusSym := statusSymbols[issue.Status]
if statusSym == "" {
statusSym = "○"
}
// Use shared helpers from ui package
statusIcon := ui.RenderStatusIcon(string(issue.Status))
priorityTag := renderPriorityTag(issue.Priority)
// Type badge - only show for notable types
typeBadge := ""
switch issue.IssueType {
case "epic":
typeBadge = "[EPIC] "
case "feature":
typeBadge = "[FEAT] "
typeBadge = ui.TypeEpicStyle.Render("[epic]") + " "
case "bug":
typeBadge = "[BUG] "
typeBadge = ui.TypeBugStyle.Render("[bug]") + " "
}
return fmt.Sprintf("%s %s %s - %s%s", statusSym, prioritySym, issue.ID, typeBadge, issue.Title)
// Format: STATUS_ICON ID PRIORITY [Type] Title
// Priority uses ● icon with color, no brackets needed
// Closed issues: entire line is muted
if issue.Status == types.StatusClosed {
return fmt.Sprintf("%s %s %s %s%s",
statusIcon,
ui.RenderMuted(issue.ID),
ui.RenderMuted(fmt.Sprintf("● P%d", issue.Priority)),
ui.RenderMuted(string(issue.IssueType)),
ui.RenderMuted(" "+issue.Title))
}
return fmt.Sprintf("%s %s %s %s%s", statusIcon, issue.ID, priorityTag, typeBadge, issue.Title)
}
// buildIssueTree builds parent-child tree structure from issues
// Uses actual parent-child dependencies from the database when store is provided
func buildIssueTree(issues []*types.Issue) (roots []*types.Issue, childrenMap map[string][]*types.Issue) {
return buildIssueTreeWithDeps(issues, nil)
}
// buildIssueTreeWithDeps builds parent-child tree using dependency records
// If allDeps is nil, falls back to dotted ID hierarchy (e.g., "parent.1")
// Treats any dependency on an epic as a parent-child relationship
func buildIssueTreeWithDeps(issues []*types.Issue, allDeps map[string][]*types.Dependency) (roots []*types.Issue, childrenMap map[string][]*types.Issue) {
issueMap := make(map[string]*types.Issue)
childrenMap = make(map[string][]*types.Issue)
isChild := make(map[string]bool)
// Build issue map and identify epics
epicIDs := make(map[string]bool)
for _, issue := range issues {
issueMap[issue.ID] = issue
if issue.IssueType == "epic" {
epicIDs[issue.ID] = true
}
}
// If we have dependency records, use them to find parent-child relationships
if allDeps != nil {
for issueID, deps := range allDeps {
for _, dep := range deps {
parentID := dep.DependsOnID
// Only include if both parent and child are in the issue set
child, childOk := issueMap[issueID]
_, parentOk := issueMap[parentID]
if !childOk || !parentOk {
continue
}
// Treat as parent-child if:
// 1. Explicit parent-child dependency type, OR
// 2. Any dependency where the target is an epic
if dep.Type == types.DepParentChild || epicIDs[parentID] {
childrenMap[parentID] = append(childrenMap[parentID], child)
isChild[issueID] = true
}
}
}
}
// Fallback: check for hierarchical subtask IDs (e.g., "parent.1")
for _, issue := range issues {
// Check if this is a hierarchical subtask (e.g., "parent.1")
if isChild[issue.ID] {
continue // Already a child via dependency
}
if strings.Contains(issue.ID, ".") {
parts := strings.Split(issue.ID, ".")
parentID := strings.Join(parts[:len(parts)-1], ".")
if _, exists := issueMap[parentID]; exists {
childrenMap[parentID] = append(childrenMap[parentID], issue)
isChild[issue.ID] = true
continue
}
}
roots = append(roots, issue)
}
// Roots are issues that aren't children of any other issue
for _, issue := range issues {
if !isChild[issue.ID] {
roots = append(roots, issue)
}
}
return roots, childrenMap
}
// printPrettyTree recursively prints the issue tree
// Children are sorted by priority (P0 first) for intuitive reading
func printPrettyTree(childrenMap map[string][]*types.Issue, parentID string, prefix string) {
children := childrenMap[parentID]
// Sort children by priority (ascending: P0 before P1 before P2...)
slices.SortFunc(children, func(a, b *types.Issue) int {
return cmp.Compare(a.Priority, b.Priority)
})
for i, child := range children {
isLast := i == len(children)-1
connector := "├── "
@@ -129,7 +185,13 @@ func printPrettyTree(childrenMap map[string][]*types.Issue, parentID string, pre
}
// displayPrettyList displays issues in pretty tree format (GH#654)
// Uses buildIssueTree which only supports dotted ID hierarchy
func displayPrettyList(issues []*types.Issue, showHeader bool) {
displayPrettyListWithDeps(issues, showHeader, nil)
}
// displayPrettyListWithDeps displays issues in tree format using dependency data
func displayPrettyListWithDeps(issues []*types.Issue, showHeader bool, allDeps map[string][]*types.Dependency) {
if showHeader {
// Clear screen and show header
fmt.Print("\033[2J\033[H")
@@ -144,14 +206,11 @@ func displayPrettyList(issues []*types.Issue, showHeader bool) {
return
}
roots, childrenMap := buildIssueTree(issues)
roots, childrenMap := buildIssueTreeWithDeps(issues, allDeps)
for i, issue := range roots {
for _, issue := range roots {
fmt.Println(formatPrettyIssue(issue))
printPrettyTree(childrenMap, issue.ID, "")
if i < len(roots)-1 {
fmt.Println()
}
}
// Summary
@@ -169,7 +228,7 @@ func displayPrettyList(issues []*types.Issue, showHeader bool) {
}
fmt.Printf("Total: %d issues (%d open, %d in progress)\n", len(issues), openCount, inProgressCount)
fmt.Println()
fmt.Println("Legend: ○ open | ◐ in progress | ⊗ blocked | 🔴 P0 | 🟠 P1 | 🟡 P2 | 🔵 P3 | ⚪ P4")
fmt.Println("Status: ○ open ◐ in_progress blocked ✓ closed ❄ deferred")
}
// watchIssues starts watching for changes and re-displays (GH#654)
@@ -330,6 +389,8 @@ func formatAgentIssue(buf *strings.Builder, issue *types.Issue) {
}
// formatIssueCompact formats a single issue in compact format to a buffer
// Uses status icons for better scanability - consistent with bd graph
// Format: [icon] [pin] ID [Priority] [Type] @assignee [labels] - Title
func formatIssueCompact(buf *strings.Builder, issue *types.Issue, labels []string) {
labelsStr := ""
if len(labels) > 0 {
@@ -339,20 +400,25 @@ func formatIssueCompact(buf *strings.Builder, issue *types.Issue, labels []strin
if issue.Assignee != "" {
assigneeStr = fmt.Sprintf(" @%s", issue.Assignee)
}
status := string(issue.Status)
if status == "closed" {
line := fmt.Sprintf("%s%s [P%d] [%s] %s%s%s - %s",
pinIndicator(issue), issue.ID, issue.Priority,
issue.IssueType, status, assigneeStr, labelsStr, issue.Title)
// Get styled status icon
statusIcon := renderStatusIcon(issue.Status)
if issue.Status == types.StatusClosed {
// Closed issues: entire line muted (fades visually)
line := fmt.Sprintf("%s %s%s [P%d] [%s]%s%s - %s",
statusIcon, pinIndicator(issue), issue.ID, issue.Priority,
issue.IssueType, assigneeStr, labelsStr, issue.Title)
buf.WriteString(ui.RenderClosedLine(line))
buf.WriteString("\n")
} else {
buf.WriteString(fmt.Sprintf("%s%s [%s] [%s] %s%s%s - %s\n",
// Active issues: status icon + semantic colors for priority/type
buf.WriteString(fmt.Sprintf("%s %s%s [%s] [%s]%s%s - %s\n",
statusIcon,
pinIndicator(issue),
ui.RenderID(issue.ID),
ui.RenderPriority(issue.Priority),
ui.RenderType(string(issue.IssueType)),
ui.RenderStatus(status),
assigneeStr, labelsStr, issue.Title))
}
}
@@ -437,6 +503,8 @@ var listCmd = &cobra.Command{
// Pretty and watch flags (GH#654)
prettyFormat, _ := cmd.Flags().GetBool("pretty")
treeFormat, _ := cmd.Flags().GetBool("tree")
prettyFormat = prettyFormat || treeFormat // --tree is alias for --pretty
watchMode, _ := cmd.Flags().GetBool("watch")
// Pager control (bd-jdz3)
@@ -824,6 +892,33 @@ var listCmd = &cobra.Command{
// Apply sorting
sortIssues(issues, sortBy, reverse)
// Handle watch mode (GH#654)
if watchMode {
watchIssues(ctx, store, filter, sortBy, reverse)
return
}
// Handle pretty/tree format (GH#654)
if prettyFormat {
// Load dependencies for tree structure
// In daemon mode, open a read-only store to get dependencies
var allDeps map[string][]*types.Dependency
if store != nil {
allDeps, _ = store.GetAllDependencyRecords(ctx)
} else if dbPath != "" {
// Daemon mode: open read-only connection for tree deps
if roStore, err := sqlite.NewReadOnlyWithTimeout(ctx, dbPath, lockTimeout); err == nil {
allDeps, _ = roStore.GetAllDependencyRecords(ctx)
_ = roStore.Close()
}
}
displayPrettyListWithDeps(issues, false, allDeps)
if effectiveLimit > 0 && len(issues) == effectiveLimit {
fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit)
}
return
}
// Build output in buffer for pager support (bd-jdz3)
var buf strings.Builder
if ui.IsAgentMode() {
@@ -891,7 +986,9 @@ var listCmd = &cobra.Command{
// Handle pretty format (GH#654)
if prettyFormat {
displayPrettyList(issues, false)
// Load dependencies for tree structure
allDeps, _ := store.GetAllDependencyRecords(ctx)
displayPrettyListWithDeps(issues, false, allDeps)
// Show truncation hint if we hit the limit (GH#788)
if effectiveLimit > 0 && len(issues) == effectiveLimit {
fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit)
@@ -1055,6 +1152,7 @@ func init() {
// Pretty and watch flags (GH#654)
listCmd.Flags().Bool("pretty", false, "Display issues in a tree format with status/priority symbols")
listCmd.Flags().Bool("tree", false, "Alias for --pretty: hierarchical tree format")
listCmd.Flags().BoolP("watch", "w", false, "Watch for changes and auto-update display (implies --pretty)")
// Pager control (bd-jdz3)

View File

@@ -57,8 +57,8 @@ func TestListFormatPrettyIssue_BadgesAndDefaults(t *testing.T) {
if !strings.Contains(out, "bd-1") || !strings.Contains(out, "Hello") {
t.Fatalf("unexpected output: %q", out)
}
if !strings.Contains(out, "[BUG]") {
t.Fatalf("expected BUG badge: %q", out)
if !strings.Contains(out, "[bug]") {
t.Fatalf("expected bug badge: %q", out)
}
}

View File

@@ -131,14 +131,14 @@ var showCmd = &cobra.Command{
allDetails = append(allDetails, details)
} else {
if displayIdx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
}
fmt.Printf("\n%s: %s\n", ui.RenderAccent(issue.ID), issue.Title)
fmt.Printf("Status: %s\n", issue.Status)
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
// Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
fmt.Printf("\n%s\n", formatIssueHeader(issue))
// Metadata: Owner · Type | Created · Updated
fmt.Println(formatIssueMetadata(issue))
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESCRIPTION"), ui.RenderMarkdown(issue.Description))
}
fmt.Println()
displayIdx++
@@ -188,54 +188,17 @@ var showCmd = &cobra.Command{
}
if displayIdx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
}
displayIdx++
// Format output (same as direct mode below)
tierEmoji := ""
statusSuffix := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
statusSuffix = " (compacted L1)"
case 2:
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
// Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
fmt.Printf("\n%s\n", formatIssueHeader(issue))
fmt.Printf("\n%s: %s%s\n", ui.RenderAccent(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason)
}
if issue.ClosedBySession != "" {
fmt.Printf("Closed by session: %s\n", issue.ClosedBySession)
}
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" {
fmt.Printf("Assignee: %s\n", issue.Assignee)
}
if issue.EstimatedMinutes != nil {
fmt.Printf("Estimated: %d minutes\n", *issue.EstimatedMinutes)
}
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
if issue.CreatedBy != "" {
fmt.Printf("Created by: %s\n", issue.CreatedBy)
}
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
if issue.DueAt != nil {
fmt.Printf("Due: %s\n", issue.DueAt.Format("2006-01-02 15:04"))
}
if issue.DeferUntil != nil {
fmt.Printf("Deferred until: %s\n", issue.DeferUntil.Format("2006-01-02 15:04"))
}
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
fmt.Printf("External Ref: %s\n", *issue.ExternalRef)
}
// Metadata: Owner · Type | Created · Updated
fmt.Println(formatIssueMetadata(issue))
// Show compaction status
// Compaction info (if applicable)
if issue.CompactionLevel > 0 {
fmt.Println()
if issue.OriginalSize > 0 {
@@ -243,47 +206,40 @@ var showCmd = &cobra.Command{
saved := issue.OriginalSize - currentSize
if saved > 0 {
reduction := float64(saved) / float64(issue.OriginalSize) * 100
fmt.Printf("📊 Original: %d bytes | Compressed: %d bytes (%.0f%% reduction)\n",
fmt.Printf("📊 %d → %d bytes (%.0f%% reduction)\n",
issue.OriginalSize, currentSize, reduction)
}
}
tierEmoji2 := "🗜️"
if issue.CompactionLevel == 2 {
tierEmoji2 = "📦"
}
compactedDate := ""
if issue.CompactedAt != nil {
compactedDate = issue.CompactedAt.Format("2006-01-02")
}
fmt.Printf("%s Compacted: %s (Tier %d)\n", tierEmoji2, compactedDate, issue.CompactionLevel)
}
// Content sections
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESCRIPTION"), ui.RenderMarkdown(issue.Description))
}
if issue.Design != "" {
fmt.Printf("\nDesign:\n%s\n", issue.Design)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESIGN"), ui.RenderMarkdown(issue.Design))
}
if issue.Notes != "" {
fmt.Printf("\nNotes:\n%s\n", issue.Notes)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("NOTES"), ui.RenderMarkdown(issue.Notes))
}
if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("ACCEPTANCE CRITERIA"), ui.RenderMarkdown(issue.AcceptanceCriteria))
}
if len(details.Labels) > 0 {
fmt.Printf("\nLabels: %v\n", details.Labels)
fmt.Printf("\n%s %s\n", ui.RenderBold("LABELS:"), strings.Join(details.Labels, ", "))
}
// Dependencies with semantic colors
if len(details.Dependencies) > 0 {
fmt.Printf("\nDepends on (%d):\n", len(details.Dependencies))
fmt.Printf("\n%s\n", ui.RenderBold("DEPENDS ON"))
for _, dep := range details.Dependencies {
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
fmt.Println(formatDependencyLine("→", dep))
}
}
// Dependents grouped by type with semantic colors
if len(details.Dependents) > 0 {
// Group by dependency type for clarity
var blocks, children, related, discovered []*types.IssueWithDependencyMetadata
for _, dep := range details.Dependents {
switch dep.DependencyType {
@@ -301,35 +257,35 @@ var showCmd = &cobra.Command{
}
if len(children) > 0 {
fmt.Printf("\nChildren (%d):\n", len(children))
fmt.Printf("\n%s\n", ui.RenderBold("CHILDREN"))
for _, dep := range children {
fmt.Printf(" ↳ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("↳", dep))
}
}
if len(blocks) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(blocks))
fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range blocks {
fmt.Printf(" ← %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("←", dep))
}
}
if len(related) > 0 {
fmt.Printf("\nRelated (%d):\n", len(related))
fmt.Printf("\n%s\n", ui.RenderBold("RELATED"))
for _, dep := range related {
fmt.Printf(" ↔ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("↔", dep))
}
}
if len(discovered) > 0 {
fmt.Printf("\nDiscovered (%d):\n", len(discovered))
fmt.Printf("\n%s\n", ui.RenderBold("DISCOVERED"))
for _, dep := range discovered {
fmt.Printf(" ◊ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("◊", dep))
}
}
}
if len(details.Comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(details.Comments))
fmt.Printf("\n%s\n", ui.RenderBold("COMMENTS"))
for _, comment := range details.Comments {
fmt.Printf(" [%s] %s\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"))
fmt.Printf(" %s %s\n", ui.RenderMuted(comment.CreatedAt.Format("2006-01-02")), comment.Author)
commentLines := strings.Split(comment.Text, "\n")
for _, line := range commentLines {
fmt.Printf(" %s\n", line)
@@ -418,102 +374,55 @@ var showCmd = &cobra.Command{
}
if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
}
// Add compaction emoji to title line
tierEmoji := ""
statusSuffix := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
statusSuffix = " (compacted L1)"
case 2:
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
// Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
fmt.Printf("\n%s\n", formatIssueHeader(issue))
fmt.Printf("\n%s: %s%s\n", ui.RenderAccent(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
if issue.CloseReason != "" {
fmt.Printf("Close reason: %s\n", issue.CloseReason)
}
if issue.ClosedBySession != "" {
fmt.Printf("Closed by session: %s\n", issue.ClosedBySession)
}
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" {
fmt.Printf("Assignee: %s\n", issue.Assignee)
}
if issue.EstimatedMinutes != nil {
fmt.Printf("Estimated: %d minutes\n", *issue.EstimatedMinutes)
}
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
if issue.CreatedBy != "" {
fmt.Printf("Created by: %s\n", issue.CreatedBy)
}
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
if issue.DueAt != nil {
fmt.Printf("Due: %s\n", issue.DueAt.Format("2006-01-02 15:04"))
}
if issue.DeferUntil != nil {
fmt.Printf("Deferred until: %s\n", issue.DeferUntil.Format("2006-01-02 15:04"))
}
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
fmt.Printf("External Ref: %s\n", *issue.ExternalRef)
}
// Metadata: Owner · Type | Created · Updated
fmt.Println(formatIssueMetadata(issue))
// Show compaction status footer
// Compaction info (if applicable)
if issue.CompactionLevel > 0 {
tierEmoji := "🗜️"
if issue.CompactionLevel == 2 {
tierEmoji = "📦"
}
tierName := fmt.Sprintf("Tier %d", issue.CompactionLevel)
fmt.Println()
if issue.OriginalSize > 0 {
currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
saved := issue.OriginalSize - currentSize
if saved > 0 {
reduction := float64(saved) / float64(issue.OriginalSize) * 100
fmt.Printf("📊 Original: %d bytes | Compressed: %d bytes (%.0f%% reduction)\n",
fmt.Printf("📊 %d → %d bytes (%.0f%% reduction)\n",
issue.OriginalSize, currentSize, reduction)
}
}
compactedDate := ""
if issue.CompactedAt != nil {
compactedDate = issue.CompactedAt.Format("2006-01-02")
}
fmt.Printf("%s Compacted: %s (%s)\n", tierEmoji, compactedDate, tierName)
}
// Content sections
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESCRIPTION"), ui.RenderMarkdown(issue.Description))
}
if issue.Design != "" {
fmt.Printf("\nDesign:\n%s\n", issue.Design)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("DESIGN"), ui.RenderMarkdown(issue.Design))
}
if issue.Notes != "" {
fmt.Printf("\nNotes:\n%s\n", issue.Notes)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("NOTES"), ui.RenderMarkdown(issue.Notes))
}
if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
fmt.Printf("\n%s\n%s\n", ui.RenderBold("ACCEPTANCE CRITERIA"), ui.RenderMarkdown(issue.AcceptanceCriteria))
}
// Show labels
labels, _ := issueStore.GetLabels(ctx, issue.ID)
if len(labels) > 0 {
fmt.Printf("\nLabels: %v\n", labels)
fmt.Printf("\n%s %s\n", ui.RenderBold("LABELS:"), strings.Join(labels, ", "))
}
// Show dependencies
// Show dependencies with semantic colors
deps, _ := issueStore.GetDependencies(ctx, issue.ID)
if len(deps) > 0 {
fmt.Printf("\nDepends on (%d):\n", len(deps))
fmt.Printf("\n%s\n", ui.RenderBold("DEPENDS ON"))
for _, dep := range deps {
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
fmt.Println(formatSimpleDependencyLine("→", dep))
}
}
@@ -541,27 +450,27 @@ var showCmd = &cobra.Command{
}
if len(children) > 0 {
fmt.Printf("\nChildren (%d):\n", len(children))
fmt.Printf("\n%s\n", ui.RenderBold("CHILDREN"))
for _, dep := range children {
fmt.Printf(" ↳ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("↳", dep))
}
}
if len(blocks) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(blocks))
fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range blocks {
fmt.Printf(" ← %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("←", dep))
}
}
if len(related) > 0 {
fmt.Printf("\nRelated (%d):\n", len(related))
fmt.Printf("\n%s\n", ui.RenderBold("RELATED"))
for _, dep := range related {
fmt.Printf(" ↔ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("↔", dep))
}
}
if len(discovered) > 0 {
fmt.Printf("\nDiscovered (%d):\n", len(discovered))
fmt.Printf("\n%s\n", ui.RenderBold("DISCOVERED"))
for _, dep := range discovered {
fmt.Printf(" ◊ %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatDependencyLine("◊", dep))
}
}
}
@@ -569,9 +478,9 @@ var showCmd = &cobra.Command{
// Fallback for non-SQLite storage
dependents, _ := issueStore.GetDependents(ctx, issue.ID)
if len(dependents) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(dependents))
fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range dependents {
fmt.Printf(" ← %s: %s [P%d - %s]\n", dep.ID, dep.Title, dep.Priority, dep.Status)
fmt.Println(formatSimpleDependencyLine("←", dep))
}
}
}
@@ -579,9 +488,13 @@ var showCmd = &cobra.Command{
// Show comments
comments, _ := issueStore.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
fmt.Printf("\n%s\n", ui.RenderBold("COMMENTS"))
for _, comment := range comments {
fmt.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text)
fmt.Printf(" %s %s\n", ui.RenderMuted(comment.CreatedAt.Format("2006-01-02")), comment.Author)
commentLines := strings.Split(comment.Text, "\n")
for _, line := range commentLines {
fmt.Printf(" %s\n", line)
}
}
}
@@ -605,10 +518,178 @@ var showCmd = &cobra.Command{
// formatShortIssue returns a compact one-line representation of an issue
// Format: <id> [<status>] P<priority> <type>: <title>
// Format: STATUS_ICON ID PRIORITY [Type] Title
func formatShortIssue(issue *types.Issue) string {
return fmt.Sprintf("%s [%s] P%d %s: %s",
issue.ID, issue.Status, issue.Priority, issue.IssueType, issue.Title)
statusIcon := ui.RenderStatusIcon(string(issue.Status))
priorityTag := ui.RenderPriority(issue.Priority)
// Type badge only for notable types
typeBadge := ""
switch issue.IssueType {
case "epic":
typeBadge = ui.TypeEpicStyle.Render("[epic]") + " "
case "bug":
typeBadge = ui.TypeBugStyle.Render("[bug]") + " "
}
// Closed issues: entire line is muted
if issue.Status == types.StatusClosed {
return fmt.Sprintf("%s %s %s %s%s",
statusIcon,
ui.RenderMuted(issue.ID),
ui.RenderMuted(fmt.Sprintf("● P%d", issue.Priority)),
ui.RenderMuted(string(issue.IssueType)),
ui.RenderMuted(" "+issue.Title))
}
return fmt.Sprintf("%s %s %s %s%s", statusIcon, issue.ID, priorityTag, typeBadge, issue.Title)
}
// formatIssueHeader returns the Tufte-aligned header line
// Format: ID · Title [Priority · STATUS]
// All elements in bd show get semantic colors since focus is on one issue
func formatIssueHeader(issue *types.Issue) string {
// Get status icon and style
statusIcon := ui.RenderStatusIcon(string(issue.Status))
statusStyle := ui.GetStatusStyle(string(issue.Status))
statusStr := statusStyle.Render(strings.ToUpper(string(issue.Status)))
// Priority with semantic color (includes ● icon)
priorityTag := ui.RenderPriority(issue.Priority)
// Type badge for notable types
typeBadge := ""
switch issue.IssueType {
case "epic":
typeBadge = " " + ui.TypeEpicStyle.Render("[EPIC]")
case "bug":
typeBadge = " " + ui.TypeBugStyle.Render("[BUG]")
}
// Compaction indicator
tierEmoji := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
case 2:
tierEmoji = " 📦"
}
// Build header: STATUS_ICON ID · Title [Priority · STATUS]
idStyled := ui.RenderAccent(issue.ID)
return fmt.Sprintf("%s %s%s · %s%s [%s · %s]",
statusIcon, idStyled, typeBadge, issue.Title, tierEmoji, priorityTag, statusStr)
}
// formatIssueMetadata returns the metadata line(s) with grouped info
// Format: Owner: user · Type: task
//
// Created: 2026-01-06 · Updated: 2026-01-08
func formatIssueMetadata(issue *types.Issue) string {
var lines []string
// Line 1: Owner/Assignee · Type
metaParts := []string{}
if issue.CreatedBy != "" {
metaParts = append(metaParts, fmt.Sprintf("Owner: %s", issue.CreatedBy))
}
if issue.Assignee != "" {
metaParts = append(metaParts, fmt.Sprintf("Assignee: %s", issue.Assignee))
}
// Type with semantic color
typeStr := string(issue.IssueType)
switch issue.IssueType {
case "epic":
typeStr = ui.TypeEpicStyle.Render("epic")
case "bug":
typeStr = ui.TypeBugStyle.Render("bug")
}
metaParts = append(metaParts, fmt.Sprintf("Type: %s", typeStr))
if len(metaParts) > 0 {
lines = append(lines, strings.Join(metaParts, " · "))
}
// Line 2: Created · Updated · Due/Defer
timeParts := []string{}
timeParts = append(timeParts, fmt.Sprintf("Created: %s", issue.CreatedAt.Format("2006-01-02")))
timeParts = append(timeParts, fmt.Sprintf("Updated: %s", issue.UpdatedAt.Format("2006-01-02")))
if issue.DueAt != nil {
timeParts = append(timeParts, fmt.Sprintf("Due: %s", issue.DueAt.Format("2006-01-02")))
}
if issue.DeferUntil != nil {
timeParts = append(timeParts, fmt.Sprintf("Deferred: %s", issue.DeferUntil.Format("2006-01-02")))
}
if len(timeParts) > 0 {
lines = append(lines, strings.Join(timeParts, " · "))
}
// Line 3: Close reason (if closed)
if issue.Status == types.StatusClosed && issue.CloseReason != "" {
lines = append(lines, ui.RenderMuted(fmt.Sprintf("Close reason: %s", issue.CloseReason)))
}
// Line 4: External ref (if exists)
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
lines = append(lines, fmt.Sprintf("External: %s", *issue.ExternalRef))
}
return strings.Join(lines, "\n")
}
// formatDependencyLine formats a single dependency with semantic colors
// Closed items get entire row muted - the work is done, no need for attention
func formatDependencyLine(prefix string, dep *types.IssueWithDependencyMetadata) string {
// Status icon (always rendered with semantic color)
statusIcon := ui.GetStatusIcon(string(dep.Status))
// Closed items: mute entire row since the work is complete
if dep.Status == types.StatusClosed {
return fmt.Sprintf(" %s %s %s: %s %s",
prefix, statusIcon,
ui.RenderMuted(dep.ID),
ui.RenderMuted(dep.Title),
ui.RenderMuted(fmt.Sprintf("● P%d", dep.Priority)))
}
// Active items: ID with status color, priority with semantic color
style := ui.GetStatusStyle(string(dep.Status))
idStr := style.Render(dep.ID)
priorityTag := ui.RenderPriority(dep.Priority)
// Type indicator for epics/bugs
typeStr := ""
if dep.IssueType == "epic" {
typeStr = ui.TypeEpicStyle.Render("(EPIC)") + " "
} else if dep.IssueType == "bug" {
typeStr = ui.TypeBugStyle.Render("(BUG)") + " "
}
return fmt.Sprintf(" %s %s %s: %s%s %s", prefix, statusIcon, idStr, typeStr, dep.Title, priorityTag)
}
// formatSimpleDependencyLine formats a dependency without metadata (fallback)
// Closed items get entire row muted - the work is done, no need for attention
func formatSimpleDependencyLine(prefix string, dep *types.Issue) string {
statusIcon := ui.GetStatusIcon(string(dep.Status))
// Closed items: mute entire row since the work is complete
if dep.Status == types.StatusClosed {
return fmt.Sprintf(" %s %s %s: %s %s",
prefix, statusIcon,
ui.RenderMuted(dep.ID),
ui.RenderMuted(dep.Title),
ui.RenderMuted(fmt.Sprintf("● P%d", dep.Priority)))
}
// Active items: use semantic colors
style := ui.GetStatusStyle(string(dep.Status))
idStr := style.Render(dep.ID)
priorityTag := ui.RenderPriority(dep.Priority)
return fmt.Sprintf(" %s %s %s: %s %s", prefix, statusIcon, idStr, dep.Title, priorityTag)
}
// showIssueRefs displays issues that reference the given issue(s), grouped by relationship type
@@ -747,13 +828,23 @@ func showIssueRefs(ctx context.Context, args []string, resolvedIDs []string, rou
}
// displayRefGroup displays a group of references with a given type
// Closed items get entire row muted - the work is done, no need for attention
func displayRefGroup(depType types.DependencyType, refs []*types.IssueWithDependencyMetadata) {
// Get emoji for type
emoji := getRefTypeEmoji(depType)
fmt.Printf("\n %s %s (%d):\n", emoji, depType, len(refs))
for _, ref := range refs {
// Color ID based on status
// Closed items: mute entire row since the work is complete
if ref.Status == types.StatusClosed {
fmt.Printf(" %s: %s %s\n",
ui.RenderMuted(ref.ID),
ui.RenderMuted(ref.Title),
ui.RenderMuted(fmt.Sprintf("[P%d - %s]", ref.Priority, ref.Status)))
continue
}
// Active items: color ID based on status
var idStr string
switch ref.Status {
case types.StatusOpen:
@@ -762,8 +853,6 @@ func displayRefGroup(depType types.DependencyType, refs []*types.IssueWithDepend
idStr = ui.StatusInProgressStyle.Render(ref.ID)
case types.StatusBlocked:
idStr = ui.StatusBlockedStyle.Render(ref.ID)
case types.StatusClosed:
idStr = ui.StatusClosedStyle.Render(ref.ID)
default:
idStr = ref.ID
}

View File

@@ -55,8 +55,8 @@ func TestShow_ExternalRef(t *testing.T) {
}
out := string(showOut)
if !strings.Contains(out, "External Ref:") {
t.Errorf("expected 'External Ref:' in output, got: %s", out)
if !strings.Contains(out, "External:") {
t.Errorf("expected 'External:' in output, got: %s", out)
}
if !strings.Contains(out, "https://example.com/spec.md") {
t.Errorf("expected external ref URL in output, got: %s", out)
@@ -108,7 +108,7 @@ func TestShow_NoExternalRef(t *testing.T) {
}
out := string(showOut)
if strings.Contains(out, "External Ref:") {
t.Errorf("expected no 'External Ref:' line for issue without external ref, got: %s", out)
if strings.Contains(out, "External:") {
t.Errorf("expected no 'External:' line for issue without external ref, got: %s", out)
}
}