From daaecaf22f524c53aebbe259420bf792114bd651 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:14:49 -0400 Subject: [PATCH 1/3] feat(cli): show ZERO wordmark on --version The TUI's empty-state ZERO ascii art gets exposed via a new tui.Wordmark helper and printed above the version line for `zero -v`/`--version`. Coloring only applies when stdout is a real TTY and NO_COLOR isn't set, so redirected or piped output (scripts parsing the version) stays plain. --- internal/cli/app.go | 2 +- internal/cli/app_test.go | 10 ++++++++-- internal/tui/startup.go | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index a138f0142..39c9b15dd 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -339,7 +339,7 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps return 0 } } - if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil { + if _, err := fmt.Fprintf(stdout, "%s\n\nversion: %s\n", tui.Wordmark(stdout), version); err != nil { return 1 } return 0 diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index d56bd9063..1d680c20e 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -41,8 +41,14 @@ func TestRunPrintsVersion(t *testing.T) { if exitCode != 0 { t.Fatalf("expected exit code 0, got %d", exitCode) } - if got := stdout.String(); got != "zero dev\n" { - t.Fatalf("expected version output, got %q", got) + wantWordmark := "███████╗███████╗██████╗ ██████╗ \n" + + "╚══███╔╝██╔════╝██╔══██╗██╔═══██╗\n" + + " ███╔╝ █████╗ ██████╔╝██║ ██║\n" + + " ███╔╝ ██╔══╝ ██╔══██╗██║ ██║\n" + + "███████╗███████╗██║ ██║╚██████╔╝\n" + + "╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ \n" + if got, want := stdout.String(), wantWordmark+"\nversion: dev\n"; got != want { + t.Fatalf("expected version output %q, got %q", want, got) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) diff --git a/internal/tui/startup.go b/internal/tui/startup.go index 9e67e1b8a..26f1212b0 100644 --- a/internal/tui/startup.go +++ b/internal/tui/startup.go @@ -1,10 +1,13 @@ package tui import ( + "io" + "os" "strings" "unicode/utf8" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" ) const ( @@ -137,6 +140,40 @@ func zeroWordmarkLines() []string { return lines } +// Wordmark renders the brand ASCII art shown on the TUI's empty state, for +// reuse outside the TUI (e.g. `zero --version`). w decides whether color is +// applied: lipgloss.Style.Render doesn't check isatty on its own, so without +// this gate every `zero --version > file` or `| grep` would carry raw ANSI. +func Wordmark(w io.Writer) string { + lines := zeroWordmarkPlainLines() + if wordmarkColorEnabled(w) { + lines = zeroWordmarkLines() + } + return strings.Join(lines, "\n") +} + +func zeroWordmarkPlainLines() []string { + lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines))) + for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ { + lines = append(lines, zeroWordmarkPrefixLines[index]+zeroWordmarkOLines[index]) + } + return lines +} + +// wordmarkColorEnabled honors NO_COLOR and requires w to be a real terminal, +// matching the no-color.org handling already applied to the interactive TUI +// in run.go. +func wordmarkColorEnabled(w io.Writer) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + f, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(f.Fd()) +} + func borderedBlock(width int, lines []string) string { return styledBlock(width, lines, zeroTheme.line) } From 051d761feaab11736a576acdc21133a8e22d97b0 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:18:51 -0400 Subject: [PATCH 2/3] fix(release): match --version output by suffix, not exact string The release smoke check compared zero --version output to an exact "zero " string, which broke once --version started printing the wordmark above the version line. Check for a "version: " suffix instead so the smoke check doesn't couple to the wordmark art. --- internal/release/release.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/release/release.go b/internal/release/release.go index 1690ca315..0fdef7bee 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -675,9 +675,9 @@ func smokeVersion(ctx context.Context, binaryPath string, version string) error } return fmt.Errorf("smoke release binary: %s", output) } - expected := "zero " + version - if output != expected { - return fmt.Errorf("expected %s --version to print %s, got %s", filepath.Base(binaryPath), expected, output) + expected := "version: " + version + if !strings.HasSuffix(output, expected) { + return fmt.Errorf("expected %s --version to end with %s, got %s", filepath.Base(binaryPath), expected, output) } return nil } From 1cdd3d08662e8a80db68c14485ff8d8dc9193686 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:18:21 -0400 Subject: [PATCH 3/3] fix(cli,tui): keep --version machine-readable off-TTY, drop wordmark color - When stdout is not a terminal, --version prints exactly "zero " again: pipes, redirects, command substitutions, and the release smoke contract all parse that single record, so the banner is a TTY-only affordance now. - The TTY path keeps the banner but restores "zero " as the version record instead of "version: ". - Wordmark renders uncolored: only newModel resolves --theme/ZERO_THEME, so painting the global dark palette here was unreadable on light terminals. The terminal's default foreground works on any background. - smokeVersion goes back to requiring the exact "zero " output instead of the suffix match that papered over the banner. --- internal/cli/app.go | 24 +++++++++++++++++++++- internal/cli/app_test.go | 39 ++++++++++++++++++++++++++++-------- internal/release/release.go | 6 +++--- internal/tui/startup.go | 36 ++++++--------------------------- internal/tui/startup_test.go | 19 ++++++++++++++++++ 5 files changed, 82 insertions(+), 42 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 39c9b15dd..b15e0fd29 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -42,6 +42,7 @@ import ( "github.com/Gitlawb/zero/internal/worktrees" "github.com/Gitlawb/zero/internal/zerogit" "github.com/Gitlawb/zero/internal/zeroruntime" + "github.com/charmbracelet/x/term" ) var version = "dev" @@ -339,7 +340,17 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps return 0 } } - if _, err := fmt.Fprintf(stdout, "%s\n\nversion: %s\n", tui.Wordmark(stdout), version); err != nil { + // Pipes and redirects keep the machine-readable contract exactly as it + // was: a single "zero " line. Scripts, command substitutions, + // and the NPM wrapper smoke check all parse that record, so the banner + // is strictly a TTY affordance. + if !stdoutIsTerminal(stdout) { + if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil { + return 1 + } + return 0 + } + if _, err := fmt.Fprintf(stdout, "%s\n\nzero %s\n", tui.Wordmark(), version); err != nil { return 1 } return 0 @@ -1115,6 +1126,17 @@ func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) { } } +// stdoutIsTerminal reports whether w is an interactive terminal. Tests pass +// bytes.Buffer writers and pipes/redirects fail term.IsTerminal, so both take +// the machine-readable path. +func stdoutIsTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + return term.IsTerminal(f.Fd()) +} + func writeAppError(stderr io.Writer, message string, exitCode int) int { if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil { return 1 diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 1d680c20e..bbcd20ad8 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -41,14 +41,37 @@ func TestRunPrintsVersion(t *testing.T) { if exitCode != 0 { t.Fatalf("expected exit code 0, got %d", exitCode) } - wantWordmark := "███████╗███████╗██████╗ ██████╗ \n" + - "╚══███╔╝██╔════╝██╔══██╗██╔═══██╗\n" + - " ███╔╝ █████╗ ██████╔╝██║ ██║\n" + - " ███╔╝ ██╔══╝ ██╔══██╗██║ ██║\n" + - "███████╗███████╗██║ ██║╚██████╔╝\n" + - "╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ \n" - if got, want := stdout.String(), wantWordmark+"\nversion: dev\n"; got != want { - t.Fatalf("expected version output %q, got %q", want, got) + // Non-terminal stdout keeps the machine-readable contract: exactly one + // "zero " record, no wordmark banner, no ANSI. + if got := stdout.String(); got != "zero dev\n" { + t.Fatalf("expected version output, got %q", got) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr, got %q", stderr.String()) + } +} + +// TestRunVersionRedirectedFile covers the `zero --version > file` path: stdout +// is a real *os.File but not a terminal, so the single-line contract must hold. +func TestRunVersionRedirectedFile(t *testing.T) { + stdout, err := os.CreateTemp(t.TempDir(), "version-stdout") + if err != nil { + t.Fatalf("create temp stdout: %v", err) + } + defer stdout.Close() + var stderr bytes.Buffer + + exitCode := Run([]string{"--version"}, stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + content, err := os.ReadFile(stdout.Name()) + if err != nil { + t.Fatalf("read temp stdout: %v", err) + } + if got := string(content); got != "zero dev\n" { + t.Fatalf("expected version output, got %q", got) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) diff --git a/internal/release/release.go b/internal/release/release.go index 0fdef7bee..1690ca315 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -675,9 +675,9 @@ func smokeVersion(ctx context.Context, binaryPath string, version string) error } return fmt.Errorf("smoke release binary: %s", output) } - expected := "version: " + version - if !strings.HasSuffix(output, expected) { - return fmt.Errorf("expected %s --version to end with %s, got %s", filepath.Base(binaryPath), expected, output) + expected := "zero " + version + if output != expected { + return fmt.Errorf("expected %s --version to print %s, got %s", filepath.Base(binaryPath), expected, output) } return nil } diff --git a/internal/tui/startup.go b/internal/tui/startup.go index 26f1212b0..69c4c1566 100644 --- a/internal/tui/startup.go +++ b/internal/tui/startup.go @@ -1,13 +1,10 @@ package tui import ( - "io" - "os" "strings" "unicode/utf8" "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/term" ) const ( @@ -141,37 +138,16 @@ func zeroWordmarkLines() []string { } // Wordmark renders the brand ASCII art shown on the TUI's empty state, for -// reuse outside the TUI (e.g. `zero --version`). w decides whether color is -// applied: lipgloss.Style.Render doesn't check isatty on its own, so without -// this gate every `zero --version > file` or `| grep` would carry raw ANSI. -func Wordmark(w io.Writer) string { - lines := zeroWordmarkPlainLines() - if wordmarkColorEnabled(w) { - lines = zeroWordmarkLines() - } - return strings.Join(lines, "\n") -} - -func zeroWordmarkPlainLines() []string { +// reuse outside the TUI (e.g. `zero --version`). It is deliberately +// uncolored: only newModel resolves --theme/ZERO_THEME, so painting the +// global palette here would ignore a selected light theme and be unreadable +// on light terminals. The terminal's default foreground works everywhere. +func Wordmark() string { lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines))) for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ { lines = append(lines, zeroWordmarkPrefixLines[index]+zeroWordmarkOLines[index]) } - return lines -} - -// wordmarkColorEnabled honors NO_COLOR and requires w to be a real terminal, -// matching the no-color.org handling already applied to the interactive TUI -// in run.go. -func wordmarkColorEnabled(w io.Writer) bool { - if os.Getenv("NO_COLOR") != "" { - return false - } - f, ok := w.(*os.File) - if !ok { - return false - } - return term.IsTerminal(f.Fd()) + return strings.Join(lines, "\n") } func borderedBlock(width int, lines []string) string { diff --git a/internal/tui/startup_test.go b/internal/tui/startup_test.go index 13e677827..9408cbc7c 100644 --- a/internal/tui/startup_test.go +++ b/internal/tui/startup_test.go @@ -21,6 +21,25 @@ func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) { assertNotContains(t, view, "fix the failing test in internal/tools") } +// TestWordmarkIsPlain guards the --version banner: it must carry no ANSI +// escapes, because this renderer never resolves --theme/ZERO_THEME and any +// palette color could be unreadable on the user's background. +func TestWordmarkIsPlain(t *testing.T) { + wordmark := Wordmark() + if strings.Contains(wordmark, "\x1b") { + t.Fatalf("expected uncolored wordmark, got %q", wordmark) + } + lines := strings.Split(wordmark, "\n") + if len(lines) != len(zeroWordmarkPrefixLines) { + t.Fatalf("expected %d wordmark lines, got %d", len(zeroWordmarkPrefixLines), len(lines)) + } + for index, line := range lines { + if want := zeroWordmarkPrefixLines[index] + zeroWordmarkOLines[index]; line != want { + t.Fatalf("wordmark line %d: expected %q, got %q", index, want, line) + } + } +} + func TestEmptyStateShowsVersion(t *testing.T) { m := newModel(context.Background(), Options{Version: "0.2.0"}) m.width, m.height = 100, 30