diff --git a/cmd/bd/autoflush.go b/cmd/bd/autoflush.go index 8a839477..39f37d3b 100644 --- a/cmd/bd/autoflush.go +++ b/cmd/bd/autoflush.go @@ -14,11 +14,11 @@ import ( "strings" "time" - "github.com/fatih/color" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) @@ -566,10 +566,9 @@ func flushToJSONLWithState(state flushState) { // Show prominent warning after 3+ consecutive failures if failCount >= 3 { - red := color.New(color.FgRed, color.Bold).SprintFunc() - fmt.Fprintf(os.Stderr, "\n%s\n", red("⚠️ CRITICAL: Auto-flush has failed "+fmt.Sprint(failCount)+" times consecutively!")) - fmt.Fprintf(os.Stderr, "%s\n", red("⚠️ Your JSONL file may be out of sync with the database.")) - fmt.Fprintf(os.Stderr, "%s\n\n", red("⚠️ Run 'bd export -o .beads/issues.jsonl' manually to fix.")) + fmt.Fprintf(os.Stderr, "\n%s\n", ui.RenderFail("⚠️ CRITICAL: Auto-flush has failed "+fmt.Sprint(failCount)+" times consecutively!")) + fmt.Fprintf(os.Stderr, "%s\n", ui.RenderFail("⚠️ Your JSONL file may be out of sync with the database.")) + fmt.Fprintf(os.Stderr, "%s\n\n", ui.RenderFail("⚠️ Run 'bd export -o .beads/issues.jsonl' manually to fix.")) } return } @@ -601,10 +600,9 @@ func flushToJSONLWithState(state flushState) { // Show prominent warning after 3+ consecutive failures if failCount >= 3 { - red := color.New(color.FgRed, color.Bold).SprintFunc() - fmt.Fprintf(os.Stderr, "\n%s\n", red("⚠️ CRITICAL: Auto-flush has failed "+fmt.Sprint(failCount)+" times consecutively!")) - fmt.Fprintf(os.Stderr, "%s\n", red("⚠️ Your JSONL file may be out of sync with the database.")) - fmt.Fprintf(os.Stderr, "%s\n\n", red("⚠️ Run 'bd export -o .beads/issues.jsonl' manually to fix.")) + fmt.Fprintf(os.Stderr, "\n%s\n", ui.RenderFail("⚠️ CRITICAL: Auto-flush has failed "+fmt.Sprint(failCount)+" times consecutively!")) + fmt.Fprintf(os.Stderr, "%s\n", ui.RenderFail("⚠️ Your JSONL file may be out of sync with the database.")) + fmt.Fprintf(os.Stderr, "%s\n\n", ui.RenderFail("⚠️ Run 'bd export -o .beads/issues.jsonl' manually to fix.")) } } diff --git a/cmd/bd/clean.go b/cmd/bd/clean.go index 681830cc..142dc7f5 100644 --- a/cmd/bd/clean.go +++ b/cmd/bd/clean.go @@ -7,13 +7,15 @@ import ( "path/filepath" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/ui" ) +// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX var cleanCmd = &cobra.Command{ - Use: "clean", - Short: "Clean up temporary git merge artifacts from .beads directory", + Use: "clean", + GroupID: "maint", + Short: "Clean up temporary git merge artifacts from .beads directory", Long: `Delete temporary git merge artifacts from the .beads directory. This command removes temporary files created during git merges and conflicts. @@ -76,7 +78,7 @@ SEE ALSO: // Just run by default, no --force needed if dryRun { - fmt.Println(color.YellowString("DRY RUN - no changes will be made")) + fmt.Println(ui.RenderWarn("DRY RUN - no changes will be made")) } fmt.Printf("Found %d file(s) to clean:\n", len(filesToDelete)) for _, file := range filesToDelete { diff --git a/cmd/bd/cleanup.go b/cmd/bd/cleanup.go index b3c4d911..33682b07 100644 --- a/cmd/bd/cleanup.go +++ b/cmd/bd/cleanup.go @@ -6,16 +6,18 @@ import ( "os" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) // Hard delete mode: bypass tombstone TTL safety, use --older-than days directly +// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX var cleanupCmd = &cobra.Command{ - Use: "cleanup", - Short: "Delete closed issues and prune expired tombstones", + Use: "cleanup", + GroupID: "maint", + Short: "Delete closed issues and prune expired tombstones", Long: `Delete closed issues and prune expired tombstones to reduce database size. This command: @@ -82,7 +84,7 @@ SEE ALSO: customTTL = -1 } if !jsonOutput && !dryRun { - fmt.Println(color.YellowString("⚠️ HARD DELETE MODE: Bypassing tombstone TTL safety")) + fmt.Println(ui.RenderWarn("⚠️ HARD DELETE MODE: Bypassing tombstone TTL safety")) } } @@ -197,7 +199,7 @@ SEE ALSO: fmt.Printf("Found %d %s issue(s)\n", len(closedIssues), issueType) } if dryRun { - fmt.Println(color.YellowString("DRY RUN - no changes will be made")) + fmt.Println(ui.RenderWarn("DRY RUN - no changes will be made")) } fmt.Println() } @@ -235,13 +237,12 @@ SEE ALSO: } } else if tombstoneResult != nil && tombstoneResult.PrunedCount > 0 { if !jsonOutput { - green := color.New(color.FgGreen).SprintFunc() ttlMsg := fmt.Sprintf("older than %d days", tombstoneResult.TTLDays) if hardDelete && olderThanDays == 0 { ttlMsg = "all tombstones (--hard mode)" } fmt.Printf("\n%s Pruned %d expired tombstone(s) (%s)\n", - green("✓"), tombstoneResult.PrunedCount, ttlMsg) + ui.RenderPass("✓"), tombstoneResult.PrunedCount, ttlMsg) } } } diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 001f99c1..26d2f544 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -6,7 +6,6 @@ import ( "os" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/debug" @@ -14,11 +13,13 @@ import ( "github.com/steveyegge/beads/internal/routing" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/validation" ) var createCmd = &cobra.Command{ Use: "create [title]", + GroupID: "issues", Aliases: []string{"new"}, Short: "Create a new issue (or multiple issues from markdown file)", Args: cobra.MinimumNArgs(0), // Changed to allow no args when using -f @@ -59,8 +60,7 @@ var createCmd = &cobra.Command{ // Warn if creating a test issue in production database (unless silent mode) if strings.HasPrefix(strings.ToLower(title), "test") && !silent && !debug.IsQuiet() { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Fprintf(os.Stderr, "%s Creating issue with 'Test' prefix in production database.\n", yellow("⚠")) + fmt.Fprintf(os.Stderr, "%s Creating issue with 'Test' prefix in production database.\n", ui.RenderWarn("⚠")) fmt.Fprintf(os.Stderr, " For testing, consider using: BEADS_DB=/tmp/test.db ./bd create \"Test issue\"\n") } @@ -74,8 +74,7 @@ var createCmd = &cobra.Command{ } // Warn if creating an issue without a description (unless silent mode) if !silent && !debug.IsQuiet() { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Fprintf(os.Stderr, "%s Creating issue without description.\n", yellow("⚠")) + fmt.Fprintf(os.Stderr, "%s Creating issue without description.\n", ui.RenderWarn("⚠")) fmt.Fprintf(os.Stderr, " Issues without descriptions lack context for future work.\n") fmt.Fprintf(os.Stderr, " Consider adding --description=\"Why this issue exists and what needs to be done\"\n") } @@ -241,8 +240,7 @@ var createCmd = &cobra.Command{ } else if silent { fmt.Println(issue.ID) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID) + fmt.Printf("%s Created issue: %s\n", ui.RenderPass("✓"), issue.ID) fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Priority: P%d\n", issue.Priority) fmt.Printf(" Status: %s\n", issue.Status) @@ -381,8 +379,7 @@ var createCmd = &cobra.Command{ } else if silent { fmt.Println(issue.ID) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID) + fmt.Printf("%s Created issue: %s\n", ui.RenderPass("✓"), issue.ID) fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Priority: P%d\n", issue.Priority) fmt.Printf(" Status: %s\n", issue.Status) diff --git a/cmd/bd/create_form.go b/cmd/bd/create_form.go index a13c4002..d246400d 100644 --- a/cmd/bd/create_form.go +++ b/cmd/bd/create_form.go @@ -9,11 +9,11 @@ import ( "strings" "github.com/charmbracelet/huh" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) // createFormRawInput holds the raw string values from the form UI. @@ -198,8 +198,9 @@ func CreateIssueFromFormValues(ctx context.Context, s storage.Storage, fv *creat } var createFormCmd = &cobra.Command{ - Use: "create-form", - Short: "Create a new issue using an interactive form", + Use: "create-form", + GroupID: "issues", + Short: "Create a new issue using an interactive form", Long: `Create a new issue using an interactive terminal form. This command provides a user-friendly form interface for creating issues, @@ -388,8 +389,7 @@ func runCreateForm(cmd *cobra.Command) { } func printCreatedIssue(issue *types.Issue) { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s Created issue: %s\n", green("✓"), issue.ID) + fmt.Printf("\n%s Created issue: %s\n", ui.RenderPass("✓"), issue.ID) fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Type: %s\n", issue.IssueType) fmt.Printf(" Priority: P%d\n", issue.Priority) diff --git a/cmd/bd/delete.go b/cmd/bd/delete.go index 3435390d..fd3f2597 100644 --- a/cmd/bd/delete.go +++ b/cmd/bd/delete.go @@ -10,11 +10,11 @@ import ( "regexp" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) // deleteViaDaemon uses the RPC daemon to delete issues @@ -62,19 +62,17 @@ func deleteViaDaemon(issueIDs []string, force, dryRun, cascade bool, jsonOutput deletedCount := int(result["deleted_count"].(float64)) totalCount := int(result["total_count"].(float64)) - - green := color.New(color.FgGreen).SprintFunc() + if deletedCount > 0 { if deletedCount == 1 { - fmt.Printf("%s Deleted %s\n", green("✓"), issueIDs[0]) + fmt.Printf("%s Deleted %s\n", ui.RenderPass("✓"), issueIDs[0]) } else { - fmt.Printf("%s Deleted %d issue(s)\n", green("✓"), deletedCount) + fmt.Printf("%s Deleted %d issue(s)\n", ui.RenderPass("✓"), deletedCount) } } - + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s Warnings:\n", yellow("⚠")) + fmt.Printf("\n%s Warnings:\n", ui.RenderWarn("⚠")) for _, e := range errors { fmt.Printf(" %s\n", e) } @@ -85,8 +83,9 @@ func deleteViaDaemon(issueIDs []string, force, dryRun, cascade bool, jsonOutput } var deleteCmd = &cobra.Command{ - Use: "delete [issue-id...]", - Short: "Delete one or more issues and clean up references", + Use: "delete [issue-id...]", + GroupID: "issues", + Short: "Delete one or more issues and clean up references", Long: `Delete one or more issues and clean up all references to them. This command will: 1. Remove all dependency links (any type, both directions) involving the issues @@ -215,9 +214,7 @@ the issues will not resurrect from remote branches.`, replacementText := `$1[deleted:` + issueID + `]$3` // Preview mode if !force { - red := color.New(color.FgRed).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW")) + fmt.Printf("\n%s\n", ui.RenderFail("⚠️ DELETE PREVIEW")) fmt.Printf("\nIssue to delete:\n") fmt.Printf(" %s: %s\n", issueID, issue.Title) totalDeps := len(depRecords) + len(dependents) @@ -248,8 +245,8 @@ the issues will not resurrect from remote branches.`, fmt.Printf(" (none have text references)\n") } } - fmt.Printf("\n%s\n", yellow("This operation cannot be undone!")) - fmt.Printf("To proceed, run: %s\n\n", yellow("bd delete "+issueID+" --force")) + fmt.Printf("\n%s\n", ui.RenderWarn("This operation cannot be undone!")) + fmt.Printf("To proceed, run: %s\n\n", ui.RenderWarn("bd delete "+issueID+" --force")) return } // Actually delete @@ -323,8 +320,7 @@ the issues will not resurrect from remote branches.`, "references_updated": updatedIssueCount, }) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Deleted %s\n", green("✓"), issueID) + fmt.Printf("%s Deleted %s\n", ui.RenderPass("✓"), issueID) fmt.Printf(" Removed %d dependency link(s)\n", totalDepsRemoved) fmt.Printf(" Updated text references in %d issue(s)\n", updatedIssueCount) } @@ -476,14 +472,13 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c if dryRun { fmt.Printf("\n(Dry-run mode - no changes made)\n") } else { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s\n", yellow("This operation cannot be undone!")) + fmt.Printf("\n%s\n", ui.RenderWarn("This operation cannot be undone!")) if cascade { fmt.Printf("To proceed with cascade deletion, run: %s\n", - yellow("bd delete "+strings.Join(issueIDs, " ")+" --cascade --force")) + ui.RenderWarn("bd delete "+strings.Join(issueIDs, " ")+" --cascade --force")) } else { fmt.Printf("To proceed, run: %s\n", - yellow("bd delete "+strings.Join(issueIDs, " ")+" --force")) + ui.RenderWarn("bd delete "+strings.Join(issueIDs, " ")+" --force")) } } return @@ -527,7 +522,7 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c // Use 'bd cleanup --hard' after syncing to fully purge old tombstones. if hardDelete { if !jsonOutput { - fmt.Println(color.YellowString("⚠️ HARD DELETE MODE: Pruning tombstones from JSONL")) + fmt.Println(ui.RenderWarn("⚠️ HARD DELETE MODE: Pruning tombstones from JSONL")) fmt.Println(" Note: Tombstones kept in DB to prevent resurrection. Run 'bd sync' then 'bd cleanup --hard' to fully purge.") } // Prune tombstones from JSONL using negative TTL (immediate expiration) @@ -555,24 +550,20 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c "orphaned_issues": result.OrphanedIssues, }) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Deleted %d issue(s)\n", green("✓"), result.DeletedCount) + fmt.Printf("%s Deleted %d issue(s)\n", ui.RenderPass("✓"), result.DeletedCount) fmt.Printf(" Removed %d dependency link(s)\n", result.DependenciesCount) fmt.Printf(" Removed %d label(s)\n", result.LabelsCount) fmt.Printf(" Removed %d event(s)\n", result.EventsCount) fmt.Printf(" Updated text references in %d issue(s)\n", updatedCount) if len(result.OrphanedIssues) > 0 { - yellow := color.New(color.FgYellow).SprintFunc() fmt.Printf(" %s Orphaned %d issue(s): %s\n", - yellow("⚠"), len(result.OrphanedIssues), strings.Join(result.OrphanedIssues, ", ")) + ui.RenderWarn("⚠"), len(result.OrphanedIssues), strings.Join(result.OrphanedIssues, ", ")) } } } // showDeletionPreview shows what would be deleted func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, cascade bool, depError error) { - red := color.New(color.FgRed).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW")) + fmt.Printf("\n%s\n", ui.RenderFail("⚠️ DELETE PREVIEW")) fmt.Printf("\nIssues to delete (%d):\n", len(issueIDs)) for _, id := range issueIDs { if issue := issues[id]; issue != nil { @@ -580,10 +571,10 @@ func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, casc } } if cascade { - fmt.Printf("\n%s Cascade mode enabled - will also delete all dependent issues\n", yellow("⚠")) + fmt.Printf("\n%s Cascade mode enabled - will also delete all dependent issues\n", ui.RenderWarn("⚠")) } if depError != nil { - fmt.Printf("\n%s\n", red(depError.Error())) + fmt.Printf("\n%s\n", ui.RenderFail(depError.Error())) } } // updateTextReferencesInIssues updates text references to deleted issues in pre-collected connected issues diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 00f14015..26560512 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -7,17 +7,18 @@ import ( "os" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var depCmd = &cobra.Command{ - Use: "dep", - Short: "Manage dependencies", + Use: "dep", + GroupID: "deps", + Short: "Manage dependencies", } var depAddCmd = &cobra.Command{ @@ -88,9 +89,8 @@ var depAddCmd = &cobra.Command{ return } - green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", - green("✓"), args[0], args[1], depType) + ui.RenderPass("✓"), args[0], args[1], depType) return } @@ -114,8 +114,7 @@ var depAddCmd = &cobra.Command{ if err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err) } else if len(cycles) > 0 { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", yellow("⚠")) + fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", ui.RenderWarn("⚠")) fmt.Fprintf(os.Stderr, "This can hide issues from the ready work list and cause confusion.\n\n") fmt.Fprintf(os.Stderr, "Cycle path:\n") for _, cycle := range cycles { @@ -144,9 +143,8 @@ var depAddCmd = &cobra.Command{ return } - green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", - green("✓"), fromID, toID, depType) + ui.RenderPass("✓"), fromID, toID, depType) }, } @@ -215,9 +213,8 @@ var depRemoveCmd = &cobra.Command{ return } - green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", - green("✓"), fromID, toID) + ui.RenderPass("✓"), fromID, toID) return } @@ -242,9 +239,8 @@ var depRemoveCmd = &cobra.Command{ return } - green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", - green("✓"), fullFromID, fullToID) + ui.RenderPass("✓"), fullFromID, fullToID) }, } @@ -388,14 +384,13 @@ Examples: return } - cyan := color.New(color.FgCyan).SprintFunc() switch direction { case "up": - fmt.Printf("\n%s Dependent tree for %s:\n\n", cyan("🌲"), fullID) + fmt.Printf("\n%s Dependent tree for %s:\n\n", ui.RenderAccent("🌲"), fullID) case "both": - fmt.Printf("\n%s Full dependency graph for %s:\n\n", cyan("🌲"), fullID) + fmt.Printf("\n%s Full dependency graph for %s:\n\n", ui.RenderAccent("🌲"), fullID) default: - fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), fullID) + fmt.Printf("\n%s Dependency tree for %s:\n\n", ui.RenderAccent("🌲"), fullID) } // Render tree with proper connectors @@ -436,13 +431,11 @@ var depCyclesCmd = &cobra.Command{ } if len(cycles) == 0 { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s No dependency cycles detected\n\n", green("✓")) + fmt.Printf("\n%s No dependency cycles detected\n\n", ui.RenderPass("✓")) return } - red := color.New(color.FgRed).SprintFunc() - fmt.Printf("\n%s Found %d dependency cycles:\n\n", red("⚠"), len(cycles)) + fmt.Printf("\n%s Found %d dependency cycles:\n\n", ui.RenderFail("⚠"), len(cycles)) for i, cycle := range cycles { fmt.Printf("%d. Cycle involving:\n", i+1) for _, issue := range cycle { @@ -578,8 +571,7 @@ func (r *treeRenderer) renderNode(node *types.TreeNode, children map[string][]*t // Check if we've seen this node before (diamond dependency) if r.seen[node.ID] { - gray := color.New(color.FgHiBlack).SprintFunc() - fmt.Printf("%s%s (shown above)\n", prefix.String(), gray(node.ID)) + fmt.Printf("%s%s (shown above)\n", prefix.String(), ui.RenderMuted(node.ID)) return } r.seen[node.ID] = true @@ -589,8 +581,7 @@ func (r *treeRenderer) renderNode(node *types.TreeNode, children map[string][]*t // Add truncation warning if at max depth and has children if node.Truncated || (depth == r.maxDepth && len(children[node.ID]) > 0) { - yellow := color.New(color.FgYellow).SprintFunc() - line += yellow(" …") + line += ui.RenderWarn(" …") } fmt.Printf("%s%s\n", prefix.String(), line) @@ -613,13 +604,13 @@ func formatTreeNode(node *types.TreeNode) string { var idStr string switch node.Status { case types.StatusOpen: - idStr = color.New(color.FgWhite).Sprint(node.ID) + idStr = ui.StatusOpenStyle.Render(node.ID) case types.StatusInProgress: - idStr = color.New(color.FgYellow).Sprint(node.ID) + idStr = ui.StatusInProgressStyle.Render(node.ID) case types.StatusBlocked: - idStr = color.New(color.FgRed).Sprint(node.ID) + idStr = ui.StatusBlockedStyle.Render(node.ID) case types.StatusClosed: - idStr = color.New(color.FgGreen).Sprint(node.ID) + idStr = ui.StatusClosedStyle.Render(node.ID) default: idStr = node.ID } @@ -632,8 +623,7 @@ func formatTreeNode(node *types.TreeNode) string { // An issue is ready if it's open and has no blocking dependencies // (In the tree view, depth 0 with status open implies ready in the "down" direction) if node.Status == types.StatusOpen && node.Depth == 0 { - green := color.New(color.FgGreen, color.Bold).SprintFunc() - line += " " + green("[READY]") + line += " " + ui.PassStyle.Bold(true).Render("[READY]") } return line diff --git a/cmd/bd/detect_pollution.go b/cmd/bd/detect_pollution.go index a860e68e..0af1f0e2 100644 --- a/cmd/bd/detect_pollution.go +++ b/cmd/bd/detect_pollution.go @@ -7,14 +7,16 @@ import ( "regexp" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) +// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX var detectPollutionCmd = &cobra.Command{ - Use: "detect-pollution", - Short: "Detect and optionally clean test issues from database", + Use: "detect-pollution", + GroupID: "maint", + Short: "Detect and optionally clean test issues from database", Long: `Detect test issues that leaked into production database using pattern matching. This command finds issues that appear to be test data based on: @@ -168,8 +170,7 @@ NOTE: Review detected issues carefully before using --clean. False positives are // Schedule auto-flush markDirtyAndScheduleFlush() - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Deleted %d test issues\n", green("✓"), deleted) + fmt.Printf("%s Deleted %d test issues\n", ui.RenderPass("✓"), deleted) fmt.Printf("\nCleanup complete. To restore, run: bd import %s\n", backupPath) }, } diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index a701e6f5..ebaafa33 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/fatih/color" _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" "github.com/spf13/cobra" @@ -64,8 +63,9 @@ const ConfigKeyHintsDoctor = "hints.doctor" const minSyncBranchHookVersion = "0.29.0" var doctorCmd = &cobra.Command{ - Use: "doctor [path]", - Short: "Check beads installation health", + Use: "doctor [path]", + GroupID: "maint", + Short: "Check and fix beads installation health (start here)", Long: `Sanity check the beads installation for the current directory or specified path. This command checks: @@ -205,9 +205,9 @@ func previewFixes(result doctorResult) { // Show the issue details fmt.Printf(" %d. %s\n", i+1, issue.Name) if issue.Status == statusError { - color.Red(" Status: ERROR\n") + fmt.Printf(" Status: %s\n", ui.RenderFail("ERROR")) } else { - color.Yellow(" Status: WARNING\n") + fmt.Printf(" Status: %s\n", ui.RenderWarn("WARNING")) } fmt.Printf(" Issue: %s\n", issue.Message) if issue.Detail != "" { @@ -286,9 +286,9 @@ func applyFixesInteractive(path string, issues []doctorCheck) { // Show issue details fmt.Printf("(%d/%d) %s\n", i+1, len(issues), issue.Name) if issue.Status == statusError { - color.Red(" Status: ERROR\n") + fmt.Printf(" Status: %s\n", ui.RenderFail("ERROR")) } else { - color.Yellow(" Status: WARNING\n") + fmt.Printf(" Status: %s\n", ui.RenderWarn("WARNING")) } fmt.Printf(" Issue: %s\n", issue.Message) if issue.Detail != "" { @@ -401,11 +401,11 @@ func applyFixList(path string, fixes []doctorCheck) { if err != nil { errorCount++ - color.Red(" ✗ Error: %v\n", err) + fmt.Printf(" %s Error: %v\n", ui.RenderFail("✗"), err) fmt.Printf(" Manual fix: %s\n", check.Fix) } else { fixedCount++ - color.Green(" ✓ Fixed\n") + fmt.Printf(" %s Fixed\n", ui.RenderPass("✓")) } } @@ -886,7 +886,7 @@ func printDiagnostics(result doctorResult) { } } else { fmt.Println() - color.Green("✓ All checks passed\n") + fmt.Printf("%s\n", ui.RenderPass("✓ All checks passed")) } } diff --git a/cmd/bd/duplicate.go b/cmd/bd/duplicate.go index db3542f9..7d76a81f 100644 --- a/cmd/bd/duplicate.go +++ b/cmd/bd/duplicate.go @@ -5,16 +5,17 @@ import ( "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var duplicateCmd = &cobra.Command{ - Use: "duplicate --of ", - Short: "Mark an issue as a duplicate of another", + Use: "duplicate --of ", + GroupID: "deps", + Short: "Mark an issue as a duplicate of another", Long: `Mark an issue as a duplicate of a canonical issue. The duplicate issue is automatically closed with a reference to the canonical. @@ -27,8 +28,9 @@ Examples: } var supersedeCmd = &cobra.Command{ - Use: "supersede --with ", - Short: "Mark an issue as superseded by a newer one", + Use: "supersede --with ", + GroupID: "deps", + Short: "Mark an issue as superseded by a newer one", Long: `Mark an issue as superseded by a newer version. The superseded issue is automatically closed with a reference to the replacement. @@ -149,8 +151,7 @@ func runDuplicate(cmd *cobra.Command, args []string) error { return encoder.Encode(result) } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Marked %s as duplicate of %s (closed)\n", green("✓"), duplicateID, canonicalID) + fmt.Printf("%s Marked %s as duplicate of %s (closed)\n", ui.RenderPass("✓"), duplicateID, canonicalID) return nil } @@ -248,7 +249,6 @@ func runSupersede(cmd *cobra.Command, args []string) error { return encoder.Encode(result) } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Marked %s as superseded by %s (closed)\n", green("✓"), oldID, newID) + fmt.Printf("%s Marked %s as superseded by %s (closed)\n", ui.RenderPass("✓"), oldID, newID) return nil } diff --git a/cmd/bd/duplicates.go b/cmd/bd/duplicates.go index 1936a693..f16475a7 100644 --- a/cmd/bd/duplicates.go +++ b/cmd/bd/duplicates.go @@ -4,13 +4,14 @@ import ( "os" "regexp" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) var duplicatesCmd = &cobra.Command{ - Use: "duplicates", - Short: "Find and optionally merge duplicate issues", + Use: "duplicates", + GroupID: "deps", + Short: "Find and optionally merge duplicate issues", Long: `Find issues with identical content (title, description, design, acceptance criteria). Groups issues by content hash and reports duplicates with suggested merge targets. The merge target is chosen by: @@ -119,18 +120,15 @@ Example: } outputJSON(output) } else { - yellow := color.New(color.FgYellow).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Found %d duplicate group(s):\n\n", yellow("🔍"), len(duplicateGroups)) + fmt.Printf("%s Found %d duplicate group(s):\n\n", ui.RenderWarn("🔍"), len(duplicateGroups)) for i, group := range duplicateGroups { target := chooseMergeTarget(group, refCounts) - fmt.Printf("%s Group %d: %s\n", cyan("━━"), i+1, group[0].Title) + fmt.Printf("%s Group %d: %s\n", ui.RenderAccent("━━"), i+1, group[0].Title) for _, issue := range group { refs := refCounts[issue.ID] marker := " " if issue.ID == target.ID { - marker = green("→ ") + marker = ui.RenderPass("→ ") } fmt.Printf("%s%s (%s, P%d, %d references)\n", marker, issue.ID, issue.Status, issue.Priority, refs) @@ -141,18 +139,18 @@ Example: sources = append(sources, issue.ID) } } - fmt.Printf(" %s Duplicate: %s (same content as %s)\n", cyan("Note:"), strings.Join(sources, " "), target.ID) + fmt.Printf(" %s Duplicate: %s (same content as %s)\n", ui.RenderAccent("Note:"), strings.Join(sources, " "), target.ID) fmt.Printf(" %s bd close %s && bd dep add %s %s --type related\n\n", - cyan("Suggested:"), strings.Join(sources, " "), strings.Join(sources, " "), target.ID) + ui.RenderAccent("Suggested:"), strings.Join(sources, " "), strings.Join(sources, " "), target.ID) } if autoMerge { if dryRun { - fmt.Printf("%s Dry run - would execute %d merge(s)\n", yellow("⚠"), len(mergeCommands)) + fmt.Printf("%s Dry run - would execute %d merge(s)\n", ui.RenderWarn("⚠"), len(mergeCommands)) } else { - fmt.Printf("%s Merged %d group(s)\n", green("✓"), len(mergeCommands)) + fmt.Printf("%s Merged %d group(s)\n", ui.RenderPass("✓"), len(mergeCommands)) } } else { - fmt.Printf("%s Run with --auto-merge to execute all suggested merges\n", cyan("💡")) + fmt.Printf("%s Run with --auto-merge to execute all suggested merges\n", ui.RenderAccent("💡")) } } }, diff --git a/cmd/bd/epic.go b/cmd/bd/epic.go index 2f510460..8fbc7cb0 100644 --- a/cmd/bd/epic.go +++ b/cmd/bd/epic.go @@ -3,14 +3,15 @@ import ( "encoding/json" "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) var epicCmd = &cobra.Command{ - Use: "epic", - Short: "Epic management commands", + Use: "epic", + GroupID: "deps", + Short: "Epic management commands", } var epicStatusCmd = &cobra.Command{ Use: "status", @@ -67,10 +68,6 @@ var epicStatusCmd = &cobra.Command{ fmt.Println("No open epics found") return } - cyan := color.New(color.FgCyan).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - bold := color.New(color.Bold).SprintFunc() for _, epicStatus := range epics { epic := epicStatus.Epic percentage := 0 @@ -79,17 +76,17 @@ var epicStatusCmd = &cobra.Command{ } statusIcon := "" if epicStatus.EligibleForClose { - statusIcon = green("✓") + statusIcon = ui.RenderPass("✓") } else if percentage > 0 { - statusIcon = yellow("○") + statusIcon = ui.RenderWarn("○") } else { statusIcon = "○" } - fmt.Printf("%s %s %s\n", statusIcon, cyan(epic.ID), bold(epic.Title)) + fmt.Printf("%s %s %s\n", statusIcon, ui.RenderAccent(epic.ID), ui.RenderBold(epic.Title)) fmt.Printf(" Progress: %d/%d children closed (%d%%)\n", epicStatus.ClosedChildren, epicStatus.TotalChildren, percentage) if epicStatus.EligibleForClose { - fmt.Printf(" %s\n", green("Eligible for closure")) + fmt.Printf(" %s\n", ui.RenderPass("Eligible for closure")) } fmt.Println() } diff --git a/cmd/bd/graph.go b/cmd/bd/graph.go index 01cbdc4c..26e54ba8 100644 --- a/cmd/bd/graph.go +++ b/cmd/bd/graph.go @@ -8,11 +8,11 @@ import ( "sort" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) @@ -33,8 +33,9 @@ type GraphLayout struct { } var graphCmd = &cobra.Command{ - Use: "graph ", - Short: "Display issue dependency graph", + Use: "graph ", + GroupID: "deps", + Short: "Display issue dependency graph", Long: `Display an ASCII visualization of an issue's dependency graph. For epics, shows all children and their dependencies. @@ -283,8 +284,7 @@ func renderGraph(layout *GraphLayout, subgraph *TemplateSubgraph) { return } - cyan := color.New(color.FgCyan).SprintFunc() - fmt.Printf("\n%s Dependency graph for %s:\n\n", cyan("📊"), layout.RootID) + fmt.Printf("\n%s Dependency graph for %s:\n\n", ui.RenderAccent("📊"), layout.RootID) // Calculate box width based on longest title maxTitleLen := 0 @@ -370,33 +370,34 @@ func renderGraph(layout *GraphLayout, subgraph *TemplateSubgraph) { func renderNodeBox(node *GraphNode, width int) string { // Status indicator var statusIcon string - var colorFn func(a ...interface{}) string + var titleStr string + + title := truncateTitle(node.Issue.Title, width-4) switch node.Issue.Status { case types.StatusOpen: statusIcon = "○" - colorFn = color.New(color.FgWhite).SprintFunc() + titleStr = padRight(title, width-4) case types.StatusInProgress: statusIcon = "◐" - colorFn = color.New(color.FgYellow).SprintFunc() + titleStr = ui.RenderWarn(padRight(title, width-4)) case types.StatusBlocked: statusIcon = "●" - colorFn = color.New(color.FgRed).SprintFunc() + titleStr = ui.RenderFail(padRight(title, width-4)) case types.StatusClosed: statusIcon = "✓" - colorFn = color.New(color.FgGreen).SprintFunc() + titleStr = ui.RenderPass(padRight(title, width-4)) default: statusIcon = "?" - colorFn = color.New(color.FgWhite).SprintFunc() + titleStr = padRight(title, width-4) } - title := truncateTitle(node.Issue.Title, width-4) id := node.Issue.ID // Build the box topBottom := " ┌" + strings.Repeat("─", width) + "┐" - middle := fmt.Sprintf(" │ %s %s │", statusIcon, colorFn(padRight(title, width-4))) - idLine := fmt.Sprintf(" │ %s │", color.New(color.FgHiBlack).Sprint(padRight(id, width-2))) + middle := fmt.Sprintf(" │ %s %s │", statusIcon, titleStr) + idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2))) bottom := " └" + strings.Repeat("─", width) + "┘" return topBottom + "\n" + middle + "\n" + idLine + "\n" + bottom @@ -446,27 +447,28 @@ func computeDependencyCounts(subgraph *TemplateSubgraph) (blocks map[string]int, func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedByCount int) string { // Status indicator var statusIcon string - var colorFn func(a ...interface{}) string + var titleStr string + + title := truncateTitle(node.Issue.Title, width-4) switch node.Issue.Status { case types.StatusOpen: statusIcon = "○" - colorFn = color.New(color.FgWhite).SprintFunc() + titleStr = padRight(title, width-4) case types.StatusInProgress: statusIcon = "◐" - colorFn = color.New(color.FgYellow).SprintFunc() + titleStr = ui.RenderWarn(padRight(title, width-4)) case types.StatusBlocked: statusIcon = "●" - colorFn = color.New(color.FgRed).SprintFunc() + titleStr = ui.RenderFail(padRight(title, width-4)) case types.StatusClosed: statusIcon = "✓" - colorFn = color.New(color.FgGreen).SprintFunc() + titleStr = ui.RenderPass(padRight(title, width-4)) default: statusIcon = "?" - colorFn = color.New(color.FgWhite).SprintFunc() + titleStr = padRight(title, width-4) } - title := truncateTitle(node.Issue.Title, width-4) id := node.Issue.ID // Build dependency info string @@ -484,12 +486,12 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB // Build the box topBottom := " ┌" + strings.Repeat("─", width) + "┐" - middle := fmt.Sprintf(" │ %s %s │", statusIcon, colorFn(padRight(title, width-4))) - idLine := fmt.Sprintf(" │ %s │", color.New(color.FgHiBlack).Sprint(padRight(id, width-2))) + middle := fmt.Sprintf(" │ %s %s │", statusIcon, titleStr) + idLine := fmt.Sprintf(" │ %s │", ui.RenderMuted(padRight(id, width-2))) var result string if depInfo != "" { - depLine := fmt.Sprintf(" │ %s │", color.New(color.FgCyan).Sprint(padRight(depInfo, width-2))) + depLine := fmt.Sprintf(" │ %s │", ui.RenderAccent(padRight(depInfo, width-2))) bottom := " └" + strings.Repeat("─", width) + "┘" result = topBottom + "\n" + middle + "\n" + idLine + "\n" + depLine + "\n" + bottom } else { diff --git a/cmd/bd/init.go b/cmd/bd/init.go index edc2aa4b..dc45dde1 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/internal/beads" @@ -21,12 +20,14 @@ import ( "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/syncbranch" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize bd in the current directory", + Use: "init", + GroupID: "setup", + Short: "Initialize bd in the current directory", Long: `Initialize bd in the current directory by creating a .beads/ directory and database file. Optionally specify a custom issue prefix. @@ -225,15 +226,12 @@ With --stealth: configures global git settings for invisible beads usage: } if !quiet { - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - - fmt.Printf("\n%s bd initialized successfully in --no-db mode!\n\n", green("✓")) - fmt.Printf(" Mode: %s\n", cyan("no-db (JSONL-only)")) - fmt.Printf(" Issues file: %s\n", cyan(jsonlPath)) - fmt.Printf(" Issue prefix: %s\n", cyan(prefix)) - fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"- (e.g., "+prefix+"-a3f2dd)")) - fmt.Printf("Run %s to get started.\n\n", cyan("bd --no-db quickstart")) + fmt.Printf("\n%s bd initialized successfully in --no-db mode!\n\n", ui.RenderPass("✓")) + fmt.Printf(" Mode: %s\n", ui.RenderAccent("no-db (JSONL-only)")) + fmt.Printf(" Issues file: %s\n", ui.RenderAccent(jsonlPath)) + fmt.Printf(" Issue prefix: %s\n", ui.RenderAccent(prefix)) + fmt.Printf(" Issues will be named: %s\n\n", ui.RenderAccent(prefix+"- (e.g., "+prefix+"-a3f2dd)")) + fmt.Printf("Run %s to get started.\n\n", ui.RenderAccent("bd --no-db quickstart")) } return } @@ -427,9 +425,8 @@ With --stealth: configures global git settings for invisible beads usage: // Install by default unless --skip-hooks is passed if !skipHooks && isGitRepo() && !hooksInstalled() { if err := installGitHooks(); err != nil && !quiet { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", yellow("⚠"), err) - fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", color.New(color.FgCyan).Sprint("bd doctor --fix")) + fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", ui.RenderWarn("⚠"), err) + fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix")) } } @@ -437,9 +434,8 @@ With --stealth: configures global git settings for invisible beads usage: // Install by default unless --skip-merge-driver is passed if !skipMergeDriver && isGitRepo() && !mergeDriverInstalled() { if err := installMergeDriver(); err != nil && !quiet { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Fprintf(os.Stderr, "\n%s Failed to install merge driver: %v\n", yellow("⚠"), err) - fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", color.New(color.FgCyan).Sprint("bd doctor --fix")) + fmt.Fprintf(os.Stderr, "\n%s Failed to install merge driver: %v\n", ui.RenderWarn("⚠"), err) + fmt.Fprintf(os.Stderr, "You can try again with: %s\n\n", ui.RenderAccent("bd doctor --fix")) } } @@ -454,14 +450,11 @@ With --stealth: configures global git settings for invisible beads usage: return } - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - - fmt.Printf("\n%s bd initialized successfully!\n\n", green("✓")) - fmt.Printf(" Database: %s\n", cyan(initDBPath)) - fmt.Printf(" Issue prefix: %s\n", cyan(prefix)) - fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"- (e.g., "+prefix+"-a3f2dd)")) - fmt.Printf("Run %s to get started.\n\n", cyan("bd quickstart")) + fmt.Printf("\n%s bd initialized successfully!\n\n", ui.RenderPass("✓")) + fmt.Printf(" Database: %s\n", ui.RenderAccent(initDBPath)) + fmt.Printf(" Issue prefix: %s\n", ui.RenderAccent(prefix)) + fmt.Printf(" Issues will be named: %s\n\n", ui.RenderAccent(prefix+"- (e.g., "+prefix+"-a3f2dd)")) + fmt.Printf("Run %s to get started.\n\n", ui.RenderAccent("bd quickstart")) // Run bd doctor diagnostics to catch setup issues early (bd-zwtq) doctorResult := runDiagnostics(cwd) @@ -474,15 +467,14 @@ With --stealth: configures global git settings for invisible beads usage: } } if hasIssues { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("%s Setup incomplete. Some issues were detected:\n", yellow("⚠")) + fmt.Printf("%s Setup incomplete. Some issues were detected:\n", ui.RenderWarn("⚠")) // Show just the warnings/errors, not all checks for _, check := range doctorResult.Checks { if check.Status != statusOK { fmt.Printf(" • %s: %s\n", check.Name, check.Message) } } - fmt.Printf("\nRun %s to see details and fix these issues.\n\n", cyan("bd doctor --fix")) + fmt.Printf("\nRun %s to see details and fix these issues.\n\n", ui.RenderAccent("bd doctor --fix")) } }, } @@ -592,9 +584,7 @@ func detectExistingHooks() []hookInfo { // promptHookAction asks user what to do with existing hooks func promptHookAction(existingHooks []hookInfo) string { - yellow := color.New(color.FgYellow).SprintFunc() - - fmt.Printf("\n%s Found existing git hooks:\n", yellow("⚠")) + fmt.Printf("\n%s Found existing git hooks:\n", ui.RenderWarn("⚠")) for _, hook := range existingHooks { if hook.exists && !hook.isBdHook { hookType := "custom script" @@ -646,7 +636,6 @@ func installGitHooks() error { // Determine installation mode chainHooks := false if hasExistingHooks { - cyan := color.New(color.FgCyan).SprintFunc() choice := promptHookAction(existingHooks) switch choice { case "1", "": @@ -665,7 +654,7 @@ func installGitHooks() error { } case "3": fmt.Printf("Skipping git hooks installation.\n") - fmt.Printf("You can install manually later with: %s\n", cyan("./examples/git-hooks/install.sh")) + fmt.Printf("You can install manually later with: %s\n", ui.RenderAccent("./examples/git-hooks/install.sh")) return nil default: return fmt.Errorf("invalid choice: %s", choice) @@ -971,8 +960,7 @@ exit 0 } if chainHooks { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Chained bd hooks with existing hooks\n", green("✓")) + fmt.Printf("%s Chained bd hooks with existing hooks\n", ui.RenderPass("✓")) } return nil @@ -1400,12 +1388,10 @@ func setupStealthMode(verbose bool) error { } if verbose { - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - fmt.Printf("\n%s Stealth mode configured successfully!\n\n", green("✓")) - fmt.Printf(" Global gitignore: %s\n", cyan(projectPath+"/.beads/ ignored")) - fmt.Printf(" Claude settings: %s\n\n", cyan("configured with bd onboard instruction")) - fmt.Printf("Your beads setup is now %s - other repo collaborators won't see any beads-related files.\n\n", cyan("invisible")) + fmt.Printf("\n%s Stealth mode configured successfully!\n\n", ui.RenderPass("✓")) + fmt.Printf(" Global gitignore: %s\n", ui.RenderAccent(projectPath+"/.beads/ ignored")) + fmt.Printf(" Claude settings: %s\n\n", ui.RenderAccent("configured with bd onboard instruction")) + fmt.Printf("Your beads setup is now %s - other repo collaborators won't see any beads-related files.\n\n", ui.RenderAccent("invisible")) } return nil @@ -1550,9 +1536,6 @@ func checkExistingBeadsData(prefix string) error { // Check for existing database file dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) if _, err := os.Stat(dbPath); err == nil { - yellow := color.New(color.FgYellow).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - return fmt.Errorf(` %s Found existing database: %s @@ -1564,7 +1547,7 @@ To use the existing database: To completely reinitialize (data loss warning): rm -rf .beads && bd init --prefix %s -Aborting.`, yellow("⚠"), dbPath, cyan("bd list"), prefix) +Aborting.`, ui.RenderWarn("⚠"), dbPath, ui.RenderAccent("bd list"), prefix) } // Fresh clones (JSONL exists but no database) are allowed - init will @@ -1646,8 +1629,7 @@ bd sync # Sync with git return fmt.Errorf("failed to create %s: %w", filename, err) } if verbose { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf(" %s Created %s with landing-the-plane instructions\n", green("✓"), filename) + fmt.Printf(" %s Created %s with landing-the-plane instructions\n", ui.RenderPass("✓"), filename) } return nil } else if err != nil { @@ -1674,8 +1656,7 @@ bd sync # Sync with git return fmt.Errorf("failed to update %s: %w", filename, err) } if verbose { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf(" %s Added landing-the-plane instructions to %s\n", green("✓"), filename) + fmt.Printf(" %s Added landing-the-plane instructions to %s\n", ui.RenderPass("✓"), filename) } return nil } diff --git a/cmd/bd/init_contributor.go b/cmd/bd/init_contributor.go index 7ad67589..ab6a4aa5 100644 --- a/cmd/bd/init_contributor.go +++ b/cmd/bd/init_contributor.go @@ -9,30 +9,25 @@ import ( "path/filepath" "strings" - "github.com/fatih/color" "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/ui" ) // runContributorWizard guides the user through OSS contributor setup func runContributorWizard(ctx context.Context, store storage.Storage) error { - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - bold := color.New(color.Bold).SprintFunc() - - fmt.Printf("\n%s %s\n\n", bold("bd"), bold("Contributor Workflow Setup Wizard")) + fmt.Printf("\n%s %s\n\n", ui.RenderBold("bd"), ui.RenderBold("Contributor Workflow Setup Wizard")) fmt.Println("This wizard will configure beads for OSS contribution.") fmt.Println() // Step 1: Detect fork relationship - fmt.Printf("%s Detecting git repository setup...\n", cyan("▶")) + fmt.Printf("%s Detecting git repository setup...\n", ui.RenderAccent("▶")) isFork, upstreamURL := detectForkSetup() if isFork { - fmt.Printf("%s Detected fork workflow (upstream: %s)\n", green("✓"), upstreamURL) + fmt.Printf("%s Detected fork workflow (upstream: %s)\n", ui.RenderPass("✓"), upstreamURL) } else { - fmt.Printf("%s No upstream remote detected\n", yellow("⚠")) + fmt.Printf("%s No upstream remote detected\n", ui.RenderWarn("⚠")) fmt.Println("\n For fork workflows, add an 'upstream' remote:") fmt.Println(" git remote add upstream ") fmt.Println() @@ -50,13 +45,13 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error { } // Step 2: Check push access to origin - fmt.Printf("\n%s Checking repository access...\n", cyan("▶")) + fmt.Printf("\n%s Checking repository access...\n", ui.RenderAccent("▶")) hasPushAccess, originURL := checkPushAccess() if hasPushAccess { - fmt.Printf("%s You have push access to origin (%s)\n", green("✓"), originURL) - fmt.Printf(" %s You can commit directly to this repository.\n", yellow("⚠")) + fmt.Printf("%s You have push access to origin (%s)\n", ui.RenderPass("✓"), originURL) + fmt.Printf(" %s You can commit directly to this repository.\n", ui.RenderWarn("⚠")) fmt.Println() fmt.Print("Do you want to use a separate planning repo anyway? [Y/n]: ") reader := bufio.NewReader(os.Stdin) @@ -68,12 +63,12 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error { return nil } } else { - fmt.Printf("%s Read-only access to origin (%s)\n", green("✓"), originURL) + fmt.Printf("%s Read-only access to origin (%s)\n", ui.RenderPass("✓"), originURL) fmt.Println(" Planning repo recommended to keep experimental work separate.") } // Step 3: Configure planning repository - fmt.Printf("\n%s Setting up planning repository...\n", cyan("▶")) + fmt.Printf("\n%s Setting up planning repository...\n", ui.RenderAccent("▶")) homeDir, err := os.UserHomeDir() if err != nil { @@ -83,7 +78,7 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error { defaultPlanningRepo := filepath.Join(homeDir, ".beads-planning") fmt.Printf("\nWhere should contributor planning issues be stored?\n") - fmt.Printf("Default: %s\n", cyan(defaultPlanningRepo)) + fmt.Printf("Default: %s\n", ui.RenderAccent(defaultPlanningRepo)) fmt.Print("Planning repo path [press Enter for default]: ") reader := bufio.NewReader(os.Stdin) @@ -101,7 +96,7 @@ func runContributorWizard(ctx context.Context, store storage.Storage) error { // Create planning repository if it doesn't exist if _, err := os.Stat(planningPath); os.IsNotExist(err) { - fmt.Printf("\nCreating planning repository at %s\n", cyan(planningPath)) + fmt.Printf("\nCreating planning repository at %s\n", ui.RenderAccent(planningPath)) if err := os.MkdirAll(planningPath, 0750); err != nil { return fmt.Errorf("failed to create planning repo directory: %w", err) @@ -159,13 +154,13 @@ Created by: bd init --contributor cmd.Dir = planningPath _ = cmd.Run() - fmt.Printf("%s Planning repository created\n", green("✓")) + fmt.Printf("%s Planning repository created\n", ui.RenderPass("✓")) } else { - fmt.Printf("%s Using existing planning repository\n", green("✓")) + fmt.Printf("%s Using existing planning repository\n", ui.RenderPass("✓")) } // Step 4: Configure contributor routing - fmt.Printf("\n%s Configuring contributor auto-routing...\n", cyan("▶")) + fmt.Printf("\n%s Configuring contributor auto-routing...\n", ui.RenderAccent("▶")) // Set contributor.planning_repo config if err := store.SetConfig(ctx, "contributor.planning_repo", planningPath); err != nil { @@ -177,7 +172,7 @@ Created by: bd init --contributor return fmt.Errorf("failed to enable auto-routing: %w", err) } - fmt.Printf("%s Auto-routing enabled\n", green("✓")) + fmt.Printf("%s Auto-routing enabled\n", ui.RenderPass("✓")) // If this is a fork, configure sync to pull beads from upstream (bd-bx9) // This ensures `bd sync` gets the latest issues from the source repo, @@ -186,22 +181,22 @@ Created by: bd init --contributor if err := store.SetConfig(ctx, "sync.remote", "upstream"); err != nil { return fmt.Errorf("failed to set sync remote: %w", err) } - fmt.Printf("%s Sync configured to pull from upstream (source repo)\n", green("✓")) + fmt.Printf("%s Sync configured to pull from upstream (source repo)\n", ui.RenderPass("✓")) } // Step 5: Summary - fmt.Printf("\n%s %s\n\n", green("✓"), bold("Contributor setup complete!")) + fmt.Printf("\n%s %s\n\n", ui.RenderPass("✓"), ui.RenderBold("Contributor setup complete!")) fmt.Println("Configuration:") - fmt.Printf(" Current repo issues: %s\n", cyan(".beads/issues.jsonl")) - fmt.Printf(" Planning repo issues: %s\n", cyan(filepath.Join(planningPath, ".beads/issues.jsonl"))) + fmt.Printf(" Current repo issues: %s\n", ui.RenderAccent(".beads/issues.jsonl")) + fmt.Printf(" Planning repo issues: %s\n", ui.RenderAccent(filepath.Join(planningPath, ".beads/issues.jsonl"))) fmt.Println() fmt.Println("How it works:") fmt.Println(" • Issues you create will route to the planning repo") fmt.Println(" • Planning stays out of your PRs to upstream") fmt.Println(" • Use 'bd list' to see issues from both repos") fmt.Println() - fmt.Printf("Try it: %s\n", cyan("bd create \"Plan feature X\" -p 2")) + fmt.Printf("Try it: %s\n", ui.RenderAccent("bd create \"Plan feature X\" -p 2")) fmt.Println() return nil diff --git a/cmd/bd/init_team.go b/cmd/bd/init_team.go index 0a36b341..3dd8475f 100644 --- a/cmd/bd/init_team.go +++ b/cmd/bd/init_team.go @@ -8,172 +8,167 @@ import ( "os/exec" "strings" - "github.com/fatih/color" "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/ui" ) // runTeamWizard guides the user through team workflow setup func runTeamWizard(ctx context.Context, store storage.Storage) error { - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - bold := color.New(color.Bold).SprintFunc() - - fmt.Printf("\n%s %s\n\n", bold("bd"), bold("Team Workflow Setup Wizard")) + fmt.Printf("\n%s %s\n\n", ui.RenderBold("bd"), ui.RenderBold("Team Workflow Setup Wizard")) fmt.Println("This wizard will configure beads for team collaboration.") fmt.Println() // Step 1: Check if we're in a git repository - fmt.Printf("%s Detecting git repository setup...\n", cyan("▶")) - + fmt.Printf("%s Detecting git repository setup...\n", ui.RenderAccent("▶")) + if !isGitRepo() { - fmt.Printf("%s Not in a git repository\n", yellow("⚠")) + fmt.Printf("%s Not in a git repository\n", ui.RenderWarn("⚠")) fmt.Println("\n Initialize git first:") fmt.Println(" git init") fmt.Println() return fmt.Errorf("not in a git repository") } - + // Get current branch currentBranch, err := getGitBranch() if err != nil { return fmt.Errorf("failed to get current branch: %w", err) } - - fmt.Printf("%s Current branch: %s\n", green("✓"), currentBranch) + + fmt.Printf("%s Current branch: %s\n", ui.RenderPass("✓"), currentBranch) // Step 2: Check for protected main branch - fmt.Printf("\n%s Checking branch configuration...\n", cyan("▶")) - + fmt.Printf("\n%s Checking branch configuration...\n", ui.RenderAccent("▶")) + fmt.Println("\nIs your main branch protected (prevents direct commits)?") fmt.Println(" GitHub: Settings → Branches → Branch protection rules") fmt.Println(" GitLab: Settings → Repository → Protected branches") fmt.Print("\nProtected main branch? [y/N]: ") - + reader := bufio.NewReader(os.Stdin) response, _ := reader.ReadString('\n') response = strings.TrimSpace(strings.ToLower(response)) - + protectedMain := (response == "y" || response == "yes") var syncBranch string - + if protectedMain { - fmt.Printf("\n%s Protected main detected\n", green("✓")) + fmt.Printf("\n%s Protected main detected\n", ui.RenderPass("✓")) fmt.Println("\n Beads will commit issue updates to a separate branch.") - fmt.Printf(" Default sync branch: %s\n", cyan("beads-metadata")) + fmt.Printf(" Default sync branch: %s\n", ui.RenderAccent("beads-metadata")) fmt.Print("\n Sync branch name [press Enter for default]: ") - + branchName, _ := reader.ReadString('\n') branchName = strings.TrimSpace(branchName) - + if branchName == "" { syncBranch = "beads-metadata" } else { syncBranch = branchName } - - fmt.Printf("\n%s Sync branch set to: %s\n", green("✓"), syncBranch) - + + fmt.Printf("\n%s Sync branch set to: %s\n", ui.RenderPass("✓"), syncBranch) + // Set sync.branch config if err := store.SetConfig(ctx, "sync.branch", syncBranch); err != nil { return fmt.Errorf("failed to set sync branch: %w", err) } - + // Create the sync branch if it doesn't exist - fmt.Printf("\n%s Creating sync branch...\n", cyan("▶")) - + fmt.Printf("\n%s Creating sync branch...\n", ui.RenderAccent("▶")) + if err := createSyncBranch(syncBranch); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to create sync branch: %v\n", err) fmt.Println(" You can create it manually: git checkout -b", syncBranch) } else { - fmt.Printf("%s Sync branch created\n", green("✓")) + fmt.Printf("%s Sync branch created\n", ui.RenderPass("✓")) } - + } else { - fmt.Printf("%s Direct commits to %s\n", green("✓"), currentBranch) + fmt.Printf("%s Direct commits to %s\n", ui.RenderPass("✓"), currentBranch) syncBranch = currentBranch } // Step 3: Configure team settings - fmt.Printf("\n%s Configuring team settings...\n", cyan("▶")) - + fmt.Printf("\n%s Configuring team settings...\n", ui.RenderAccent("▶")) + // Set team.enabled to true if err := store.SetConfig(ctx, "team.enabled", "true"); err != nil { return fmt.Errorf("failed to enable team mode: %w", err) } - + // Set team.sync_branch if err := store.SetConfig(ctx, "team.sync_branch", syncBranch); err != nil { return fmt.Errorf("failed to set team sync branch: %w", err) } - - fmt.Printf("%s Team mode enabled\n", green("✓")) + + fmt.Printf("%s Team mode enabled\n", ui.RenderPass("✓")) // Step 4: Configure auto-sync fmt.Println("\n Enable automatic sync (daemon commits/pushes)?") fmt.Println(" • Auto-commit: Commits issue changes every 5 seconds") fmt.Println(" • Auto-push: Pushes commits to remote") fmt.Print("\nEnable auto-sync? [Y/n]: ") - + response, _ = reader.ReadString('\n') response = strings.TrimSpace(strings.ToLower(response)) - + autoSync := !(response == "n" || response == "no") - + if autoSync { if err := store.SetConfig(ctx, "daemon.auto_commit", "true"); err != nil { return fmt.Errorf("failed to enable auto-commit: %w", err) } - + if err := store.SetConfig(ctx, "daemon.auto_push", "true"); err != nil { return fmt.Errorf("failed to enable auto-push: %w", err) } - - fmt.Printf("%s Auto-sync enabled\n", green("✓")) + + fmt.Printf("%s Auto-sync enabled\n", ui.RenderPass("✓")) } else { - fmt.Printf("%s Auto-sync disabled (manual sync with 'bd sync')\n", yellow("⚠")) + fmt.Printf("%s Auto-sync disabled (manual sync with 'bd sync')\n", ui.RenderWarn("⚠")) } // Step 5: Summary - fmt.Printf("\n%s %s\n\n", green("✓"), bold("Team setup complete!")) - + fmt.Printf("\n%s %s\n\n", ui.RenderPass("✓"), ui.RenderBold("Team setup complete!")) + fmt.Println("Configuration:") if protectedMain { - fmt.Printf(" Protected main: %s\n", cyan("yes")) - fmt.Printf(" Sync branch: %s\n", cyan(syncBranch)) - fmt.Printf(" Commits will go to: %s\n", cyan(syncBranch)) - fmt.Printf(" Merge to main via: %s\n", cyan("Pull Request")) + fmt.Printf(" Protected main: %s\n", ui.RenderAccent("yes")) + fmt.Printf(" Sync branch: %s\n", ui.RenderAccent(syncBranch)) + fmt.Printf(" Commits will go to: %s\n", ui.RenderAccent(syncBranch)) + fmt.Printf(" Merge to main via: %s\n", ui.RenderAccent("Pull Request")) } else { - fmt.Printf(" Protected main: %s\n", cyan("no")) - fmt.Printf(" Commits will go to: %s\n", cyan(currentBranch)) + fmt.Printf(" Protected main: %s\n", ui.RenderAccent("no")) + fmt.Printf(" Commits will go to: %s\n", ui.RenderAccent(currentBranch)) } - + if autoSync { - fmt.Printf(" Auto-sync: %s\n", cyan("enabled")) + fmt.Printf(" Auto-sync: %s\n", ui.RenderAccent("enabled")) } else { - fmt.Printf(" Auto-sync: %s\n", cyan("disabled")) + fmt.Printf(" Auto-sync: %s\n", ui.RenderAccent("disabled")) } - + fmt.Println() fmt.Println("How it works:") fmt.Println(" • All team members work on the same repository") fmt.Println(" • Issues are shared via git commits") fmt.Println(" • Use 'bd list' to see all team's issues") - + if protectedMain { fmt.Println(" • Issue updates commit to", syncBranch) fmt.Println(" • Periodically merge", syncBranch, "to main via PR") } - + if autoSync { fmt.Println(" • Daemon automatically commits and pushes changes") } else { fmt.Println(" • Run 'bd sync' manually to sync changes") } - + fmt.Println() - fmt.Printf("Try it: %s\n", cyan("bd create \"Team planning issue\" -p 2")) + fmt.Printf("Try it: %s\n", ui.RenderAccent("bd create \"Team planning issue\" -p 2")) fmt.Println() if protectedMain { diff --git a/cmd/bd/label.go b/cmd/bd/label.go index 438fe25e..62227908 100644 --- a/cmd/bd/label.go +++ b/cmd/bd/label.go @@ -7,15 +7,16 @@ import ( "os" "sort" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var labelCmd = &cobra.Command{ - Use: "label", - Short: "Manage issue labels", + Use: "label", + GroupID: "issues", + Short: "Manage issue labels", } // Helper function to process label operations for multiple issues func processBatchLabelOperation(issueIDs []string, label string, operation string, jsonOut bool, @@ -40,14 +41,13 @@ func processBatchLabelOperation(issueIDs []string, label string, operation strin "label": label, }) } else { - green := color.New(color.FgGreen).SprintFunc() verb := "Added" prep := "to" if operation == "removed" { verb = "Removed" prep = "from" } - fmt.Printf("%s %s label '%s' %s %s\n", green("✓"), verb, label, prep, issueID) + fmt.Printf("%s %s label '%s' %s %s\n", ui.RenderPass("✓"), verb, label, prep, issueID) } } if len(issueIDs) > 0 && daemonClient == nil { @@ -217,8 +217,7 @@ var labelListCmd = &cobra.Command{ fmt.Printf("\n%s has no labels\n", issueID) return } - cyan := color.New(color.FgCyan).SprintFunc() - fmt.Printf("\n%s Labels for %s:\n", cyan("🏷"), issueID) + fmt.Printf("\n%s Labels for %s:\n", ui.RenderAccent("🏷"), issueID) for _, label := range labels { fmt.Printf(" - %s\n", label) } @@ -302,8 +301,7 @@ var labelListAllCmd = &cobra.Command{ outputJSON(result) return } - cyan := color.New(color.FgCyan).SprintFunc() - fmt.Printf("\n%s All labels (%d unique):\n", cyan("🏷"), len(labels)) + fmt.Printf("\n%s All labels (%d unique):\n", ui.RenderAccent("🏷"), len(labels)) // Find longest label for alignment maxLen := 0 for _, label := range labels { diff --git a/cmd/bd/markdown.go b/cmd/bd/markdown.go index 77dd24dc..6fb407ee 100644 --- a/cmd/bd/markdown.go +++ b/cmd/bd/markdown.go @@ -10,9 +10,9 @@ import ( "regexp" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/validation" ) @@ -397,8 +397,7 @@ func createIssuesFromMarkdown(_ *cobra.Command, filepath string) { // Report failures if any if len(failedIssues) > 0 { - red := color.New(color.FgRed).SprintFunc() - fmt.Fprintf(os.Stderr, "\n%s Failed to create %d issues:\n", red("✗"), len(failedIssues)) + fmt.Fprintf(os.Stderr, "\n%s Failed to create %d issues:\n", ui.RenderFail("✗"), len(failedIssues)) for _, title := range failedIssues { fmt.Fprintf(os.Stderr, " - %s\n", title) } @@ -407,8 +406,7 @@ func createIssuesFromMarkdown(_ *cobra.Command, filepath string) { if jsonOutput { outputJSON(createdIssues) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Created %d issues from %s:\n", green("✓"), len(createdIssues), filepath) + fmt.Printf("%s Created %d issues from %s:\n", ui.RenderPass("✓"), len(createdIssues), filepath) for _, issue := range createdIssues { fmt.Printf(" %s: %s [P%d, %s]\n", issue.ID, issue.Title, issue.Priority, issue.IssueType) } diff --git a/cmd/bd/migrate.go b/cmd/bd/migrate.go index 33b79d6e..44487605 100644 --- a/cmd/bd/migrate.go +++ b/cmd/bd/migrate.go @@ -8,20 +8,22 @@ import ( "strings" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" ) +// TODO: Consider integrating into 'bd doctor' migration detection var migrateCmd = &cobra.Command{ - Use: "migrate", - Short: "Migrate database to current version", + Use: "migrate", + GroupID: "maint", + Short: "Migrate database to current version", Long: `Detect and migrate database files to the current version. This command: @@ -140,12 +142,12 @@ This command: fmt.Printf(" Current database: %s\n", filepath.Base(currentDB.path)) fmt.Printf(" Schema version: %s\n", currentDB.version) if currentDB.version != Version { - color.Yellow(" ⚠ Version mismatch (current: %s, expected: %s)\n", currentDB.version, Version) + fmt.Printf(" ⚠ %s\n", ui.RenderWarn(fmt.Sprintf("Version mismatch (current: %s, expected: %s)", currentDB.version, Version))) } else { - color.Green(" ✓ Version matches\n") + fmt.Printf(" %s\n", ui.RenderPass("✓ Version matches")) } } else { - color.Yellow(" No %s found\n", cfg.Database) + fmt.Printf(" %s\n", ui.RenderWarn(fmt.Sprintf("No %s found", cfg.Database))) } if len(oldDBs) > 0 { @@ -231,7 +233,7 @@ This command: os.Exit(1) } if !jsonOutput { - color.Green("✓ Created backup: %s\n", filepath.Base(backupPath)) + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Created backup: %s", filepath.Base(backupPath)))) } } @@ -256,7 +258,7 @@ This command: needsVersionUpdate = true if !jsonOutput { - color.Green("✓ Migration complete\n\n") + fmt.Printf("%s\n\n", ui.RenderPass("✓ Migration complete")) } } @@ -305,7 +307,7 @@ This command: os.Exit(1) } if !jsonOutput { - color.Green("✓ Detected and set issue prefix: %s\n", detectedPrefix) + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Detected and set issue prefix: %s", detectedPrefix))) } } } @@ -327,12 +329,12 @@ This command: // Close and checkpoint to finalize the WAL if err := store.Close(); err != nil { if !jsonOutput { - color.Yellow("Warning: error closing database: %v\n", err) + fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: error closing database: %v", err))) } } if !jsonOutput { - color.Green("✓ Version updated\n\n") + fmt.Printf("%s\n\n", ui.RenderPass("✓ Version updated")) } } @@ -361,7 +363,7 @@ This command: for _, db := range oldDBs { if err := os.Remove(db.path); err != nil { if !jsonOutput { - color.Yellow("Warning: failed to remove %s: %v\n", filepath.Base(db.path), err) + fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: failed to remove %s: %v", filepath.Base(db.path), err))) } } else if !jsonOutput { fmt.Printf("Removed %s\n", filepath.Base(db.path)) @@ -369,7 +371,7 @@ This command: } if !jsonOutput { - color.Green("\n✓ Cleanup complete\n") + fmt.Printf("\n%s\n", ui.RenderPass("✓ Cleanup complete")) } } } @@ -426,7 +428,7 @@ This command: os.Exit(1) } if !jsonOutput { - color.Green("✓ Created backup: %s\n", filepath.Base(backupPath)) + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Created backup: %s", filepath.Base(backupPath)))) } } @@ -449,7 +451,7 @@ This command: if dryRun { fmt.Printf("\nWould migrate %d issues to hash-based IDs\n", len(mapping)) } else { - color.Green("✓ Migrated %d issues to hash-based IDs\n", len(mapping)) + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Migrated %d issues to hash-based IDs", len(mapping)))) } } } else { @@ -464,7 +466,7 @@ This command: if !dryRun { if err := cfg.Save(beadsDir); err != nil { if !jsonOutput { - color.Yellow("Warning: failed to save metadata.json: %v\n", err) + fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: failed to save metadata.json: %v", err))) } // Don't fail migration if config save fails } @@ -693,7 +695,7 @@ func handleUpdateRepoID(dryRun bool, autoYes bool) { "new_repo_id": newRepoID[:8], }) } else { - color.Green("✓ Repository ID updated\n\n") + fmt.Printf("%s\n\n", ui.RenderPass("✓ Repository ID updated")) fmt.Printf(" Old: %s\n", oldDisplay) fmt.Printf(" New: %s\n", newRepoID[:8]) } @@ -1016,7 +1018,7 @@ func handleToSeparateBranch(branch string, dryRun bool) { "message": "sync.branch already set to this value", }) } else { - color.Green("✓ sync.branch already set to '%s'\n", b) + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ sync.branch already set to '%s'", b))) fmt.Println("No changes needed") } return @@ -1044,7 +1046,7 @@ func handleToSeparateBranch(branch string, dryRun bool) { "message": "Enabled separate branch workflow", }) } else { - color.Green("✓ Enabled separate branch workflow\n\n") + fmt.Printf("%s\n\n", ui.RenderPass("✓ Enabled separate branch workflow")) fmt.Printf("Set sync.branch to '%s'\n\n", b) fmt.Println("Next steps:") fmt.Println(" 1. Restart the daemon to create worktree and start committing to the branch:") diff --git a/cmd/bd/migrate_hash_ids.go b/cmd/bd/migrate_hash_ids.go index 7f86d9d8..c5e17b0d 100644 --- a/cmd/bd/migrate_hash_ids.go +++ b/cmd/bd/migrate_hash_ids.go @@ -13,16 +13,18 @@ import ( "strings" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) +// TODO: Consider integrating into 'bd doctor' migration detection var migrateHashIDsCmd = &cobra.Command{ - Use: "migrate-hash-ids", - Short: "Migrate sequential IDs to hash-based IDs (legacy)", + Use: "migrate-hash-ids", + GroupID: "maint", + Short: "Migrate sequential IDs to hash-based IDs (legacy)", Long: `Migrate database from sequential IDs (bd-1, bd-2) to hash-based IDs (bd-a3f8e9a2). *** LEGACY COMMAND *** @@ -86,7 +88,7 @@ WARNING: Backup your database before running this command, even though it create os.Exit(1) } if !jsonOutput { - color.Green("✓ Created backup: %s\n\n", filepath.Base(backupPath)) + fmt.Printf("%s\n\n", ui.RenderPass(fmt.Sprintf("✓ Created backup: %s", filepath.Base(backupPath)))) } } @@ -163,10 +165,10 @@ WARNING: Backup your database before running this command, even though it create mappingPath := filepath.Join(filepath.Dir(dbPath), "hash-id-mapping.json") if err := saveMappingFile(mappingPath, mapping); err != nil { if !jsonOutput { - color.Yellow("Warning: failed to save mapping file: %v\n", err) + fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: failed to save mapping file: %v", err))) } } else if !jsonOutput { - color.Green("✓ Saved mapping to: %s\n", filepath.Base(mappingPath)) + fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Saved mapping to: %s", filepath.Base(mappingPath)))) } } @@ -193,7 +195,7 @@ WARNING: Backup your database before running this command, even though it create count++ } } else { - color.Green("\n✓ Migration complete!\n\n") + fmt.Printf("\n%s\n\n", ui.RenderPass("✓ Migration complete!")) fmt.Printf("Migrated %d issues to hash-based IDs\n", len(mapping)) fmt.Println("\nNext steps:") fmt.Println(" 1. Run 'bd export' to update JSONL file") diff --git a/cmd/bd/migrate_tombstones.go b/cmd/bd/migrate_tombstones.go index 612e381b..3135ce31 100644 --- a/cmd/bd/migrate_tombstones.go +++ b/cmd/bd/migrate_tombstones.go @@ -8,9 +8,9 @@ import ( "path/filepath" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) // legacyDeletionRecordCmd represents a single deletion entry from the legacy deletions.jsonl manifest. @@ -68,9 +68,11 @@ func loadLegacyDeletionsCmd(path string) (map[string]legacyDeletionRecordCmd, [] return records, warnings, nil } +// TODO: Consider integrating into 'bd doctor' migration detection var migrateTombstonesCmd = &cobra.Command{ - Use: "migrate-tombstones", - Short: "Convert deletions.jsonl entries to inline tombstones", + Use: "migrate-tombstones", + GroupID: "maint", + Short: "Convert deletions.jsonl entries to inline tombstones", Long: `Migrate legacy deletions.jsonl entries to inline tombstones in issues.jsonl. This command converts existing deletion records from the legacy deletions.jsonl @@ -149,7 +151,7 @@ Examples: // Print warnings from loading for _, warning := range warnings { if !jsonOutput { - color.Yellow("Warning: %s\n", warning) + fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: %s", warning))) } } @@ -288,7 +290,7 @@ Examples: if err := os.Rename(deletionsPath, archivePath); err != nil { // Warn but don't fail - tombstones were already created if !jsonOutput { - color.Yellow("Warning: could not archive deletions.jsonl: %v\n", err) + fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: could not archive deletions.jsonl: %v", err))) } } else if verbose && !jsonOutput { fmt.Printf(" ✓ Archived deletions.jsonl to %s\n", filepath.Base(archivePath)) @@ -305,7 +307,7 @@ Examples: "migrated_ids": migratedIDs, }) } else { - color.Green("\n✓ Migration complete\n\n") + fmt.Printf("\n%s\n\n", ui.RenderPass("✓ Migration complete")) fmt.Printf(" Migrated: %d tombstone(s)\n", len(migratedIDs)) if len(skippedIDs) > 0 { fmt.Printf(" Skipped: %d (already had tombstones)\n", len(skippedIDs)) diff --git a/cmd/bd/molecule.go b/cmd/bd/molecule.go index 693dfc46..30f93f32 100644 --- a/cmd/bd/molecule.go +++ b/cmd/bd/molecule.go @@ -6,11 +6,11 @@ import ( "os" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/hooks" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) var moleculeCmd = &cobra.Command{ @@ -268,8 +268,7 @@ Examples: if jsonOutput { outputJSON(&issue) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Created work item: %s (from template %s)\n", green("✓"), issue.ID, moleculeID) + fmt.Printf("%s Created work item: %s (from template %s)\n", ui.RenderPass("✓"), issue.ID, moleculeID) fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Priority: P%d\n", issue.Priority) fmt.Printf(" Status: %s\n", issue.Status) @@ -328,8 +327,7 @@ Examples: if jsonOutput { outputJSON(createdIssue) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Created work item: %s (from template %s)\n", green("✓"), createdIssue.ID, moleculeID) + fmt.Printf("%s Created work item: %s (from template %s)\n", ui.RenderPass("✓"), createdIssue.ID, moleculeID) fmt.Printf(" Title: %s\n", createdIssue.Title) fmt.Printf(" Priority: P%d\n", createdIssue.Priority) fmt.Printf(" Status: %s\n", createdIssue.Status) diff --git a/cmd/bd/onboard.go b/cmd/bd/onboard.go index 1c77add3..7609eed4 100644 --- a/cmd/bd/onboard.go +++ b/cmd/bd/onboard.go @@ -4,8 +4,8 @@ import ( "fmt" "io" - "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/ui" ) const copilotInstructionsContent = `# GitHub Copilot Instructions @@ -37,10 +37,6 @@ Run ` + "`bd prime`" + ` for workflow context, or install hooks (` + "`bd hooks For full workflow details: ` + "`bd prime`" + `` func renderOnboardInstructions(w io.Writer) error { - bold := color.New(color.Bold).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - writef := func(format string, args ...interface{}) error { _, err := fmt.Fprintf(w, format, args...) return err @@ -54,7 +50,7 @@ func renderOnboardInstructions(w io.Writer) error { return err } - if err := writef("\n%s\n\n", bold("bd Onboarding")); err != nil { + if err := writef("\n%s\n\n", ui.RenderBold("bd Onboarding")); err != nil { return err } if err := writeln("Add this minimal snippet to AGENTS.md (or create it):"); err != nil { @@ -64,17 +60,17 @@ func renderOnboardInstructions(w io.Writer) error { return err } - if err := writef("%s\n", cyan("--- BEGIN AGENTS.MD CONTENT ---")); err != nil { + if err := writef("%s\n", ui.RenderAccent("--- BEGIN AGENTS.MD CONTENT ---")); err != nil { return err } if err := writeln(agentsContent); err != nil { return err } - if err := writef("%s\n\n", cyan("--- END AGENTS.MD CONTENT ---")); err != nil { + if err := writef("%s\n\n", ui.RenderAccent("--- END AGENTS.MD CONTENT ---")); err != nil { return err } - if err := writef("%s\n", bold("For GitHub Copilot users:")); err != nil { + if err := writef("%s\n", ui.RenderBold("For GitHub Copilot users:")); err != nil { return err } if err := writeln("Add the same content to .github/copilot-instructions.md"); err != nil { @@ -84,13 +80,13 @@ func renderOnboardInstructions(w io.Writer) error { return err } - if err := writef("%s\n", bold("How it works:")); err != nil { + if err := writef("%s\n", ui.RenderBold("How it works:")); err != nil { return err } - if err := writef(" • %s provides dynamic workflow context (~80 lines)\n", cyan("bd prime")); err != nil { + if err := writef(" • %s provides dynamic workflow context (~80 lines)\n", ui.RenderAccent("bd prime")); err != nil { return err } - if err := writef(" • %s auto-injects bd prime at session start\n", cyan("bd hooks install")); err != nil { + if err := writef(" • %s auto-injects bd prime at session start\n", ui.RenderAccent("bd hooks install")); err != nil { return err } if err := writeln(" • AGENTS.md only needs this minimal pointer, not full instructions"); err != nil { @@ -100,7 +96,7 @@ func renderOnboardInstructions(w io.Writer) error { return err } - if err := writef("%s\n\n", green("This keeps AGENTS.md lean while bd prime provides up-to-date workflow details.")); err != nil { + if err := writef("%s\n\n", ui.RenderPass("This keeps AGENTS.md lean while bd prime provides up-to-date workflow details.")); err != nil { return err } @@ -108,8 +104,9 @@ func renderOnboardInstructions(w io.Writer) error { } var onboardCmd = &cobra.Command{ - Use: "onboard", - Short: "Display minimal snippet for AGENTS.md", + Use: "onboard", + GroupID: "setup", + Short: "Display minimal snippet for AGENTS.md", Long: `Display a minimal snippet to add to AGENTS.md for bd integration. This outputs a small (~10 line) snippet that points to 'bd prime' for full diff --git a/cmd/bd/pin.go b/cmd/bd/pin.go index a2b96351..715f410b 100644 --- a/cmd/bd/pin.go +++ b/cmd/bd/pin.go @@ -5,16 +5,17 @@ import ( "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var pinCmd = &cobra.Command{ - Use: "pin [id...]", - Short: "Pin one or more issues as persistent context markers", + Use: "pin [id...]", + GroupID: "issues", + Short: "Pin one or more issues as persistent context markers", Long: `Pin issues to mark them as persistent context markers. Pinned issues are not work items - they are context beads that should @@ -78,8 +79,8 @@ Examples: pinnedIssues = append(pinnedIssues, &issue) } } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Pinned %s\n", green("📌"), id) + + fmt.Printf("%s Pinned %s\n", ui.RenderPass("📌"), id) } } @@ -117,8 +118,8 @@ Examples: pinnedIssues = append(pinnedIssues, issue) } } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Pinned %s\n", green("📌"), fullID) + + fmt.Printf("%s Pinned %s\n", ui.RenderPass("📌"), fullID) } } diff --git a/cmd/bd/quickstart.go b/cmd/bd/quickstart.go index f0f0095f..a5860e4b 100644 --- a/cmd/bd/quickstart.go +++ b/cmd/bd/quickstart.go @@ -3,98 +3,94 @@ package main import ( "fmt" - "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/ui" ) var quickstartCmd = &cobra.Command{ - Use: "quickstart", - Short: "Quick start guide for bd", + Use: "quickstart", + GroupID: "setup", + Short: "Quick start guide for bd", Long: `Display a quick start guide showing common bd workflows and patterns.`, Run: func(cmd *cobra.Command, args []string) { - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - bold := color.New(color.Bold).SprintFunc() - - fmt.Printf("\n%s\n\n", bold("bd - Dependency-Aware Issue Tracker")) + fmt.Printf("\n%s\n\n", ui.RenderBold("bd - Dependency-Aware Issue Tracker")) fmt.Printf("Issues chained together like beads.\n\n") - fmt.Printf("%s\n", bold("GETTING STARTED")) - fmt.Printf(" %s Initialize bd in your project\n", cyan("bd init")) + fmt.Printf("%s\n", ui.RenderBold("GETTING STARTED")) + fmt.Printf(" %s Initialize bd in your project\n", ui.RenderAccent("bd init")) fmt.Printf(" Creates .beads/ directory with project-specific database\n") fmt.Printf(" Auto-detects prefix from directory name (e.g., myapp-1, myapp-2)\n\n") - fmt.Printf(" %s Initialize with custom prefix\n", cyan("bd init --prefix api")) + fmt.Printf(" %s Initialize with custom prefix\n", ui.RenderAccent("bd init --prefix api")) fmt.Printf(" Issues will be named: api- (e.g., api-a3f2dd)\n\n") - fmt.Printf("%s\n", bold("CREATING ISSUES")) - fmt.Printf(" %s\n", cyan("bd create \"Fix login bug\"")) - fmt.Printf(" %s\n", cyan("bd create \"Add auth\" -p 0 -t feature")) - fmt.Printf(" %s\n\n", cyan("bd create \"Write tests\" -d \"Unit tests for auth\" --assignee alice")) + fmt.Printf("%s\n", ui.RenderBold("CREATING ISSUES")) + fmt.Printf(" %s\n", ui.RenderAccent("bd create \"Fix login bug\"")) + fmt.Printf(" %s\n", ui.RenderAccent("bd create \"Add auth\" -p 0 -t feature")) + fmt.Printf(" %s\n\n", ui.RenderAccent("bd create \"Write tests\" -d \"Unit tests for auth\" --assignee alice")) - fmt.Printf("%s\n", bold("VIEWING ISSUES")) - fmt.Printf(" %s List all issues\n", cyan("bd list")) - fmt.Printf(" %s List by status\n", cyan("bd list --status open")) - fmt.Printf(" %s List by priority (0-4, 0=highest)\n", cyan("bd list --priority 0")) - fmt.Printf(" %s Show issue details\n\n", cyan("bd show bd-1")) + fmt.Printf("%s\n", ui.RenderBold("VIEWING ISSUES")) + fmt.Printf(" %s List all issues\n", ui.RenderAccent("bd list")) + fmt.Printf(" %s List by status\n", ui.RenderAccent("bd list --status open")) + fmt.Printf(" %s List by priority (0-4, 0=highest)\n", ui.RenderAccent("bd list --priority 0")) + fmt.Printf(" %s Show issue details\n\n", ui.RenderAccent("bd show bd-1")) - fmt.Printf("%s\n", bold("MANAGING DEPENDENCIES")) - fmt.Printf(" %s Add dependency (bd-2 blocks bd-1)\n", cyan("bd dep add bd-1 bd-2")) - fmt.Printf(" %s Visualize dependency tree\n", cyan("bd dep tree bd-1")) - fmt.Printf(" %s Detect circular dependencies\n\n", cyan("bd dep cycles")) + fmt.Printf("%s\n", ui.RenderBold("MANAGING DEPENDENCIES")) + fmt.Printf(" %s Add dependency (bd-2 blocks bd-1)\n", ui.RenderAccent("bd dep add bd-1 bd-2")) + fmt.Printf(" %s Visualize dependency tree\n", ui.RenderAccent("bd dep tree bd-1")) + fmt.Printf(" %s Detect circular dependencies\n\n", ui.RenderAccent("bd dep cycles")) - fmt.Printf("%s\n", bold("DEPENDENCY TYPES")) - fmt.Printf(" %s Task B must complete before task A\n", yellow("blocks")) - fmt.Printf(" %s Soft connection, doesn't block progress\n", yellow("related")) - fmt.Printf(" %s Epic/subtask hierarchical relationship\n", yellow("parent-child")) - fmt.Printf(" %s Auto-created when AI discovers related work\n\n", yellow("discovered-from")) + fmt.Printf("%s\n", ui.RenderBold("DEPENDENCY TYPES")) + fmt.Printf(" %s Task B must complete before task A\n", ui.RenderWarn("blocks")) + fmt.Printf(" %s Soft connection, doesn't block progress\n", ui.RenderWarn("related")) + fmt.Printf(" %s Epic/subtask hierarchical relationship\n", ui.RenderWarn("parent-child")) + fmt.Printf(" %s Auto-created when AI discovers related work\n\n", ui.RenderWarn("discovered-from")) - fmt.Printf("%s\n", bold("READY WORK")) - fmt.Printf(" %s Show issues ready to work on\n", cyan("bd ready")) + fmt.Printf("%s\n", ui.RenderBold("READY WORK")) + fmt.Printf(" %s Show issues ready to work on\n", ui.RenderAccent("bd ready")) fmt.Printf(" Ready = status is 'open' AND no blocking dependencies\n") fmt.Printf(" Perfect for agents to claim next work!\n\n") - fmt.Printf("%s\n", bold("UPDATING ISSUES")) - fmt.Printf(" %s\n", cyan("bd update bd-1 --status in_progress")) - fmt.Printf(" %s\n", cyan("bd update bd-1 --priority 0")) - fmt.Printf(" %s\n\n", cyan("bd update bd-1 --assignee bob")) + fmt.Printf("%s\n", ui.RenderBold("UPDATING ISSUES")) + fmt.Printf(" %s\n", ui.RenderAccent("bd update bd-1 --status in_progress")) + fmt.Printf(" %s\n", ui.RenderAccent("bd update bd-1 --priority 0")) + fmt.Printf(" %s\n\n", ui.RenderAccent("bd update bd-1 --assignee bob")) - fmt.Printf("%s\n", bold("CLOSING ISSUES")) - fmt.Printf(" %s\n", cyan("bd close bd-1")) - fmt.Printf(" %s\n\n", cyan("bd close bd-2 bd-3 --reason \"Fixed in PR #42\"")) + fmt.Printf("%s\n", ui.RenderBold("CLOSING ISSUES")) + fmt.Printf(" %s\n", ui.RenderAccent("bd close bd-1")) + fmt.Printf(" %s\n\n", ui.RenderAccent("bd close bd-2 bd-3 --reason \"Fixed in PR #42\"")) - fmt.Printf("%s\n", bold("DATABASE LOCATION")) + fmt.Printf("%s\n", ui.RenderBold("DATABASE LOCATION")) fmt.Printf(" bd automatically discovers your database:\n") - fmt.Printf(" 1. %s flag\n", cyan("--db /path/to/db.db")) - fmt.Printf(" 2. %s environment variable\n", cyan("$BEADS_DB")) - fmt.Printf(" 3. %s in current directory or ancestors\n", cyan(".beads/*.db")) - fmt.Printf(" 4. %s as fallback\n\n", cyan("~/.beads/default.db")) + fmt.Printf(" 1. %s flag\n", ui.RenderAccent("--db /path/to/db.db")) + fmt.Printf(" 2. %s environment variable\n", ui.RenderAccent("$BEADS_DB")) + fmt.Printf(" 3. %s in current directory or ancestors\n", ui.RenderAccent(".beads/*.db")) + fmt.Printf(" 4. %s as fallback\n\n", ui.RenderAccent("~/.beads/default.db")) - fmt.Printf("%s\n", bold("AGENT INTEGRATION")) + fmt.Printf("%s\n", ui.RenderBold("AGENT INTEGRATION")) fmt.Printf(" bd is designed for AI-supervised workflows:\n") fmt.Printf(" • Agents create issues when discovering new work\n") - fmt.Printf(" • %s shows unblocked work ready to claim\n", cyan("bd ready")) - fmt.Printf(" • Use %s flags for programmatic parsing\n", cyan("--json")) + fmt.Printf(" • %s shows unblocked work ready to claim\n", ui.RenderAccent("bd ready")) + fmt.Printf(" • Use %s flags for programmatic parsing\n", ui.RenderAccent("--json")) fmt.Printf(" • Dependencies prevent agents from duplicating effort\n\n") - fmt.Printf("%s\n", bold("DATABASE EXTENSION")) + fmt.Printf("%s\n", ui.RenderBold("DATABASE EXTENSION")) fmt.Printf(" Applications can extend bd's SQLite database:\n") - fmt.Printf(" • Add your own tables (e.g., %s)\n", cyan("myapp_executions")) - fmt.Printf(" • Join with %s table for powerful queries\n", cyan("issues")) + fmt.Printf(" • Add your own tables (e.g., %s)\n", ui.RenderAccent("myapp_executions")) + fmt.Printf(" • Join with %s table for powerful queries\n", ui.RenderAccent("issues")) fmt.Printf(" • See database extension docs for integration patterns:\n") - fmt.Printf(" %s\n\n", cyan("https://github.com/steveyegge/beads/blob/main/EXTENDING.md")) + fmt.Printf(" %s\n\n", ui.RenderAccent("https://github.com/steveyegge/beads/blob/main/EXTENDING.md")) - fmt.Printf("%s\n", bold("GIT WORKFLOW (AUTO-SYNC)")) + fmt.Printf("%s\n", ui.RenderBold("GIT WORKFLOW (AUTO-SYNC)")) fmt.Printf(" bd automatically keeps git in sync:\n") - fmt.Printf(" • %s Export to JSONL after CRUD operations (5s debounce)\n", green("✓")) - fmt.Printf(" • %s Import from JSONL when newer than DB (after %s)\n", green("✓"), cyan("git pull")) - fmt.Printf(" • %s Works seamlessly across machines and team members\n", green("✓")) + fmt.Printf(" • %s Export to JSONL after CRUD operations (5s debounce)\n", ui.RenderPass("✓")) + fmt.Printf(" • %s Import from JSONL when newer than DB (after %s)\n", ui.RenderPass("✓"), ui.RenderAccent("git pull")) + fmt.Printf(" • %s Works seamlessly across machines and team members\n", ui.RenderPass("✓")) fmt.Printf(" • No manual export/import needed!\n") - fmt.Printf(" Disable with: %s or %s\n\n", cyan("--no-auto-flush"), cyan("--no-auto-import")) + fmt.Printf(" Disable with: %s or %s\n\n", ui.RenderAccent("--no-auto-flush"), ui.RenderAccent("--no-auto-import")) - fmt.Printf("%s\n", green("Ready to start!")) - fmt.Printf("Run %s to create your first issue.\n\n", cyan("bd create \"My first issue\"")) + fmt.Printf("%s\n", ui.RenderPass("Ready to start!")) + fmt.Printf("Run %s to create your first issue.\n\n", ui.RenderAccent("bd create \"My first issue\"")) }, } diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index 4376b685..ed5dda6b 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -5,12 +5,12 @@ import ( "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/util" ) var readyCmd = &cobra.Command{ @@ -105,20 +105,20 @@ var readyCmd = &cobra.Command{ hasOpenIssues = stats.OpenIssues > 0 || stats.InProgressIssues > 0 } } - yellow := color.New(color.FgYellow).SprintFunc() if hasOpenIssues { fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n", - yellow("✨")) + ui.RenderWarn("✨")) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s No open issues\n\n", green("✨")) + fmt.Printf("\n%s No open issues\n\n", ui.RenderPass("✨")) } return } - cyan := color.New(color.FgCyan).SprintFunc() - fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues)) + fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", ui.RenderAccent("📋"), len(issues)) for i, issue := range issues { - fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title) + fmt.Printf("%d. [%s] [%s] %s: %s\n", i+1, + ui.RenderPriority(issue.Priority), + ui.RenderType(string(issue.IssueType)), + ui.RenderID(issue.ID), issue.Title) if issue.EstimatedMinutes != nil { fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes) } @@ -175,21 +175,21 @@ var readyCmd = &cobra.Command{ hasOpenIssues = stats.OpenIssues > 0 || stats.InProgressIssues > 0 } if hasOpenIssues { - yellow := color.New(color.FgYellow).SprintFunc() fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n", - yellow("✨")) + ui.RenderWarn("✨")) } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s No open issues\n\n", green("✨")) + fmt.Printf("\n%s No open issues\n\n", ui.RenderPass("✨")) } // Show tip even when no ready work found maybeShowTip(store) return } - cyan := color.New(color.FgCyan).SprintFunc() - fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues)) + fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", ui.RenderAccent("📋"), len(issues)) for i, issue := range issues { - fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title) + fmt.Printf("%d. [%s] [%s] %s: %s\n", i+1, + ui.RenderPriority(issue.Priority), + ui.RenderType(string(issue.IssueType)), + ui.RenderID(issue.ID), issue.Title) if issue.EstimatedMinutes != nil { fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes) } @@ -233,14 +233,14 @@ var blockedCmd = &cobra.Command{ return } if len(blocked) == 0 { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s No blocked issues\n\n", green("✨")) + fmt.Printf("\n%s No blocked issues\n\n", ui.RenderPass("✨")) return } - red := color.New(color.FgRed).SprintFunc() - fmt.Printf("\n%s Blocked issues (%d):\n\n", red("🚫"), len(blocked)) + fmt.Printf("\n%s Blocked issues (%d):\n\n", ui.RenderFail("🚫"), len(blocked)) for _, issue := range blocked { - fmt.Printf("[P%d] %s: %s\n", issue.Priority, issue.ID, issue.Title) + fmt.Printf("[%s] %s: %s\n", + ui.RenderPriority(issue.Priority), + ui.RenderID(issue.ID), issue.Title) blockedBy := issue.BlockedBy if blockedBy == nil { blockedBy = []string{} @@ -272,16 +272,13 @@ var statsCmd = &cobra.Command{ outputJSON(stats) return } - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊")) + fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊")) fmt.Printf("Total Issues: %d\n", stats.TotalIssues) - fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues))) - fmt.Printf("In Progress: %s\n", yellow(fmt.Sprintf("%d", stats.InProgressIssues))) + fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues))) + fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues))) fmt.Printf("Closed: %d\n", stats.ClosedIssues) - fmt.Printf("Blocked: %d\n", stats.BlockedIssues) - fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues))) + fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues))) + fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues))) if stats.TombstoneIssues > 0 { fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) } @@ -316,16 +313,13 @@ var statsCmd = &cobra.Command{ outputJSON(stats) return } - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊")) + fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊")) fmt.Printf("Total Issues: %d\n", stats.TotalIssues) - fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues))) - fmt.Printf("In Progress: %s\n", yellow(fmt.Sprintf("%d", stats.InProgressIssues))) + fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues))) + fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues))) fmt.Printf("Closed: %d\n", stats.ClosedIssues) - fmt.Printf("Blocked: %d\n", stats.BlockedIssues) - fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues))) + fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues))) + fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues))) if stats.TombstoneIssues > 0 { fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) } @@ -333,7 +327,7 @@ var statsCmd = &cobra.Command{ fmt.Printf("Pinned: %d\n", stats.PinnedIssues) } if stats.EpicsEligibleForClosure > 0 { - fmt.Printf("Epics Ready to Close: %s\n", green(fmt.Sprintf("%d", stats.EpicsEligibleForClosure))) + fmt.Printf("Epics Ready to Close: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure))) } if stats.AverageLeadTime > 0 { fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime) diff --git a/cmd/bd/relate.go b/cmd/bd/relate.go index 5d93c648..b735544f 100644 --- a/cmd/bd/relate.go +++ b/cmd/bd/relate.go @@ -5,16 +5,17 @@ import ( "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var relateCmd = &cobra.Command{ - Use: "relate ", - Short: "Create a bidirectional relates_to link between issues", + Use: "relate ", + GroupID: "deps", + Short: "Create a bidirectional relates_to link between issues", Long: `Create a loose 'see also' relationship between two issues. The relates_to link is bidirectional - both issues will reference each other. @@ -28,8 +29,9 @@ Examples: } var unrelateCmd = &cobra.Command{ - Use: "unrelate ", - Short: "Remove a relates_to link between issues", + Use: "unrelate ", + GroupID: "deps", + Short: "Remove a relates_to link between issues", Long: `Remove a relates_to relationship between two issues. Removes the link in both directions. @@ -177,8 +179,7 @@ func runRelate(cmd *cobra.Command, args []string) error { return encoder.Encode(result) } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Linked %s ↔ %s\n", green("✓"), id1, id2) + fmt.Printf("%s Linked %s ↔ %s\n", ui.RenderPass("✓"), id1, id2) return nil } @@ -300,8 +301,7 @@ func runUnrelate(cmd *cobra.Command, args []string) error { return encoder.Encode(result) } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Unlinked %s ↔ %s\n", green("✓"), id1, id2) + fmt.Printf("%s Unlinked %s ↔ %s\n", ui.RenderPass("✓"), id1, id2) return nil } diff --git a/cmd/bd/rename_prefix.go b/cmd/bd/rename_prefix.go index b1109bff..bf9bba10 100644 --- a/cmd/bd/rename_prefix.go +++ b/cmd/bd/rename_prefix.go @@ -11,17 +11,18 @@ import ( "strings" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var renamePrefixCmd = &cobra.Command{ - Use: "rename-prefix ", - Short: "Rename the issue prefix for all issues in the database", + Use: "rename-prefix ", + GroupID: "advanced", + Short: "Rename the issue prefix for all issues in the database", Long: `Rename the issue prefix for all issues in the database. This will update all issue IDs and all text references across all fields. @@ -99,12 +100,10 @@ NOTE: This is a rare operation. Most users never need this command.`, if len(prefixes) > 1 { // Multiple prefixes detected - requires repair mode - red := color.New(color.FgRed).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Fprintf(os.Stderr, "%s Multiple prefixes detected in database:\n", red("✗")) + fmt.Fprintf(os.Stderr, "%s Multiple prefixes detected in database:\n", ui.RenderFail("✗")) for prefix, count := range prefixes { - fmt.Fprintf(os.Stderr, " - %s: %d issues\n", yellow(prefix), count) + fmt.Fprintf(os.Stderr, " - %s: %d issues\n", ui.RenderWarn(prefix), count) } fmt.Fprintf(os.Stderr, "\n") @@ -141,8 +140,7 @@ NOTE: This is a rare operation. Most users never need this command.`, } if dryRun { - cyan := color.New(color.FgCyan).SprintFunc() - fmt.Printf("DRY RUN: Would rename %d issues from prefix '%s' to '%s'\n\n", len(issues), oldPrefix, newPrefix) + fmt.Printf("DRY RUN: Would rename %d issues from prefix '%s' to '%s'\n\n", len(issues), oldPrefix, newPrefix) fmt.Printf("Sample changes:\n") for i, issue := range issues { if i >= 5 { @@ -151,13 +149,11 @@ NOTE: This is a rare operation. Most users never need this command.`, } oldID := fmt.Sprintf("%s-%s", oldPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-")) newID := fmt.Sprintf("%s-%s", newPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-")) - fmt.Printf(" %s -> %s\n", cyan(oldID), cyan(newID)) + fmt.Printf(" %s -> %s\n", ui.RenderAccent(oldID), ui.RenderAccent(newID)) } return } - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() fmt.Printf("Renaming %d issues from prefix '%s' to '%s'...\n", len(issues), oldPrefix, newPrefix) @@ -169,7 +165,7 @@ NOTE: This is a rare operation. Most users never need this command.`, // Schedule full export (IDs changed, incremental won't work) markDirtyAndScheduleFullExport() - fmt.Printf("%s Successfully renamed prefix from %s to %s\n", green("✓"), cyan(oldPrefix), cyan(newPrefix)) + fmt.Printf("%s Successfully renamed prefix from %s to %s\n", ui.RenderPass("✓"), ui.RenderAccent(oldPrefix), ui.RenderAccent(newPrefix)) if jsonOutput { result := map[string]interface{}{ @@ -230,9 +226,6 @@ type issueSort struct { // Issues with the correct prefix are left unchanged. // Issues with incorrect prefixes get new hash-based IDs. func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, targetPrefix string, issues []*types.Issue, prefixes map[string]int, dryRun bool) error { - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() // Separate issues into correct and incorrect prefix groups var correctIssues []*types.Issue @@ -290,7 +283,7 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t if dryRun { fmt.Printf("DRY RUN: Would repair %d issues with incorrect prefixes\n\n", len(incorrectIssues)) - fmt.Printf("Issues with correct prefix (%s): %d\n", cyan(targetPrefix), len(correctIssues)) + fmt.Printf("Issues with correct prefix (%s): %d\n", ui.RenderAccent(targetPrefix), len(correctIssues)) fmt.Printf("Issues to repair: %d\n\n", len(incorrectIssues)) fmt.Printf("Planned renames (showing first 10):\n") @@ -301,14 +294,14 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t } oldID := is.issue.ID newID := renameMap[oldID] - fmt.Printf(" %s -> %s\n", yellow(oldID), cyan(newID)) + fmt.Printf(" %s -> %s\n", ui.RenderWarn(oldID), ui.RenderAccent(newID)) } return nil } // Perform the repairs fmt.Printf("Repairing database with multiple prefixes...\n") - fmt.Printf(" Issues with correct prefix (%s): %d\n", cyan(targetPrefix), len(correctIssues)) + fmt.Printf(" Issues with correct prefix (%s): %d\n", ui.RenderAccent(targetPrefix), len(correctIssues)) fmt.Printf(" Issues to repair: %d\n\n", len(incorrectIssues)) // Pattern to match any issue ID reference in text (both hash and sequential IDs) @@ -348,7 +341,7 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t return fmt.Errorf("failed to update issue %s -> %s: %w", oldID, newID, err) } - fmt.Printf(" Renamed %s -> %s\n", yellow(oldID), cyan(newID)) + fmt.Printf(" Renamed %s -> %s\n", ui.RenderWarn(oldID), ui.RenderAccent(newID)) } // Update all dependencies to use new prefix @@ -378,7 +371,7 @@ func repairPrefixes(ctx context.Context, st storage.Storage, actorName string, t markDirtyAndScheduleFullExport() fmt.Printf("\n%s Successfully consolidated %d prefixes into %s\n", - green("✓"), len(prefixes), cyan(targetPrefix)) + ui.RenderPass("✓"), len(prefixes), ui.RenderAccent(targetPrefix)) fmt.Printf(" %d issues repaired, %d issues unchanged\n", len(incorrectIssues), len(correctIssues)) if jsonOutput { diff --git a/cmd/bd/reopen.go b/cmd/bd/reopen.go index 1e5b2da2..e0afcfd4 100644 --- a/cmd/bd/reopen.go +++ b/cmd/bd/reopen.go @@ -3,15 +3,16 @@ import ( "encoding/json" "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var reopenCmd = &cobra.Command{ - Use: "reopen [id...]", - Short: "Reopen one or more closed issues", + Use: "reopen [id...]", + GroupID: "issues", + Short: "Reopen one or more closed issues", Long: `Reopen closed issues by setting status to 'open' and clearing the closed_at timestamp. This is more explicit than 'bd update --status open' and emits a Reopened event.`, Args: cobra.MinimumNArgs(1), @@ -76,12 +77,11 @@ This is more explicit than 'bd update --status open' and emits a Reopened event. reopenedIssues = append(reopenedIssues, &issue) } } else { - blue := color.New(color.FgBlue).SprintFunc() reasonMsg := "" if reason != "" { reasonMsg = ": " + reason } - fmt.Printf("%s Reopened %s%s\n", blue("↻"), id, reasonMsg) + fmt.Printf("%s Reopened %s%s\n", ui.RenderAccent("↻"), id, reasonMsg) } } if jsonOutput && len(reopenedIssues) > 0 { @@ -120,12 +120,11 @@ This is more explicit than 'bd update --status open' and emits a Reopened event. reopenedIssues = append(reopenedIssues, issue) } } else { - blue := color.New(color.FgBlue).SprintFunc() reasonMsg := "" if reason != "" { reasonMsg = ": " + reason } - fmt.Printf("%s Reopened %s%s\n", blue("↻"), fullID, reasonMsg) + fmt.Printf("%s Reopened %s%s\n", ui.RenderAccent("↻"), fullID, reasonMsg) } } // Schedule auto-flush if any issues were reopened diff --git a/cmd/bd/repair_deps.go b/cmd/bd/repair_deps.go index 81b794ee..c20a9c4a 100644 --- a/cmd/bd/repair_deps.go +++ b/cmd/bd/repair_deps.go @@ -5,15 +5,17 @@ import ( "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) +// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX var repairDepsCmd = &cobra.Command{ - Use: "repair-deps", - Short: "Find and fix orphaned dependency references", + Use: "repair-deps", + GroupID: "maint", + Short: "Find and fix orphaned dependency references", Long: `Scans all issues for dependencies pointing to non-existent issues. Reports orphaned dependencies and optionally removes them with --fix. @@ -105,13 +107,11 @@ Interactive mode with --interactive prompts for each orphan.`, // Report results if len(orphans) == 0 { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s No orphaned dependencies found\n\n", green("✓")) + fmt.Printf("\n%s No orphaned dependencies found\n\n", ui.RenderPass("✓")) return } - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s Found %d orphaned dependencies:\n\n", yellow("⚠"), len(orphans)) + fmt.Printf("\n%s Found %d orphaned dependencies:\n\n", ui.RenderWarn("⚠"), len(orphans)) for i, o := range orphans { fmt.Printf("%d. %s → %s (%s) [%s does not exist]\n", @@ -142,8 +142,7 @@ Interactive mode with --interactive prompts for each orphan.`, } } markDirtyAndScheduleFlush() - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("\n%s Fixed %d orphaned dependencies\n\n", green("✓"), fixed) + fmt.Printf("\n%s Fixed %d orphaned dependencies\n\n", ui.RenderPass("✓"), fixed) } else if fix { db := store.UnderlyingDB() for _, o := range orphans { @@ -159,8 +158,7 @@ Interactive mode with --interactive prompts for each orphan.`, } } markDirtyAndScheduleFlush() - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Fixed %d orphaned dependencies\n\n", green("✓"), len(orphans)) + fmt.Printf("%s Fixed %d orphaned dependencies\n\n", ui.RenderPass("✓"), len(orphans)) } else { fmt.Printf("Run with --fix to automatically remove orphaned dependencies\n") fmt.Printf("Run with --interactive to review each dependency\n\n") diff --git a/cmd/bd/reset.go b/cmd/bd/reset.go index 7f4042b5..f592bf44 100644 --- a/cmd/bd/reset.go +++ b/cmd/bd/reset.go @@ -8,14 +8,15 @@ import ( "path/filepath" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/git" + "github.com/steveyegge/beads/internal/ui" ) var resetCmd = &cobra.Command{ - Use: "reset", - Short: "Remove all beads data and configuration", + Use: "reset", + GroupID: "advanced", + Short: "Remove all beads data and configuration", Long: `Reset beads to an uninitialized state, removing all local data. This command removes: @@ -206,29 +207,26 @@ func showResetPreview(items []resetItem) { return } - yellow := color.New(color.FgYellow).SprintFunc() - red := color.New(color.FgRed).SprintFunc() - fmt.Println(yellow("Reset preview (dry-run mode)")) + fmt.Println(ui.RenderWarn("Reset preview (dry-run mode)")) fmt.Println() fmt.Println("The following will be removed:") fmt.Println() for _, item := range items { - fmt.Printf(" %s %s\n", red("•"), item.Description) + fmt.Printf(" %s %s\n", ui.RenderFail("•"), item.Description) if item.Type != "config" { fmt.Printf(" %s\n", item.Path) } } fmt.Println() - fmt.Println(red("⚠ This operation cannot be undone!")) + fmt.Println(ui.RenderFail("⚠ This operation cannot be undone!")) fmt.Println() - fmt.Printf("To proceed, run: %s\n", yellow("bd reset --force")) + fmt.Printf("To proceed, run: %s\n", ui.RenderWarn("bd reset --force")) } func performReset(items []resetItem, _, beadsDir string) { - green := color.New(color.FgGreen).SprintFunc() var errors []string @@ -238,14 +236,14 @@ func performReset(items []resetItem, _, beadsDir string) { pidFile := filepath.Join(beadsDir, "daemon.pid") stopDaemonQuiet(pidFile) if !jsonOutput { - fmt.Printf("%s Stopped daemon\n", green("✓")) + fmt.Printf("%s Stopped daemon\n", ui.RenderPass("✓")) } case "hook": if err := os.Remove(item.Path); err != nil { errors = append(errors, fmt.Sprintf("failed to remove hook %s: %v", item.Path, err)) } else if !jsonOutput { - fmt.Printf("%s Removed %s\n", green("✓"), filepath.Base(item.Path)) + fmt.Printf("%s Removed %s\n", ui.RenderPass("✓"), filepath.Base(item.Path)) } // Restore backup if exists backupPath := item.Path + ".backup" @@ -260,28 +258,28 @@ func performReset(items []resetItem, _, beadsDir string) { _ = exec.Command("git", "config", "--unset", "merge.beads.driver").Run() _ = exec.Command("git", "config", "--unset", "merge.beads.name").Run() if !jsonOutput { - fmt.Printf("%s Removed merge driver config\n", green("✓")) + fmt.Printf("%s Removed merge driver config\n", ui.RenderPass("✓")) } case "gitattributes": if err := removeGitattributesEntry(); err != nil { errors = append(errors, fmt.Sprintf("failed to update .gitattributes: %v", err)) } else if !jsonOutput { - fmt.Printf("%s Updated .gitattributes\n", green("✓")) + fmt.Printf("%s Updated .gitattributes\n", ui.RenderPass("✓")) } case "worktrees": if err := os.RemoveAll(item.Path); err != nil { errors = append(errors, fmt.Sprintf("failed to remove worktrees: %v", err)) } else if !jsonOutput { - fmt.Printf("%s Removed sync worktrees\n", green("✓")) + fmt.Printf("%s Removed sync worktrees\n", ui.RenderPass("✓")) } case "directory": if err := os.RemoveAll(item.Path); err != nil { errors = append(errors, fmt.Sprintf("failed to remove .beads: %v", err)) } else if !jsonOutput { - fmt.Printf("%s Removed .beads directory\n", green("✓")) + fmt.Printf("%s Removed .beads directory\n", ui.RenderPass("✓")) } } } @@ -305,7 +303,7 @@ func performReset(items []resetItem, _, beadsDir string) { fmt.Printf(" • %s\n", e) } } else { - fmt.Printf("%s Reset complete\n", green("✓")) + fmt.Printf("%s Reset complete\n", ui.RenderPass("✓")) fmt.Println() fmt.Println("To reinitialize beads, run: bd init") } diff --git a/cmd/bd/restore.go b/cmd/bd/restore.go index 7e6229be..1095fe83 100644 --- a/cmd/bd/restore.go +++ b/cmd/bd/restore.go @@ -8,14 +8,15 @@ import ( "os/exec" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) var restoreCmd = &cobra.Command{ - Use: "restore ", - Short: "Restore full history of a compacted issue from git", + Use: "restore ", + GroupID: "sync", + Short: "Restore full history of a compacted issue from git", Long: `Restore full history of a compacted issue from git version control. When an issue is compacted, the git commit hash is saved. This command: @@ -185,59 +186,54 @@ func readIssueFromJSONL(jsonlPath, issueID string) (*types.Issue, error) { // displayRestoredIssue displays the restored issue in a readable format func displayRestoredIssue(issue *types.Issue, commitHash string) { - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - bold := color.New(color.Bold).SprintFunc() - - fmt.Printf("\n%s %s (restored from git commit %s)\n", cyan("📜"), bold(issue.ID), yellow(commitHash[:8])) - fmt.Printf("%s\n\n", bold(issue.Title)) + fmt.Printf("\n%s %s (restored from git commit %s)\n", ui.RenderAccent("📜"), ui.RenderBold(issue.ID), ui.RenderWarn(commitHash[:8])) + fmt.Printf("%s\n\n", ui.RenderBold(issue.Title)) if issue.Description != "" { - fmt.Printf("%s\n%s\n\n", bold("Description:"), issue.Description) + fmt.Printf("%s\n%s\n\n", ui.RenderBold("Description:"), issue.Description) } if issue.Design != "" { - fmt.Printf("%s\n%s\n\n", bold("Design:"), issue.Design) + fmt.Printf("%s\n%s\n\n", ui.RenderBold("Design:"), issue.Design) } if issue.AcceptanceCriteria != "" { - fmt.Printf("%s\n%s\n\n", bold("Acceptance Criteria:"), issue.AcceptanceCriteria) + fmt.Printf("%s\n%s\n\n", ui.RenderBold("Acceptance Criteria:"), issue.AcceptanceCriteria) } if issue.Notes != "" { - fmt.Printf("%s\n%s\n\n", bold("Notes:"), issue.Notes) + fmt.Printf("%s\n%s\n\n", ui.RenderBold("Notes:"), issue.Notes) } fmt.Printf("%s %s | %s %d | %s %s\n", - bold("Status:"), issue.Status, - bold("Priority:"), issue.Priority, - bold("Type:"), issue.IssueType, + ui.RenderBold("Status:"), issue.Status, + ui.RenderBold("Priority:"), issue.Priority, + ui.RenderBold("Type:"), issue.IssueType, ) if issue.Assignee != "" { - fmt.Printf("%s %s\n", bold("Assignee:"), issue.Assignee) + fmt.Printf("%s %s\n", ui.RenderBold("Assignee:"), issue.Assignee) } if len(issue.Labels) > 0 { - fmt.Printf("%s %s\n", bold("Labels:"), strings.Join(issue.Labels, ", ")) + fmt.Printf("%s %s\n", ui.RenderBold("Labels:"), strings.Join(issue.Labels, ", ")) } - fmt.Printf("\n%s %s\n", bold("Created:"), issue.CreatedAt.Format("2006-01-02 15:04:05")) - fmt.Printf("%s %s\n", bold("Updated:"), issue.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("\n%s %s\n", ui.RenderBold("Created:"), issue.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("%s %s\n", ui.RenderBold("Updated:"), issue.UpdatedAt.Format("2006-01-02 15:04:05")) if issue.ClosedAt != nil { - fmt.Printf("%s %s\n", bold("Closed:"), issue.ClosedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("%s %s\n", ui.RenderBold("Closed:"), issue.ClosedAt.Format("2006-01-02 15:04:05")) } if len(issue.Dependencies) > 0 { - fmt.Printf("\n%s\n", bold("Dependencies:")) + fmt.Printf("\n%s\n", ui.RenderBold("Dependencies:")) for _, dep := range issue.Dependencies { - fmt.Printf(" %s %s (%s)\n", green("→"), dep.DependsOnID, dep.Type) + fmt.Printf(" %s %s (%s)\n", ui.RenderPass("→"), dep.DependsOnID, dep.Type) } } if issue.CompactionLevel > 0 { - fmt.Printf("\n%s Level %d", yellow("⚠️ This issue was compacted:"), issue.CompactionLevel) + fmt.Printf("\n%s Level %d", ui.RenderWarn("⚠️ This issue was compacted:"), issue.CompactionLevel) if issue.CompactedAt != nil { fmt.Printf(" at %s", issue.CompactedAt.Format("2006-01-02 15:04:05")) } diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 8d4a3ae2..108ee2b3 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -9,21 +9,22 @@ import ( "sort" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/hooks" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/validation" ) var showCmd = &cobra.Command{ - Use: "show [id...]", - Short: "Show issue details", - Args: cobra.MinimumNArgs(1), + Use: "show [id...]", + GroupID: "issues", + Short: "Show issue details", + Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { jsonOutput, _ := cmd.Flags().GetBool("json") showThread, _ := cmd.Flags().GetBool("thread") @@ -118,8 +119,6 @@ var showCmd = &cobra.Command{ } issue := &details.Issue - cyan := color.New(color.FgCyan).SprintFunc() - // Format output (same as direct mode below) tierEmoji := "" statusSuffix := "" @@ -132,7 +131,7 @@ var showCmd = &cobra.Command{ statusSuffix = " (compacted L2)" } - fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) + 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) @@ -299,8 +298,6 @@ var showCmd = &cobra.Command{ fmt.Println("\n" + strings.Repeat("─", 60)) } - cyan := color.New(color.FgCyan).SprintFunc() - // Add compaction emoji to title line tierEmoji := "" statusSuffix := "" @@ -313,7 +310,7 @@ var showCmd = &cobra.Command{ statusSuffix = " (compacted L2)" } - fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) + 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) @@ -463,9 +460,10 @@ var showCmd = &cobra.Command{ } var updateCmd = &cobra.Command{ - Use: "update [id...]", - Short: "Update one or more issues", - Args: cobra.MinimumNArgs(1), + Use: "update [id...]", + GroupID: "issues", + Short: "Update one or more issues", + Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("update") jsonOutput, _ := cmd.Flags().GetBool("json") @@ -659,8 +657,7 @@ var updateCmd = &cobra.Command{ } } if !jsonOutput { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Updated issue: %s\n", green("✓"), id) + fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id) } } @@ -754,8 +751,7 @@ var updateCmd = &cobra.Command{ updatedIssues = append(updatedIssues, updatedIssue) } } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Updated issue: %s\n", green("✓"), id) + fmt.Printf("%s Updated issue: %s\n", ui.RenderPass("✓"), id) } } @@ -771,8 +767,9 @@ var updateCmd = &cobra.Command{ } var editCmd = &cobra.Command{ - Use: "edit [id]", - Short: "Edit an issue field in $EDITOR", + Use: "edit [id]", + GroupID: "issues", + Short: "Edit an issue field in $EDITOR", Long: `Edit an issue field using your configured $EDITOR. By default, edits the description. Use flags to edit other fields. @@ -962,16 +959,16 @@ Examples: markDirtyAndScheduleFlush() } - green := color.New(color.FgGreen).SprintFunc() fieldName := strings.ReplaceAll(fieldToEdit, "_", " ") - fmt.Printf("%s Updated %s for issue: %s\n", green("✓"), fieldName, id) + fmt.Printf("%s Updated %s for issue: %s\n", ui.RenderPass("✓"), fieldName, id) }, } var closeCmd = &cobra.Command{ - Use: "close [id...]", - Short: "Close one or more issues", - Args: cobra.MinimumNArgs(1), + Use: "close [id...]", + GroupID: "issues", + Short: "Close one or more issues", + Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("close") reason, _ := cmd.Flags().GetString("reason") @@ -1053,8 +1050,7 @@ var closeCmd = &cobra.Command{ } } if !jsonOutput { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) + fmt.Printf("%s Closed %s: %s\n", ui.RenderPass("✓"), id, reason) } } @@ -1100,8 +1096,7 @@ var closeCmd = &cobra.Command{ closedIssues = append(closedIssues, closedIssue) } } else { - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) + fmt.Printf("%s Closed %s: %s\n", ui.RenderPass("✓"), id, reason) } } @@ -1221,10 +1216,7 @@ func showMessageThread(ctx context.Context, messageID string, jsonOutput bool) { } // Display the thread - cyan := color.New(color.FgCyan).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - - fmt.Printf("\n%s Thread: %s\n", cyan("📬"), rootMsg.Title) + fmt.Printf("\n%s Thread: %s\n", ui.RenderAccent("📬"), rootMsg.Title) fmt.Println(strings.Repeat("─", 66)) for _, msg := range threadMessages { @@ -1246,12 +1238,12 @@ func showMessageThread(ctx context.Context, messageID string, jsonOutput bool) { statusIcon = "✓" } - fmt.Printf("%s%s %s %s\n", indent, statusIcon, cyan(msg.ID), dim(timeStr)) + fmt.Printf("%s%s %s %s\n", indent, statusIcon, ui.RenderAccent(msg.ID), ui.RenderMuted(timeStr)) fmt.Printf("%s From: %s To: %s\n", indent, msg.Sender, msg.Assignee) if parentID := repliesTo[msg.ID]; parentID != "" { fmt.Printf("%s Re: %s\n", indent, parentID) } - fmt.Printf("%s %s: %s\n", indent, dim("Subject"), msg.Title) + fmt.Printf("%s %s: %s\n", indent, ui.RenderMuted("Subject"), msg.Title) if msg.Description != "" { // Indent the body bodyLines := strings.Split(msg.Description, "\n") diff --git a/cmd/bd/template.go b/cmd/bd/template.go index 057fe9ae..6b196caa 100644 --- a/cmd/bd/template.go +++ b/cmd/bd/template.go @@ -9,11 +9,11 @@ import ( "strings" "time" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) @@ -39,8 +39,9 @@ type InstantiateResult struct { } var templateCmd = &cobra.Command{ - Use: "template", - Short: "Manage issue templates", + Use: "template", + GroupID: "setup", + Short: "Manage issue templates", Long: `Manage Beads templates for creating issue hierarchies. Templates are epics with the "template" label. They can have child issues @@ -106,17 +107,14 @@ var templateListCmd = &cobra.Command{ return } - green := color.New(color.FgGreen).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - - fmt.Printf("%s\n", green("Templates (for bd template instantiate):")) + fmt.Printf("%s\n", ui.RenderPass("Templates (for bd template instantiate):")) for _, tmpl := range beadsTemplates { vars := extractVariables(tmpl.Title + " " + tmpl.Description) varStr := "" if len(vars) > 0 { varStr = fmt.Sprintf(" (vars: %s)", strings.Join(vars, ", ")) } - fmt.Printf(" %s: %s%s\n", cyan(tmpl.ID), tmpl.Title, varStr) + fmt.Printf(" %s: %s%s\n", ui.RenderAccent(tmpl.ID), tmpl.Title, varStr) } fmt.Println() }, @@ -175,25 +173,21 @@ func showBeadsTemplate(subgraph *TemplateSubgraph) { return } - cyan := color.New(color.FgCyan).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - - fmt.Printf("\n%s Template: %s\n", cyan("📋"), subgraph.Root.Title) + fmt.Printf("\n%s Template: %s\n", ui.RenderAccent("📋"), subgraph.Root.Title) fmt.Printf(" ID: %s\n", subgraph.Root.ID) fmt.Printf(" Issues: %d\n", len(subgraph.Issues)) // Show variables vars := extractAllVariables(subgraph) if len(vars) > 0 { - fmt.Printf("\n%s Variables:\n", yellow("📝")) + fmt.Printf("\n%s Variables:\n", ui.RenderWarn("📝")) for _, v := range vars { fmt.Printf(" {{%s}}\n", v) } } // Show structure - fmt.Printf("\n%s Structure:\n", green("🌲")) + fmt.Printf("\n%s Structure:\n", ui.RenderPass("🌲")) printTemplateTree(subgraph, subgraph.Root.ID, 0, true) fmt.Println() } @@ -309,8 +303,7 @@ Example: return } - green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Created %d issues from template\n", green("✓"), result.Created) + fmt.Printf("%s Created %d issues from template\n", ui.RenderPass("✓"), result.Created) fmt.Printf(" New epic: %s\n", result.NewEpicID) }, } diff --git a/cmd/bd/unpin.go b/cmd/bd/unpin.go index 813afff7..b4a8948a 100644 --- a/cmd/bd/unpin.go +++ b/cmd/bd/unpin.go @@ -5,16 +5,17 @@ import ( "fmt" "os" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/utils" ) var unpinCmd = &cobra.Command{ - Use: "unpin [id...]", - Short: "Unpin one or more issues", + Use: "unpin [id...]", + GroupID: "issues", + Short: "Unpin one or more issues", Long: `Unpin issues to remove their persistent context marker status. This restores the issue to a normal work item that can be cleaned up @@ -78,8 +79,7 @@ Examples: unpinnedIssues = append(unpinnedIssues, &issue) } } else { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("%s Unpinned %s\n", yellow("📍"), id) + fmt.Printf("%s Unpinned %s\n", ui.RenderWarn("📍"), id) } } @@ -117,8 +117,7 @@ Examples: unpinnedIssues = append(unpinnedIssues, issue) } } else { - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("%s Unpinned %s\n", yellow("📍"), fullID) + fmt.Printf("%s Unpinned %s\n", ui.RenderWarn("📍"), fullID) } } diff --git a/cmd/bd/validate.go b/cmd/bd/validate.go index 21209028..60d6acd3 100644 --- a/cmd/bd/validate.go +++ b/cmd/bd/validate.go @@ -5,13 +5,15 @@ import ( "fmt" "os" "strings" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" ) +// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX var validateCmd = &cobra.Command{ - Use: "validate", - Short: "Run comprehensive database health checks", + Use: "validate", + GroupID: "maint", + Short: "Run comprehensive database health checks", Long: `Run all validation checks to ensure database integrity: - Orphaned dependencies (references to deleted issues) - Duplicate issues (identical content) @@ -193,9 +195,6 @@ func (r *validationResults) toJSON() map[string]interface{} { return output } func (r *validationResults) print(_ bool) { - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - red := color.New(color.FgRed).SprintFunc() fmt.Println("\nValidation Results:") fmt.Println("===================") totalIssues := 0 @@ -204,33 +203,34 @@ func (r *validationResults) print(_ bool) { for _, name := range r.checkOrder { result := r.checks[name] prefix := "✓" - colorFunc := green + var coloredPrefix string if result.err != nil { prefix = "✗" - colorFunc = red - fmt.Printf("%s %s: ERROR - %v\n", colorFunc(prefix), result.name, result.err) + coloredPrefix = ui.RenderFail(prefix) + fmt.Printf("%s %s: ERROR - %v\n", coloredPrefix, result.name, result.err) } else if result.issueCount > 0 { prefix = "⚠" - colorFunc = yellow + coloredPrefix = ui.RenderWarn(prefix) if result.fixedCount > 0 { - fmt.Printf("%s %s: %d found, %d fixed\n", colorFunc(prefix), result.name, result.issueCount, result.fixedCount) + fmt.Printf("%s %s: %d found, %d fixed\n", coloredPrefix, result.name, result.issueCount, result.fixedCount) } else { - fmt.Printf("%s %s: %d found\n", colorFunc(prefix), result.name, result.issueCount) + fmt.Printf("%s %s: %d found\n", coloredPrefix, result.name, result.issueCount) } } else { - fmt.Printf("%s %s: OK\n", colorFunc(prefix), result.name) + coloredPrefix = ui.RenderPass(prefix) + fmt.Printf("%s %s: OK\n", coloredPrefix, result.name) } totalIssues += result.issueCount totalFixed += result.fixedCount } fmt.Println() if totalIssues == 0 { - fmt.Printf("%s Database is healthy!\n", green("✓")) + fmt.Printf("%s Database is healthy!\n", ui.RenderPass("✓")) } else if totalFixed == totalIssues { - fmt.Printf("%s Fixed all %d issues\n", green("✓"), totalFixed) + fmt.Printf("%s Fixed all %d issues\n", ui.RenderPass("✓"), totalFixed) } else { remaining := totalIssues - totalFixed - fmt.Printf("%s Found %d issues", yellow("⚠"), totalIssues) + fmt.Printf("%s Found %d issues", ui.RenderWarn("⚠"), totalIssues) if totalFixed > 0 { fmt.Printf(" (fixed %d, %d remaining)", totalFixed, remaining) } diff --git a/docs/ui-philosophy.md b/docs/ui-philosophy.md new file mode 100644 index 00000000..f7cf87c4 --- /dev/null +++ b/docs/ui-philosophy.md @@ -0,0 +1,114 @@ +# UI/UX Philosophy + +Beads CLI follows Tufte-inspired design principles for terminal output, using semantic color tokens with adaptive light/dark mode support via Lipgloss. + +## Core Principles + +### 1. Maximize Data-Ink Ratio (Tufte) +Only color what demands attention. Every colored element should serve a purpose: +- Navigation landmarks (section headers, group titles) +- Scan targets (command names, flag names) +- Semantic states (success, warning, error, blocked) + +**Anti-pattern**: Coloring everything defeats the purpose and creates cognitive overload. + +### 2. Semantic Color Tokens +Use meaning-based tokens, not raw colors: + +| Token | Semantic Meaning | Use Cases | +|-------|-----------------|-----------| +| `Pass` | Success, completion, ready | Checkmarks, completed items, healthy status | +| `Warn` | Attention needed, caution | Warnings, in-progress items, action required | +| `Fail` | Error, blocked, critical | Errors, blocked items, failures | +| `Accent` | Navigation, emphasis | Headers, links, key information | +| `Muted` | De-emphasized, secondary | Defaults, closed items, metadata | +| `Command` | Interactive elements | Command names, flags | + +### 3. Perceptual Optimization (Light/Dark Modes) +Lipgloss `AdaptiveColor` ensures optimal contrast in both terminal modes: + +```go +ColorPass = lipgloss.AdaptiveColor{ + Light: "#86b300", // Darker green for light backgrounds + Dark: "#c2d94c", // Brighter green for dark backgrounds +} +``` + +**Why this matters**: +- Light terminals need darker colors for contrast +- Dark terminals need brighter colors for visibility +- Same semantic meaning, optimized perception + +### 4. Respect Cognitive Load +Let whitespace and position do most of the work: +- Group related information visually +- Use indentation for hierarchy +- Reserve color for exceptional states + +## Color Usage Guide + +### When to Color + +| Situation | Style | Rationale | +|-----------|-------|-----------| +| Navigation landmarks | Accent | Helps users orient in output | +| Command/flag names | Bold | Creates vertical scan targets | +| Success indicators | Pass (green) | Immediate positive feedback | +| Warnings | Warn (yellow) | Draws attention without alarm | +| Errors | Fail (red) | Demands immediate attention | +| Closed/done items | Muted | Visually recedes, "done" | +| High priority (P0/P1) | Semantic color | Only urgent items deserve color | +| Normal priority (P2+) | Plain | Most items don't need highlighting | + +### When NOT to Color + +- **Descriptions and prose**: Let content speak for itself +- **Examples in help text**: Keep copy-paste friendly +- **Every list item**: Only color exceptional states +- **Decorative purposes**: Color is functional, not aesthetic + +## Ayu Theme + +All colors use the [Ayu theme](https://github.com/ayu-theme/ayu-colors) for consistency: + +```go +// Semantic colors with light/dark adaptation +ColorPass = AdaptiveColor{Light: "#86b300", Dark: "#c2d94c"} // Green +ColorWarn = AdaptiveColor{Light: "#f2ae49", Dark: "#ffb454"} // Yellow +ColorFail = AdaptiveColor{Light: "#f07171", Dark: "#f07178"} // Red +ColorAccent = AdaptiveColor{Light: "#399ee6", Dark: "#59c2ff"} // Blue +ColorMuted = AdaptiveColor{Light: "#828c99", Dark: "#6c7680"} // Gray +``` + +## Implementation + +All styling is centralized in `internal/ui/styles.go`: + +```go +// Render functions for semantic styling +ui.RenderPass("✓") // Success indicator +ui.RenderWarn("⚠") // Warning indicator +ui.RenderFail("✗") // Error indicator +ui.RenderAccent("→") // Accent/link +ui.RenderMuted("...") // Secondary info +ui.RenderBold("name") // Emphasis +ui.RenderCommand("bd") // Command reference +``` + +## Help Text Styling + +Following Tufte's principle of layered information: + +1. **Section headers** (`Flags:`, `Examples:`) - Accent color for navigation +2. **Flag names** (`--file`) - Bold for scannability +3. **Type annotations** (`string`) - Muted, reference info +4. **Default values** (`(default: ...)`) - Muted, secondary +5. **Descriptions** - Plain, primary content +6. **Examples** - Plain, copy-paste friendly + +## References + +- Tufte, E. (2001). *The Visual Display of Quantitative Information* +- [Ayu Theme Colors](https://github.com/ayu-theme/ayu-colors) +- [Lipgloss - Terminal Styling](https://github.com/charmbracelet/lipgloss) +- [WCAG Color Contrast Guidelines](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) diff --git a/go.mod b/go.mod index eabf5493..fd6300e9 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( 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/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 github.com/ncruces/go-sqlite3 v0.30.3 github.com/spf13/cobra v1.10.2 @@ -39,7 +38,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // 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 diff --git a/go.sum b/go.sum index 034f61b6..e4f60699 100644 --- a/go.sum +++ b/go.sum @@ -47,8 +47,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp 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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -65,9 +63,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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= @@ -138,7 +133,6 @@ golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/ui/styles.go b/internal/ui/styles.go index a7ca8fb7..a50fe949 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -3,6 +3,7 @@ package ui import ( + "fmt" "strings" "github.com/charmbracelet/lipgloss" @@ -11,8 +12,9 @@ import ( // Ayu theme color palette // Dark: https://terminalcolors.com/themes/ayu/dark/ // Light: https://terminalcolors.com/themes/ayu/light/ +// Source: https://github.com/ayu-theme/ayu-colors var ( - // Semantic status colors (Ayu theme - adaptive light/dark) + // Core semantic colors (Ayu theme - adaptive light/dark) ColorPass = lipgloss.AdaptiveColor{ Light: "#86b300", // ayu light bright green Dark: "#c2d94c", // ayu dark bright green @@ -33,9 +35,86 @@ var ( Light: "#399ee6", // ayu light bright blue Dark: "#59c2ff", // ayu dark bright blue } + + // === Workflow Status Colors === + // Only actionable states get color - open/closed match standard text + ColorStatusOpen = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } + ColorStatusInProgress = lipgloss.AdaptiveColor{ + Light: "#f2ae49", // yellow - active work, very visible + Dark: "#ffb454", + } + ColorStatusClosed = lipgloss.AdaptiveColor{ + Light: "#9099a1", // slightly dimmed - visually shows "done" + Dark: "#8090a0", + } + ColorStatusBlocked = lipgloss.AdaptiveColor{ + Light: "#f07171", // red - needs attention + Dark: "#f26d78", + } + ColorStatusPinned = lipgloss.AdaptiveColor{ + Light: "#d2a6ff", // purple - special/elevated + Dark: "#d2a6ff", + } + + // === Priority Colors === + // Only P0/P1 get color - P2/P3/P4 match standard text + ColorPriorityP0 = lipgloss.AdaptiveColor{ + Light: "#f07171", // bright red - critical + Dark: "#f07178", + } + ColorPriorityP1 = lipgloss.AdaptiveColor{ + Light: "#ff8f40", // orange - high urgency + Dark: "#ff8f40", + } + ColorPriorityP2 = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } + ColorPriorityP3 = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } + ColorPriorityP4 = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } + + // === Issue Type Colors === + // Bugs and epics get color - they need attention + // All other types use standard text + ColorTypeBug = lipgloss.AdaptiveColor{ + Light: "#f07171", // bright red - bugs are problems + Dark: "#f26d78", + } + ColorTypeFeature = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } + ColorTypeTask = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } + ColorTypeEpic = lipgloss.AdaptiveColor{ + Light: "#d2a6ff", // purple - larger scope work + Dark: "#d2a6ff", + } + ColorTypeChore = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } + + // === Issue ID Color === + // IDs use standard text color - subtle, not attention-grabbing + ColorID = lipgloss.AdaptiveColor{ + Light: "", // standard text color + Dark: "", + } ) -// Status styles - consistent across all commands +// Core styles - consistent across all commands var ( PassStyle = lipgloss.NewStyle().Foreground(ColorPass) WarnStyle = lipgloss.NewStyle().Foreground(ColorWarn) @@ -44,6 +123,36 @@ var ( AccentStyle = lipgloss.NewStyle().Foreground(ColorAccent) ) +// Issue ID style +var IDStyle = lipgloss.NewStyle().Foreground(ColorID) + +// Status styles for workflow states +var ( + StatusOpenStyle = lipgloss.NewStyle().Foreground(ColorStatusOpen) + StatusInProgressStyle = lipgloss.NewStyle().Foreground(ColorStatusInProgress) + StatusClosedStyle = lipgloss.NewStyle().Foreground(ColorStatusClosed) + StatusBlockedStyle = lipgloss.NewStyle().Foreground(ColorStatusBlocked) + StatusPinnedStyle = lipgloss.NewStyle().Foreground(ColorStatusPinned) +) + +// Priority styles +var ( + PriorityP0Style = lipgloss.NewStyle().Foreground(ColorPriorityP0).Bold(true) + PriorityP1Style = lipgloss.NewStyle().Foreground(ColorPriorityP1) + PriorityP2Style = lipgloss.NewStyle().Foreground(ColorPriorityP2) + PriorityP3Style = lipgloss.NewStyle().Foreground(ColorPriorityP3) + PriorityP4Style = lipgloss.NewStyle().Foreground(ColorPriorityP4) +) + +// Type styles for issue categories +var ( + TypeBugStyle = lipgloss.NewStyle().Foreground(ColorTypeBug) + TypeFeatureStyle = lipgloss.NewStyle().Foreground(ColorTypeFeature) + TypeTaskStyle = lipgloss.NewStyle().Foreground(ColorTypeTask) + TypeEpicStyle = lipgloss.NewStyle().Foreground(ColorTypeEpic) + TypeChoreStyle = lipgloss.NewStyle().Foreground(ColorTypeChore) +) + // CategoryStyle for section headers - bold with accent color var CategoryStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorAccent) @@ -128,3 +237,125 @@ func RenderSkipIcon() string { func RenderInfoIcon() string { return AccentStyle.Render(IconInfo) } + +// === Issue Component Renderers === + +// RenderID renders an issue ID with semantic styling +func RenderID(id string) string { + return IDStyle.Render(id) +} + +// RenderStatus renders a status with semantic styling +// in_progress/blocked/pinned get color; open/closed use standard text +func RenderStatus(status string) string { + switch status { + case "in_progress": + return StatusInProgressStyle.Render(status) + case "blocked": + return StatusBlockedStyle.Render(status) + case "pinned": + return StatusPinnedStyle.Render(status) + case "closed": + return StatusClosedStyle.Render(status) + default: // open and others + return StatusOpenStyle.Render(status) + } +} + +// RenderPriority renders a priority level with semantic styling +// P0/P1 get color; P2/P3/P4 use standard text +func RenderPriority(priority int) string { + label := fmt.Sprintf("P%d", 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 + } +} + +// RenderType renders an issue type with semantic styling +// bugs get color; all other types use standard text +func RenderType(issueType string) string { + switch issueType { + case "bug": + return TypeBugStyle.Render(issueType) + case "feature": + return TypeFeatureStyle.Render(issueType) + case "task": + return TypeTaskStyle.Render(issueType) + case "epic": + return TypeEpicStyle.Render(issueType) + case "chore": + return TypeChoreStyle.Render(issueType) + default: + return issueType + } +} + +// RenderIssueCompact renders a compact one-line issue summary +// Format: ID [Priority] [Type] Status - Title +// When status is "closed", the entire line is dimmed to show it's done +func RenderIssueCompact(id string, priority int, issueType, status, title string) string { + line := fmt.Sprintf("%s [P%d] [%s] %s - %s", + id, priority, issueType, status, title) + if status == "closed" { + // Entire line is dimmed - visually shows "done" + return StatusClosedStyle.Render(line) + } + return fmt.Sprintf("%s [%s] [%s] %s - %s", + RenderID(id), + RenderPriority(priority), + RenderType(issueType), + RenderStatus(status), + title, + ) +} + +// RenderPriorityForStatus renders priority with color only if not closed +func RenderPriorityForStatus(priority int, status string) string { + if status == "closed" { + return fmt.Sprintf("P%d", priority) + } + return RenderPriority(priority) +} + +// RenderTypeForStatus renders type with color only if not closed +func RenderTypeForStatus(issueType, status string) string { + if status == "closed" { + return issueType + } + return RenderType(issueType) +} + +// RenderClosedLine renders an entire line in the closed/dimmed style +func RenderClosedLine(line string) string { + return StatusClosedStyle.Render(line) +} + +// BoldStyle for emphasis +var BoldStyle = lipgloss.NewStyle().Bold(true) + +// CommandStyle for command names - subtle contrast, not attention-grabbing +var CommandStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ + Light: "#5c6166", // slightly darker than standard + Dark: "#bfbdb6", // slightly brighter than standard +}) + +// RenderBold renders text in bold +func RenderBold(s string) string { + return BoldStyle.Render(s) +} + +// RenderCommand renders a command name with subtle styling +func RenderCommand(s string) string { + return CommandStyle.Render(s) +}