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 RootID string
} }
var (
graphCompact bool
graphBox bool
graphAll bool
)
var graphCmd = &cobra.Command{ var graphCmd = &cobra.Command{
Use: "graph <issue-id>", Use: "graph [issue-id]",
GroupID: "deps", GroupID: "deps",
Short: "Display issue dependency graph", 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 epics, shows all children and their dependencies.
For regular issues, shows the issue and its direct dependencies. For regular issues, shows the issue and its direct dependencies.
The graph shows execution order left-to-right: With --all, shows all open issues grouped by connected component.
- 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
Colors indicate status: Display formats:
- White: open (ready to work) --box (default) ASCII boxes showing layers, more detailed
- Yellow: in progress --compact Tree format, one line per issue, more scannable
- Red: blocked
- Green: closed`, The graph shows execution order:
Args: cobra.ExactArgs(1), - 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) { Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx ctx := rootCtx
var issueID string
// Resolve the issue ID // Validate args
if daemonClient != nil { if graphAll && len(args) > 0 {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]} fmt.Fprintf(os.Stderr, "Error: cannot specify issue ID with --all flag\n")
resp, err := daemonClient.ResolveID(resolveArgs) os.Exit(1)
if err != nil { }
fmt.Fprintf(os.Stderr, "Error: issue '%s' not found\n", args[0]) if !graphAll && len(args) == 0 {
os.Exit(1) fmt.Fprintf(os.Stderr, "Error: issue ID required (or use --all for all open issues)\n")
}
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")
os.Exit(1) os.Exit(1)
} }
@@ -92,6 +85,66 @@ Colors indicate status:
defer func() { _ = store.Close() }() 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 // Load the subgraph
subgraph, err := loadGraphSubgraph(ctx, store, issueID) subgraph, err := loadGraphSubgraph(ctx, store, issueID)
if err != nil { if err != nil {
@@ -111,12 +164,19 @@ Colors indicate status:
return return
} }
// Render ASCII graph // Render graph - compact tree format or box format (default)
renderGraph(layout, subgraph) if graphCompact {
renderGraphCompact(layout, subgraph)
} else {
renderGraph(layout, subgraph)
}
}, },
} }
func init() { 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 graphCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(graphCmd) rootCmd.AddCommand(graphCmd)
} }
@@ -191,6 +251,157 @@ func loadGraphSubgraph(ctx context.Context, s storage.Storage, issueID string) (
return subgraph, nil 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 // computeLayout assigns layers to nodes using topological sort
func computeLayout(subgraph *TemplateSubgraph) *GraphLayout { func computeLayout(subgraph *TemplateSubgraph) *GraphLayout {
layout := &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)) 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 // 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 { func renderNodeBox(node *GraphNode, width int) string {
// Status indicator
var statusIcon string
var titleStr string
title := truncateTitle(node.Issue.Title, width-4) title := truncateTitle(node.Issue.Title, width-4)
paddedTitle := padRight(title, width-4)
status := string(node.Issue.Status)
switch node.Issue.Status { // Use shared status icon and style
case types.StatusOpen: statusIcon := ui.RenderStatusIcon(status)
statusIcon = "○" style := ui.GetStatusStyle(status)
titleStr = padRight(title, width-4)
case types.StatusInProgress: // Apply style to title for actionable statuses
statusIcon = "◐" var titleStr string
titleStr = ui.RenderWarn(padRight(title, width-4)) if node.Issue.Status == types.StatusOpen {
case types.StatusBlocked: titleStr = paddedTitle // no color for open - available but not urgent
statusIcon = "●" } else {
titleStr = ui.RenderFail(padRight(title, width-4)) titleStr = style.Render(paddedTitle)
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)
} }
id := node.Issue.ID 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 // 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) { func computeDependencyCounts(subgraph *TemplateSubgraph) (blocks map[string]int, blockedBy map[string]int) {
blocks = make(map[string]int) blocks = make(map[string]int)
blockedBy = make(map[string]int) blockedBy = make(map[string]int)
@@ -446,61 +780,76 @@ func computeDependencyCounts(subgraph *TemplateSubgraph) (blocks map[string]int,
return blocks, blockedBy return blocks, blockedBy
} }
rootID := ""
if subgraph.Root != nil {
rootID = subgraph.Root.ID
}
for _, dep := range subgraph.Dependencies { for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks { // Only count "blocks" dependencies (not parent-child, related, etc.)
// dep.DependsOnID blocks dep.IssueID if dep.Type != types.DepBlocks {
// So dep.DependsOnID "blocks" count increases continue
blocks[dep.DependsOnID]++
// And dep.IssueID "blocked by" count increases
blockedBy[dep.IssueID]++
} }
// 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 return blocks, blockedBy
} }
// renderNodeBoxWithDeps renders a node box with dependency information // 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 { 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) title := truncateTitle(node.Issue.Title, width-4)
paddedTitle := padRight(title, width-4)
status := string(node.Issue.Status)
switch node.Issue.Status { // Use shared status icon and style from ui package
case types.StatusOpen: statusIcon := ui.RenderStatusIcon(status)
statusIcon = "○" style := ui.GetStatusStyle(status)
titleStr = padRight(title, width-4)
case types.StatusInProgress: // Apply style to title for actionable statuses
statusIcon = "◐" var titleStr string
titleStr = ui.RenderWarn(padRight(title, width-4)) if node.Issue.Status == types.StatusOpen {
case types.StatusBlocked: titleStr = paddedTitle // no color for open - available but not urgent
statusIcon = "●" } else {
titleStr = ui.RenderFail(padRight(title, width-4)) titleStr = style.Render(paddedTitle)
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)
} }
id := node.Issue.ID id := node.Issue.ID
// Build dependency info string // Build dependency info string - only show if meaningful counts exist
var depInfo string // Note: we build the plain text version first for padding, then apply colors
var depInfoPlain string
var depInfoStyled string
if blocksCount > 0 || blockedByCount > 0 { if blocksCount > 0 || blockedByCount > 0 {
parts := []string{} plainParts := []string{}
styledParts := []string{}
if blocksCount > 0 { 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 { 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 // 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))) idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2)))
var result string var result string
if depInfo != "" { if depInfoPlain != "" {
depLine := fmt.Sprintf(" │ %s │", ui.RenderAccent(padRight(depInfo, width-2))) // 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) + "┘" bottom := " └" + strings.Repeat("─", width) + "┘"
result = topBottom + "\n" + middle + "\n" + idLine + "\n" + depLine + "\n" + bottom result = topBottom + "\n" + middle + "\n" + idLine + "\n" + depLine + "\n" + bottom
} else { } else {

View File

@@ -20,6 +20,7 @@ import (
"github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/timeparsing" "github.com/steveyegge/beads/internal/timeparsing"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/ui"
@@ -42,76 +43,131 @@ func pinIndicator(issue *types.Issue) string {
return "" return ""
} }
// Priority symbols for pretty output (GH#654) // Priority tags for pretty output - simple text, semantic colors applied via ui package
var prioritySymbols = map[int]string{ // Design principle: only P0/P1 get color for attention, P2-P4 are neutral
0: "🔴", // P0 - Critical func renderPriorityTag(priority int) string {
1: "🟠", // P1 - High return ui.RenderPriority(priority)
2: "🟡", // P2 - Medium (default)
3: "🔵", // P3 - Low
4: "⚪", // P4 - Lowest
} }
// Status symbols for pretty output (GH#654) // renderStatusIcon returns the status icon with semantic coloring applied
var statusSymbols = map[types.Status]string{ // Delegates to the shared ui.RenderStatusIcon for consistency across commands
"open": "○", func renderStatusIcon(status types.Status) string {
"in_progress": "◐", return ui.RenderStatusIcon(string(status))
"blocked": "⊗",
"deferred": "◇",
"closed": "●",
} }
// formatPrettyIssue formats a single issue for pretty output // 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 { func formatPrettyIssue(issue *types.Issue) string {
prioritySym := prioritySymbols[issue.Priority] // Use shared helpers from ui package
if prioritySym == "" { statusIcon := ui.RenderStatusIcon(string(issue.Status))
prioritySym = "⚪" priorityTag := renderPriorityTag(issue.Priority)
}
statusSym := statusSymbols[issue.Status]
if statusSym == "" {
statusSym = "○"
}
// Type badge - only show for notable types
typeBadge := "" typeBadge := ""
switch issue.IssueType { switch issue.IssueType {
case "epic": case "epic":
typeBadge = "[EPIC] " typeBadge = ui.TypeEpicStyle.Render("[epic]") + " "
case "feature":
typeBadge = "[FEAT] "
case "bug": 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 // 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) { 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) issueMap := make(map[string]*types.Issue)
childrenMap = 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 { for _, issue := range issues {
issueMap[issue.ID] = issue 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 { 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, ".") { if strings.Contains(issue.ID, ".") {
parts := strings.Split(issue.ID, ".") parts := strings.Split(issue.ID, ".")
parentID := strings.Join(parts[:len(parts)-1], ".") parentID := strings.Join(parts[:len(parts)-1], ".")
if _, exists := issueMap[parentID]; exists { if _, exists := issueMap[parentID]; exists {
childrenMap[parentID] = append(childrenMap[parentID], issue) childrenMap[parentID] = append(childrenMap[parentID], issue)
isChild[issue.ID] = true
continue 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 return roots, childrenMap
} }
// printPrettyTree recursively prints the issue tree // 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) { func printPrettyTree(childrenMap map[string][]*types.Issue, parentID string, prefix string) {
children := childrenMap[parentID] 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 { for i, child := range children {
isLast := i == len(children)-1 isLast := i == len(children)-1
connector := "├── " connector := "├── "
@@ -129,7 +185,13 @@ func printPrettyTree(childrenMap map[string][]*types.Issue, parentID string, pre
} }
// displayPrettyList displays issues in pretty tree format (GH#654) // displayPrettyList displays issues in pretty tree format (GH#654)
// Uses buildIssueTree which only supports dotted ID hierarchy
func displayPrettyList(issues []*types.Issue, showHeader bool) { 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 { if showHeader {
// Clear screen and show header // Clear screen and show header
fmt.Print("\033[2J\033[H") fmt.Print("\033[2J\033[H")
@@ -144,14 +206,11 @@ func displayPrettyList(issues []*types.Issue, showHeader bool) {
return return
} }
roots, childrenMap := buildIssueTree(issues) roots, childrenMap := buildIssueTreeWithDeps(issues, allDeps)
for i, issue := range roots { for _, issue := range roots {
fmt.Println(formatPrettyIssue(issue)) fmt.Println(formatPrettyIssue(issue))
printPrettyTree(childrenMap, issue.ID, "") printPrettyTree(childrenMap, issue.ID, "")
if i < len(roots)-1 {
fmt.Println()
}
} }
// Summary // 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.Printf("Total: %d issues (%d open, %d in progress)\n", len(issues), openCount, inProgressCount)
fmt.Println() 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) // 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 // 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) { func formatIssueCompact(buf *strings.Builder, issue *types.Issue, labels []string) {
labelsStr := "" labelsStr := ""
if len(labels) > 0 { if len(labels) > 0 {
@@ -339,20 +400,25 @@ func formatIssueCompact(buf *strings.Builder, issue *types.Issue, labels []strin
if issue.Assignee != "" { if issue.Assignee != "" {
assigneeStr = fmt.Sprintf(" @%s", issue.Assignee) assigneeStr = fmt.Sprintf(" @%s", issue.Assignee)
} }
status := string(issue.Status)
if status == "closed" { // Get styled status icon
line := fmt.Sprintf("%s%s [P%d] [%s] %s%s%s - %s", statusIcon := renderStatusIcon(issue.Status)
pinIndicator(issue), issue.ID, issue.Priority,
issue.IssueType, status, assigneeStr, labelsStr, issue.Title) 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(ui.RenderClosedLine(line))
buf.WriteString("\n") buf.WriteString("\n")
} else { } 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), pinIndicator(issue),
ui.RenderID(issue.ID), ui.RenderID(issue.ID),
ui.RenderPriority(issue.Priority), ui.RenderPriority(issue.Priority),
ui.RenderType(string(issue.IssueType)), ui.RenderType(string(issue.IssueType)),
ui.RenderStatus(status),
assigneeStr, labelsStr, issue.Title)) assigneeStr, labelsStr, issue.Title))
} }
} }
@@ -437,6 +503,8 @@ var listCmd = &cobra.Command{
// Pretty and watch flags (GH#654) // Pretty and watch flags (GH#654)
prettyFormat, _ := cmd.Flags().GetBool("pretty") prettyFormat, _ := cmd.Flags().GetBool("pretty")
treeFormat, _ := cmd.Flags().GetBool("tree")
prettyFormat = prettyFormat || treeFormat // --tree is alias for --pretty
watchMode, _ := cmd.Flags().GetBool("watch") watchMode, _ := cmd.Flags().GetBool("watch")
// Pager control (bd-jdz3) // Pager control (bd-jdz3)
@@ -824,6 +892,33 @@ var listCmd = &cobra.Command{
// Apply sorting // Apply sorting
sortIssues(issues, sortBy, reverse) 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) // Build output in buffer for pager support (bd-jdz3)
var buf strings.Builder var buf strings.Builder
if ui.IsAgentMode() { if ui.IsAgentMode() {
@@ -891,7 +986,9 @@ var listCmd = &cobra.Command{
// Handle pretty format (GH#654) // Handle pretty format (GH#654)
if prettyFormat { 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) // Show truncation hint if we hit the limit (GH#788)
if effectiveLimit > 0 && len(issues) == effectiveLimit { if effectiveLimit > 0 && len(issues) == effectiveLimit {
fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", 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) // Pretty and watch flags (GH#654)
listCmd.Flags().Bool("pretty", false, "Display issues in a tree format with status/priority symbols") 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)") listCmd.Flags().BoolP("watch", "w", false, "Watch for changes and auto-update display (implies --pretty)")
// Pager control (bd-jdz3) // 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") { if !strings.Contains(out, "bd-1") || !strings.Contains(out, "Hello") {
t.Fatalf("unexpected output: %q", out) t.Fatalf("unexpected output: %q", out)
} }
if !strings.Contains(out, "[BUG]") { if !strings.Contains(out, "[bug]") {
t.Fatalf("expected BUG badge: %q", out) t.Fatalf("expected bug badge: %q", out)
} }
} }

View File

@@ -131,14 +131,14 @@ var showCmd = &cobra.Command{
allDetails = append(allDetails, details) allDetails = append(allDetails, details)
} else { } else {
if displayIdx > 0 { 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) // Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
fmt.Printf("Status: %s\n", issue.Status) fmt.Printf("\n%s\n", formatIssueHeader(issue))
fmt.Printf("Priority: P%d\n", issue.Priority) // Metadata: Owner · Type | Created · Updated
fmt.Printf("Type: %s\n", issue.IssueType) fmt.Println(formatIssueMetadata(issue))
if issue.Description != "" { 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() fmt.Println()
displayIdx++ displayIdx++
@@ -188,54 +188,17 @@ var showCmd = &cobra.Command{
} }
if displayIdx > 0 { if displayIdx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60)) fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
} }
displayIdx++ displayIdx++
// Format output (same as direct mode below) // Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
tierEmoji := "" fmt.Printf("\n%s\n", formatIssueHeader(issue))
statusSuffix := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
statusSuffix = " (compacted L1)"
case 2:
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
fmt.Printf("\n%s: %s%s\n", ui.RenderAccent(issue.ID), issue.Title, tierEmoji) // Metadata: Owner · Type | Created · Updated
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) fmt.Println(formatIssueMetadata(issue))
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)
}
// Show compaction status // Compaction info (if applicable)
if issue.CompactionLevel > 0 { if issue.CompactionLevel > 0 {
fmt.Println() fmt.Println()
if issue.OriginalSize > 0 { if issue.OriginalSize > 0 {
@@ -243,47 +206,40 @@ var showCmd = &cobra.Command{
saved := issue.OriginalSize - currentSize saved := issue.OriginalSize - currentSize
if saved > 0 { if saved > 0 {
reduction := float64(saved) / float64(issue.OriginalSize) * 100 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) 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 != "" { 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 != "" { 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 != "" { 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 != "" { 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 { 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 { 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 { 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 { if len(details.Dependents) > 0 {
// Group by dependency type for clarity
var blocks, children, related, discovered []*types.IssueWithDependencyMetadata var blocks, children, related, discovered []*types.IssueWithDependencyMetadata
for _, dep := range details.Dependents { for _, dep := range details.Dependents {
switch dep.DependencyType { switch dep.DependencyType {
@@ -301,35 +257,35 @@ var showCmd = &cobra.Command{
} }
if len(children) > 0 { if len(children) > 0 {
fmt.Printf("\nChildren (%d):\n", len(children)) fmt.Printf("\n%s\n", ui.RenderBold("CHILDREN"))
for _, dep := range 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 { if len(blocks) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(blocks)) fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range 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 { if len(related) > 0 {
fmt.Printf("\nRelated (%d):\n", len(related)) fmt.Printf("\n%s\n", ui.RenderBold("RELATED"))
for _, dep := range 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 { if len(discovered) > 0 {
fmt.Printf("\nDiscovered (%d):\n", len(discovered)) fmt.Printf("\n%s\n", ui.RenderBold("DISCOVERED"))
for _, dep := range 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 { 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 { 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") commentLines := strings.Split(comment.Text, "\n")
for _, line := range commentLines { for _, line := range commentLines {
fmt.Printf(" %s\n", line) fmt.Printf(" %s\n", line)
@@ -418,102 +374,55 @@ var showCmd = &cobra.Command{
} }
if idx > 0 { if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60)) fmt.Println("\n" + ui.RenderMuted(strings.Repeat("─", 60)))
} }
// Add compaction emoji to title line // Tufte-aligned header: STATUS_ICON ID · Title [Priority · STATUS]
tierEmoji := "" fmt.Printf("\n%s\n", formatIssueHeader(issue))
statusSuffix := ""
switch issue.CompactionLevel {
case 1:
tierEmoji = " 🗜️"
statusSuffix = " (compacted L1)"
case 2:
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
fmt.Printf("\n%s: %s%s\n", ui.RenderAccent(issue.ID), issue.Title, tierEmoji) // Metadata: Owner · Type | Created · Updated
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) fmt.Println(formatIssueMetadata(issue))
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)
}
// Show compaction status footer // Compaction info (if applicable)
if issue.CompactionLevel > 0 { if issue.CompactionLevel > 0 {
tierEmoji := "🗜️"
if issue.CompactionLevel == 2 {
tierEmoji = "📦"
}
tierName := fmt.Sprintf("Tier %d", issue.CompactionLevel)
fmt.Println() fmt.Println()
if issue.OriginalSize > 0 { if issue.OriginalSize > 0 {
currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria) currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
saved := issue.OriginalSize - currentSize saved := issue.OriginalSize - currentSize
if saved > 0 { if saved > 0 {
reduction := float64(saved) / float64(issue.OriginalSize) * 100 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) 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 != "" { 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 != "" { 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 != "" { 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 != "" { 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 // Show labels
labels, _ := issueStore.GetLabels(ctx, issue.ID) labels, _ := issueStore.GetLabels(ctx, issue.ID)
if len(labels) > 0 { 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) deps, _ := issueStore.GetDependencies(ctx, issue.ID)
if len(deps) > 0 { 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 { 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 { if len(children) > 0 {
fmt.Printf("\nChildren (%d):\n", len(children)) fmt.Printf("\n%s\n", ui.RenderBold("CHILDREN"))
for _, dep := range 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 { if len(blocks) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(blocks)) fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range 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 { if len(related) > 0 {
fmt.Printf("\nRelated (%d):\n", len(related)) fmt.Printf("\n%s\n", ui.RenderBold("RELATED"))
for _, dep := range 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 { if len(discovered) > 0 {
fmt.Printf("\nDiscovered (%d):\n", len(discovered)) fmt.Printf("\n%s\n", ui.RenderBold("DISCOVERED"))
for _, dep := range 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 // Fallback for non-SQLite storage
dependents, _ := issueStore.GetDependents(ctx, issue.ID) dependents, _ := issueStore.GetDependents(ctx, issue.ID)
if len(dependents) > 0 { if len(dependents) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(dependents)) fmt.Printf("\n%s\n", ui.RenderBold("BLOCKS"))
for _, dep := range dependents { 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 // Show comments
comments, _ := issueStore.GetIssueComments(ctx, issue.ID) comments, _ := issueStore.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 { if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments)) fmt.Printf("\n%s\n", ui.RenderBold("COMMENTS"))
for _, comment := range 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 // 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 { func formatShortIssue(issue *types.Issue) string {
return fmt.Sprintf("%s [%s] P%d %s: %s", statusIcon := ui.RenderStatusIcon(string(issue.Status))
issue.ID, issue.Status, issue.Priority, issue.IssueType, issue.Title) 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 // 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 // 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) { func displayRefGroup(depType types.DependencyType, refs []*types.IssueWithDependencyMetadata) {
// Get emoji for type // Get emoji for type
emoji := getRefTypeEmoji(depType) emoji := getRefTypeEmoji(depType)
fmt.Printf("\n %s %s (%d):\n", emoji, depType, len(refs)) fmt.Printf("\n %s %s (%d):\n", emoji, depType, len(refs))
for _, ref := range 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 var idStr string
switch ref.Status { switch ref.Status {
case types.StatusOpen: case types.StatusOpen:
@@ -762,8 +853,6 @@ func displayRefGroup(depType types.DependencyType, refs []*types.IssueWithDepend
idStr = ui.StatusInProgressStyle.Render(ref.ID) idStr = ui.StatusInProgressStyle.Render(ref.ID)
case types.StatusBlocked: case types.StatusBlocked:
idStr = ui.StatusBlockedStyle.Render(ref.ID) idStr = ui.StatusBlockedStyle.Render(ref.ID)
case types.StatusClosed:
idStr = ui.StatusClosedStyle.Render(ref.ID)
default: default:
idStr = ref.ID idStr = ref.ID
} }

View File

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

View File

@@ -9,7 +9,7 @@ pkgs.buildGoModule {
subPackages = [ "cmd/bd" ]; subPackages = [ "cmd/bd" ];
doCheck = false; doCheck = false;
# Go module dependencies hash - if build fails with hash mismatch, update with the "got:" value # 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 # Git is required for tests
nativeBuildInputs = [ pkgs.git ]; nativeBuildInputs = [ pkgs.git ];

13
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/charmbracelet/huh v0.8.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/fsnotify/fsnotify v1.9.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/ncruces/go-sqlite3 v0.30.4 github.com/ncruces/go-sqlite3 v0.30.4
@@ -26,29 +26,37 @@ require (
require ( require (
github.com/AlekSi/pointer v1.0.0 // indirect 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/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // 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/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // 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/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/flock v0.13.0 // indirect github.com/gofrs/flock v0.13.0 // indirect
github.com/google/go-cmp v0.7.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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // 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/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // 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/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.8.1 // 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/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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 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/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect golang.org/x/tools v0.39.0 // indirect

27
go.sum
View File

@@ -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/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 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 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 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE=
github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 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-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 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 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 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 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/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 h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 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 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 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 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 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 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 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/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 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/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 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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= 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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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-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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 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 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

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

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

View File

@@ -12,9 +12,12 @@ import (
) )
func init() { func init() {
// Disable colors when not appropriate (non-TTY, NO_COLOR, etc.)
if !ShouldUseColor() { if !ShouldUseColor() {
// Disable colors when not appropriate (non-TTY, NO_COLOR, etc.)
lipgloss.SetColorProfile(termenv.Ascii) 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 === // === 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{ ColorPriorityP0 = lipgloss.AdaptiveColor{
Light: "#f07171", // bright red - critical Light: "#f07171", // bright red - critical, demands attention
Dark: "#f07178", Dark: "#f07178",
} }
ColorPriorityP1 = lipgloss.AdaptiveColor{ ColorPriorityP1 = lipgloss.AdaptiveColor{
Light: "#ff8f40", // orange - high urgency Light: "#ff8f40", // orange - high priority, needs attention soon
Dark: "#ff8f40", Dark: "#ff8f40",
} }
ColorPriorityP2 = lipgloss.AdaptiveColor{ ColorPriorityP2 = lipgloss.AdaptiveColor{
Light: "", // standard text color Light: "#e6b450", // muted gold - medium priority, visible but calm
Dark: "", Dark: "#e6b450",
} }
ColorPriorityP3 = lipgloss.AdaptiveColor{ ColorPriorityP3 = lipgloss.AdaptiveColor{
Light: "", // standard text color Light: "", // neutral - low priority
Dark: "", Dark: "",
} }
ColorPriorityP4 = lipgloss.AdaptiveColor{ ColorPriorityP4 = lipgloss.AdaptiveColor{
Light: "", // standard text color Light: "", // neutral - backlog
Dark: "", Dark: "",
} }
@@ -199,6 +203,87 @@ const (
IconInfo = "" 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 // Tree characters for hierarchical display
const ( const (
TreeChild = "⎿ " // child indicator TreeChild = "⎿ " // child indicator
@@ -299,8 +384,29 @@ func RenderStatus(status string) string {
} }
// RenderPriority renders a priority level with semantic styling // RenderPriority renders a priority level with semantic styling
// Format: ● P0 (icon + label)
// P0/P1 get color; P2/P3/P4 use standard text // P0/P1 get color; P2/P3/P4 use standard text
func RenderPriority(priority int) string { 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) label := fmt.Sprintf("P%d", priority)
switch priority { switch priority {
case 0: case 0:

View File

@@ -57,16 +57,17 @@ func TestRenderStatusAndPriority(t *testing.T) {
} }
} }
// RenderPriority now includes the priority icon (●)
priorityCases := []struct { priorityCases := []struct {
priority int priority int
want string want string
}{ }{
{0, PriorityP0Style.Render("P0")}, {0, PriorityP0Style.Render(PriorityIcon + " P0")},
{1, PriorityP1Style.Render("P1")}, {1, PriorityP1Style.Render(PriorityIcon + " P1")},
{2, PriorityP2Style.Render("P2")}, {2, PriorityP2Style.Render(PriorityIcon + " P2")},
{3, PriorityP3Style.Render("P3")}, {3, PriorityP3Style.Render(PriorityIcon + " P3")},
{4, PriorityP4Style.Render("P4")}, {4, PriorityP4Style.Render(PriorityIcon + " P4")},
{5, "P5"}, {5, PriorityIcon + " P5"},
} }
for _, tc := range priorityCases { for _, tc := range priorityCases {
if got := RenderPriority(tc.priority); got != tc.want { 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" { if got := RenderPriorityForStatus(0, "closed"); got != "P0" {
t.Fatalf("closed priority should be plain text, got %q", got) t.Fatalf("closed priority should be plain text, got %q", got)
} }