From 30a4ff4091eedbe02c8ffd3ad7bc25b7175b08a6 Mon Sep 17 00:00:00 2001 From: Ajmal Khan Date: Thu, 4 Dec 2025 09:06:54 +0000 Subject: [PATCH] feat: add comprehensive color support and UI enhancements Add rich terminal color support with automatic detection: - Add color constants (Green, Red, Yellow, Cyan, ProlificBlue) - Add Unicode and ASCII symbol support for cross-platform compatibility - Implement TTY detection and NO_COLOR environment variable support - Add helper functions: Success(), Error(), Warn(), Info(), Dim(), Bold(), Highlight() - Add Write* functions for direct stderr/stdout output - Implement shouldUseColor() for conditional formatting - Initialize color profile detection (TrueColor/256/16/ASCII) - Add RenderBanner() function for displaying ASCII art banners --- cmd/root.go | 83 ++++++++++++++++++++++- ui/ui.go | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 4 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 3d436f6..6ebc654 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -21,6 +21,7 @@ import ( "github.com/prolific-oss/cli/cmd/submission" "github.com/prolific-oss/cli/cmd/user" "github.com/prolific-oss/cli/cmd/workspace" + "github.com/prolific-oss/cli/ui" "github.com/prolific-oss/cli/version" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -31,9 +32,15 @@ var cfgFile string // ApplicationName is the name of the cli binary const ApplicationName = "prolific" +// BannerFilePath is the path to the ASCII art banner file +const BannerFilePath = "banner.txt" + // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once func Execute() { + // Register custom template functions before building commands + registerTemplateFuncs() + // We need the configuration loaded before we create a NewCli // as that needs the viper configuration up and running initConfig() @@ -43,7 +50,7 @@ func Execute() { // Execute the application if err := cmd.Execute(); err != nil { - fmt.Println(err) + ui.WriteError(err.Error()) os.Exit(1) } } @@ -51,9 +58,17 @@ func Execute() { // NewRootCommand builds the main cli application and // adds all the child commands func NewRootCommand() *cobra.Command { + // Load banner from file + banner, err := os.ReadFile(BannerFilePath) + bannerText := "" + if err == nil { + bannerText = string(banner) + } + var cmd = &cobra.Command{ Use: ApplicationName, Short: "CLI application for retrieving data from the Prolific Platform", + Long: ui.RenderBanner(bannerText) + "\nCLI application for retrieving data from the Prolific Platform", Version: version.GITCOMMIT, } @@ -81,9 +96,73 @@ func NewRootCommand() *cobra.Command { workspace.NewWorkspaceCommand(&client, w), ) + // Apply custom templates to all commands recursively (including root) + applyTemplateToAllCommands(cmd) + return cmd } +// applyTemplateToAllCommands recursively applies custom help templates to all commands +func applyTemplateToAllCommands(cmd *cobra.Command) { + cmd.SetHelpTemplate(getHelpTemplate()) + cmd.SetUsageTemplate(getUsageTemplate()) + + for _, subCmd := range cmd.Commands() { + applyTemplateToAllCommands(subCmd) + } +} + +// getHelpTemplate returns a custom help template with colors +func getHelpTemplate() string { + return `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} + +{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}` +} + +// getUsageTemplate returns a custom usage template with colors +func getUsageTemplate() string { + return `{{bold "Usage:"}}{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +{{bold "Aliases:"}} + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +{{bold "Examples:"}} +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} + +{{bold "Available Commands:"}}{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{highlight (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} + +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{highlight (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} + +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{highlight (rpad .Name .NamePadding)}} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +{{bold "Flags:"}} +{{.LocalFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +{{bold "Global Flags:"}} +{{.InheritedFlags.FlagUsagesWrapped 100 | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{highlight (rpad .CommandPath .CommandPathPadding)}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +{{dim (print "Use \"" .CommandPath " [command] --help\" for more information about a command.")}}{{end}} +` +} + +// registerTemplateFuncs registers custom template functions for cobra commands +func registerTemplateFuncs() { + cobra.AddTemplateFunc("trimTrailingWhitespaces", func(s string) string { + return strings.TrimRight(s, " \t") + }) + cobra.AddTemplateFunc("bold", ui.Bold) + cobra.AddTemplateFunc("highlight", ui.Highlight) + cobra.AddTemplateFunc("dim", ui.Dim) +} + // initConfig reads in config file and ENV variables if set. func initConfig() { if cfgFile != "" { @@ -93,7 +172,7 @@ func initConfig() { // Find home directory. home, err := homedir.Dir() if err != nil { - fmt.Println(err) + ui.WriteError(err.Error()) os.Exit(1) } diff --git a/ui/ui.go b/ui/ui.go index c3b3a04..bbcebb5 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -2,8 +2,13 @@ package ui import ( "fmt" + "io" + "os" + "sync" "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" + "github.com/muesli/termenv" "github.com/prolific-oss/cli/config" "github.com/prolific-oss/cli/model" "golang.org/x/text/currency" @@ -14,19 +19,80 @@ import ( const ( // DarkGrey is the colour for Grey DarkGrey = "#989898" + + // Color constants for minimal formatting + Green = "#00D787" + Red = "#FF6B6B" + Yellow = "#FFD93D" + Cyan = "#6FC3DF" + ProlificBlue = "#0F2BC9" // Official Prolific brand blue +) + +// Symbol constants +const ( + // Unicode symbols for TTY output + SymbolSuccess = "✓" + SymbolError = "✗" + SymbolWarning = "!" + SymbolInfo = "•" + + // ASCII fallback symbols for non-TTY + SymbolSuccessAscii = "[ok]" + SymbolErrorAscii = "[error]" + SymbolWarningAscii = "[warn]" + SymbolInfoAscii = "[info]" ) // AppDateTimeFormat The format for date/times in the application. const AppDateTimeFormat string = "02-01-2006 15:04" +var ( + isTTYStdout = isatty.IsTerminal(os.Stdout.Fd()) + isTTYStderr = isatty.IsTerminal(os.Stderr.Fd()) + noColor = os.Getenv("NO_COLOR") != "" + colorProfileOnce sync.Once +) + +// initColorProfile sets lipgloss color profile based on terminal capabilities +// This is called lazily using sync.Once to ensure it runs exactly once +func initColorProfile() { + colorProfileOnce.Do(func() { + if noColor { + lipgloss.SetColorProfile(termenv.Ascii) + } else if isTTYStdout { + // Auto-detect color profile for the terminal + lipgloss.SetColorProfile(termenv.EnvColorProfile()) + } else { + lipgloss.SetColorProfile(termenv.Ascii) + } + }) +} + +// Pre-configured lipgloss styles for consistent, vibrant output +var ( + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Green)).Bold(true) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Red)).Bold(true) + warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Yellow)).Bold(true) + infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(DarkGrey)) + boldStyle = lipgloss.NewStyle().Bold(true) + highlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(Cyan)) // Cyan color, no underline +) + // RenderSectionMarker will render a section marker in the output. func RenderSectionMarker() string { - return fmt.Sprintf("\n%s\n\n", lipgloss.NewStyle().Foreground(lipgloss.Color(DarkGrey)).Render("---")) + if !shouldUseColor(isTTYStdout) { + return "\n---\n\n" + } + return fmt.Sprintf("\n%s\n\n", dimStyle.Render("---")) } // RenderHeading will render a heading in the output. func RenderHeading(heading string) string { - return lipgloss.NewStyle().Bold(true).Render(heading) + if !shouldUseColor(isTTYStdout) { + return heading + } + return boldStyle.Render(heading) } // RenderMoney will return a symbolised string of money, e.g. £10.00 @@ -61,6 +127,121 @@ func RenderApplicationLink(entity, slug string) string { return content } +// shouldUseColor returns true if color output should be used based on TTY detection and NO_COLOR env. +func shouldUseColor(isTTY bool) bool { + initColorProfile() // Lazy initialization + return isTTY && !noColor +} + +// Success renders a success message with a green checkmark. +// Output format: "✓ message" (or "[ok] message" for non-TTY). +func Success(msg string) string { + if !shouldUseColor(isTTYStdout) { + return fmt.Sprintf("%s %s", SymbolSuccessAscii, msg) + } + symbol := successStyle.Render(SymbolSuccess) + return fmt.Sprintf("%s %s", symbol, msg) +} + +// Error renders an error message with a red X. +// Output format: "✗ Error: message" (or "[error] Error: message" for non-TTY). +// This should be written to stderr. +func Error(msg string) string { + if !shouldUseColor(isTTYStderr) { + return fmt.Sprintf("%s Error: %s", SymbolErrorAscii, msg) + } + symbol := errorStyle.Render(SymbolError) + errorText := errorStyle.Render("Error:") + return fmt.Sprintf("%s %s %s", symbol, errorText, msg) +} + +// ErrorWithHint renders an error message with a hint for next steps. +// The hint is displayed on a new line with 2-space indentation. +func ErrorWithHint(msg, hint string) string { + errorLine := Error(msg) + dimmedHint := Dim(hint) + return fmt.Sprintf("%s\n\n %s", errorLine, dimmedHint) +} + +// Warn renders a warning message with a yellow exclamation mark. +// Output format: "! Warning: message" (or "[warn] Warning: message" for non-TTY). +func Warn(msg string) string { + if !shouldUseColor(isTTYStdout) { + return fmt.Sprintf("%s Warning: %s", SymbolWarningAscii, msg) + } + symbol := warningStyle.Render(SymbolWarning) + warningText := warningStyle.Render("Warning:") + return fmt.Sprintf("%s %s %s", symbol, warningText, msg) +} + +// Info renders an informational message with a cyan bullet. +// Output format: "• message" (or "[info] message" for non-TTY). +func Info(msg string) string { + if !shouldUseColor(isTTYStdout) { + return fmt.Sprintf("%s %s", SymbolInfoAscii, msg) + } + symbol := infoStyle.Render(SymbolInfo) + return fmt.Sprintf("%s %s", symbol, msg) +} + +// Dim renders text in a dimmed/grey color for secondary information. +func Dim(text string) string { + if !shouldUseColor(isTTYStdout) { + return text + } + return dimStyle.Render(text) +} + +// Bold renders text in bold (no color). +func Bold(text string) string { + if !shouldUseColor(isTTYStdout) { + return text + } + return boldStyle.Render(text) +} + +// Highlight renders text in cyan for emphasis (URLs, commands, etc.). +func Highlight(text string) string { + if !shouldUseColor(isTTYStdout) { + return text + } + return highlightStyle.Render(text) +} + +// RenderHighlightedText renders text with bold orange background and black foreground. func RenderHighlightedText(text string) string { return lipgloss.NewStyle().Bold(true).Background(lipgloss.Color("#FFA500")).Foreground(lipgloss.Color("#000000")).Render(text) } + +// WriteSuccess writes a success message to the provided writer. +func WriteSuccess(w io.Writer, msg string) { + fmt.Fprintln(w, Success(msg)) +} + +// WriteError writes an error message to stderr. +func WriteError(msg string) { + fmt.Fprintln(os.Stderr, Error(msg)) +} + +// WriteErrorWithHint writes an error message with a hint to stderr. +func WriteErrorWithHint(msg, hint string) { + fmt.Fprintln(os.Stderr, ErrorWithHint(msg, hint)) +} + +// WriteWarn writes a warning message to the provided writer. +func WriteWarn(w io.Writer, msg string) { + fmt.Fprintln(w, Warn(msg)) +} + +// WriteInfo writes an informational message to the provided writer. +func WriteInfo(w io.Writer, msg string) { + fmt.Fprintln(w, Info(msg)) +} + +// RenderBanner renders the ASCII banner in Prolific blue. +func RenderBanner(banner string) string { + if !shouldUseColor(isTTYStdout) { + return banner + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(ProlificBlue)).Render(banner) +}