diff --git a/dispatcher.go b/dispatcher.go index 6c18e33..121fd8a 100644 --- a/dispatcher.go +++ b/dispatcher.go @@ -6,8 +6,34 @@ import ( "os" "sort" "strings" + "sync" + + "golang.org/x/term" ) +var stdoutIsTerminal = sync.OnceValue(func() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +}) + +// faint returns the string wrapped in ANSI faint styling if stdout is a TTY +// and the NO_COLOR environment variable is not set. +func faint(s string) string { + if os.Getenv("NO_COLOR") != "" { + return s + } + if !stdoutIsTerminal() { + return s + } + return "\033[2m" + s + "\033[0m" +} + +func subCommandsLabel(n int) string { + if n == 1 { + return "(1 sub-command)" + } + return fmt.Sprintf("(%d sub-commands)", n) +} + // Command is an interface for executable commands type Command interface { // FlagSet returns the flagset for this command @@ -443,10 +469,16 @@ func (d *Dispatcher) showHelp() error { } } - // Print commands with usage for _, child := range children { + grandchildren := d.getDirectChildren(child.Path) + suffix := "" + if len(grandchildren) > 0 { + suffix = " " + faint(subCommandsLabel(len(grandchildren))) + } if child.Usage != "" { - fmt.Printf(" %-*s %s\n", maxLen+2, child.Name, child.Usage) + fmt.Printf(" %-*s %s%s\n", maxLen+2, child.Name, child.Usage, suffix) + } else if suffix != "" { + fmt.Printf(" %-*s %s\n", maxLen+2, child.Name, suffix) } else { fmt.Printf(" %s\n", child.Name) } @@ -568,8 +600,15 @@ func (d *Dispatcher) showCommandHelp(entry *CommandEntry) error { // Print sub-commands with usage for _, child := range children { + grandchildren := d.getDirectChildren(child.Path) + suffix := "" + if len(grandchildren) > 0 { + suffix = " " + faint(subCommandsLabel(len(grandchildren))) + } if child.Usage != "" { - fmt.Printf(" %-*s %s\n", maxLen+2, child.Name, child.Usage) + fmt.Printf(" %-*s %s%s\n", maxLen+2, child.Name, child.Usage, suffix) + } else if suffix != "" { + fmt.Printf(" %-*s %s\n", maxLen+2, child.Name, suffix) } else { fmt.Printf(" %s\n", child.Name) } @@ -704,8 +743,15 @@ func (d *Dispatcher) showNamespaceHelp(namespacePath string) error { } for _, child := range children { + grandchildren := d.getDirectChildren(child.Path) + suffix := "" + if len(grandchildren) > 0 { + suffix = " " + faint(subCommandsLabel(len(grandchildren))) + } if child.Usage != "" { - fmt.Printf(" %-*s %s\n", maxLen+2, child.Name, child.Usage) + fmt.Printf(" %-*s %s%s\n", maxLen+2, child.Name, child.Usage, suffix) + } else if suffix != "" { + fmt.Printf(" %-*s %s\n", maxLen+2, child.Name, suffix) } else { fmt.Printf(" %s\n", child.Name) } diff --git a/dispatcher_test.go b/dispatcher_test.go index 43f7a92..807a18c 100644 --- a/dispatcher_test.go +++ b/dispatcher_test.go @@ -963,6 +963,8 @@ func TestDispatcherSubCommandHelp(t *testing.T) { assert.Contains(t, output, "Update remote refs") // Should not show nested sub-command "remote add", only direct children assert.NotContains(t, output, "remote add") + // "remote" is a namespace with 1 sub-command, should show the count + assert.Contains(t, output, "(1 sub-command)") } func TestDispatcherNamespaceDiscovery(t *testing.T) { @@ -1000,6 +1002,7 @@ func TestDispatcherNamespaceDiscovery(t *testing.T) { assert.Contains(t, output, "Available commands:") assert.Contains(t, output, "build") assert.Contains(t, output, "config") + assert.Contains(t, output, "(2 sub-commands)") // Should NOT show the full subcommand paths at the top level assert.NotContains(t, output, "config get") assert.NotContains(t, output, "config set") diff --git a/go.mod b/go.mod index 79ce28b..6835cea 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cc8b3f4..a19b998 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=