From cfd1f39e1ee83c6240dddbf8e5702be8b611055e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Thu, 8 Jan 2026 20:49:09 -0800 Subject: [PATCH] feat(ux): visual improvements for list tree, graph, and show commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/graph.go | 546 +++++++++++++++++++++++++++++------- cmd/bd/list.go | 184 +++++++++--- cmd/bd/list_helpers_test.go | 4 +- cmd/bd/show.go | 405 +++++++++++++++----------- cmd/bd/show_test.go | 8 +- default.nix | 2 +- go.mod | 13 +- go.sum | 27 ++ internal/ui/markdown.go | 54 ++++ internal/ui/styles.go | 122 +++++++- internal/ui/styles_test.go | 18 +- 11 files changed, 1064 insertions(+), 319 deletions(-) create mode 100644 internal/ui/markdown.go diff --git a/cmd/bd/graph.go b/cmd/bd/graph.go index 6fb23cdc..2fa93fd2 100644 --- a/cmd/bd/graph.go +++ b/cmd/bd/graph.go @@ -33,51 +33,44 @@ type GraphLayout struct { RootID string } +var ( + graphCompact bool + graphBox bool + graphAll bool +) + var graphCmd = &cobra.Command{ - Use: "graph ", + 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 { diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 0127e585..a5fa899c 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -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) diff --git a/cmd/bd/list_helpers_test.go b/cmd/bd/list_helpers_test.go index d2725391..9556ab6c 100644 --- a/cmd/bd/list_helpers_test.go +++ b/cmd/bd/list_helpers_test.go @@ -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) } } diff --git a/cmd/bd/show.go b/cmd/bd/show.go index cebb84c4..106c563f 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -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: [] P : +// 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 } diff --git a/cmd/bd/show_test.go b/cmd/bd/show_test.go index 10a1ac31..61000b67 100644 --- a/cmd/bd/show_test.go +++ b/cmd/bd/show_test.go @@ -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) } } diff --git a/default.nix b/default.nix index fd3755cd..03f213c5 100644 --- a/default.nix +++ b/default.nix @@ -9,7 +9,7 @@ pkgs.buildGoModule { subPackages = [ "cmd/bd" ]; doCheck = false; # Go module dependencies hash - if build fails with hash mismatch, update with the "got:" value - vendorHash = "sha256-l3ctY2hGXskM8U1wLupyvFDWfJu8nCX5tWAH1Macavw="; + vendorHash = "sha256-pY5m5ODRgqghyELRwwxOr+xlW41gtJWLXaW53GlLaFw="; # Git is required for tests nativeBuildInputs = [ pkgs.git ]; diff --git a/go.mod b/go.mod index 21802961..f1277ecf 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/charmbracelet/huh v0.8.0 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/fsnotify/fsnotify v1.9.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.30.4 @@ -26,29 +26,37 @@ require ( require ( github.com/AlekSi/pointer v1.0.0 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.8.1 // indirect @@ -64,7 +72,10 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.39.0 // indirect diff --git a/go.sum b/go.sum index 4d27e722..2b521cfc 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -12,6 +14,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= @@ -20,10 +24,14 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= @@ -34,6 +42,8 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= @@ -47,6 +57,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -61,6 +73,8 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -73,14 +87,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA= @@ -95,6 +114,7 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -134,12 +154,19 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/ui/markdown.go b/internal/ui/markdown.go new file mode 100644 index 00000000..0865af7b --- /dev/null +++ b/internal/ui/markdown.go @@ -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 +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 5f923fc9..493b6104 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -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: diff --git a/internal/ui/styles_test.go b/internal/ui/styles_test.go index e065ef61..8a5f39b3 100644 --- a/internal/ui/styles_test.go +++ b/internal/ui/styles_test.go @@ -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) }