From 511aa09541aea559b3b9bb639a1f6a2d2769d679 Mon Sep 17 00:00:00 2001 From: Jordan Jackson Date: Tue, 6 Oct 2026 23:42:37 -0400 Subject: [PATCH] fix(render): emit colour only on a TTY, honour NO_COLOR; README: -C operates on the named directory Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ --- README.md | 12 +-- cmd/root.go | 27 +++++- cmd/root_test.go | 35 ++++++++ color_e2e_test.go | 109 +++++++++++++++++++++++ main.go | 8 ++ render/color_mode.go | 124 +++++++++++++++++++++++++++ render/color_mode_test.go | 176 ++++++++++++++++++++++++++++++++++++++ render/colors.go | 56 ++++++++---- render/colors_test.go | 12 ++- render/skyline.go | 7 +- 10 files changed, 540 insertions(+), 26 deletions(-) create mode 100644 color_e2e_test.go create mode 100644 render/color_mode.go create mode 100644 render/color_mode_test.go diff --git a/README.md b/README.md index f6ebab4..4db505a 100644 --- a/README.md +++ b/README.md @@ -231,16 +231,17 @@ override: codemap -C /tmp/independent-clone --setup-root /path/to/original context ``` -`-C`/`--project-root` selects the repository Codemap operates on. -`--setup-root` explicitly reuses `/.codemap` policy and runtime state -from another checkout. Both accept a repository or subdirectory; relative setup -paths resolve from the project root. +`-C`/`--project-root` operates on exactly the named directory, like `git -C`; +it may be a subdirectory of a repository, and storage and config discovery still +walk up to the git root. `--setup-root` explicitly reuses `/.codemap` +policy and runtime state from another checkout. Both accept a repository or +subdirectory; relative setup paths resolve from the project root. | Flag | Description | | Flag | Description | |------|-------------| -| `-C, --project-root ` | Operate on code in `` | +| `-C, --project-root ` | Operate on exactly `` (may be a subdirectory of a repository; storage and config discovery still walk up to the git root) | | `--setup-root ` | Explicitly reuse policy and runtime state from `/.codemap` | | `--depth, -d ` | Limit tree depth (0 = unlimited) | | `--only ` | Only include files with these extensions | @@ -252,6 +253,7 @@ paths resolve from the project root. | `--skyline` | City skyline visualization | | `--animate` | Animate the skyline (with `--skyline`) | | `--json` | Output JSON | +| `--color ` | `auto` (default): ANSI colour only when stdout is a terminal and `NO_COLOR` is unset; `always` keeps colour when piping into `less -R`; `never` turns it off | > Flags come before the path/URL: `codemap --json github.com/user/repo` diff --git a/cmd/root.go b/cmd/root.go index 18b4e52..3376cb9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,8 @@ import ( type GlobalRootOptions struct { Directory string SetupRoot string + // Color is the --color value (auto, always, never); empty means auto. + Color string } // Active reports whether this invocation overrides either root. @@ -73,6 +75,18 @@ func ParseGlobalRootOptions(args []string) (GlobalRootOptions, []string, error) if strings.TrimSpace(opts.SetupRoot) == "" { return GlobalRootOptions{}, nil, fmt.Errorf("--setup-root requires a path") } + case arg == "--color": + // Bare --color means always, like ls and grep; "--color " is also accepted. + opts.Color = "always" + if i+1 < len(args) && isColorMode(args[i+1]) { + i++ + opts.Color = args[i] + } + case strings.HasPrefix(arg, "--color="): + opts.Color = strings.TrimPrefix(arg, "--color=") + if !isColorMode(opts.Color) { + return GlobalRootOptions{}, nil, fmt.Errorf("--color: unknown mode %q (want auto, always or never)", opts.Color) + } default: remaining = append(remaining, arg) } @@ -82,8 +96,17 @@ func ParseGlobalRootOptions(args []string) (GlobalRootOptions, []string, error) } func isGlobalRootOption(arg string) bool { - return arg == "-C" || arg == "--project-root" || arg == "--setup-root" || - strings.HasPrefix(arg, "--project-root=") || strings.HasPrefix(arg, "--setup-root=") + return arg == "-C" || arg == "--project-root" || arg == "--setup-root" || arg == "--color" || + strings.HasPrefix(arg, "--project-root=") || strings.HasPrefix(arg, "--setup-root=") || + strings.HasPrefix(arg, "--color=") +} + +func isColorMode(arg string) bool { + switch strings.ToLower(arg) { + case "auto", "always", "never": + return true + } + return false } // ResolveGlobalRoots resolves both inputs with nearest-repository recovery. diff --git a/cmd/root_test.go b/cmd/root_test.go index c8d9846..9f9c8c9 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -410,6 +410,41 @@ func TestParseGlobalRootOptions(t *testing.T) { } }) + t.Run("color accepts equals, separate value and bare forms", func(t *testing.T) { + cases := []struct { + args []string + wantColor string + wantArgs []string + }{ + {[]string{"--color=never", "."}, "never", []string{"."}}, + {[]string{"--color", "always", "."}, "always", []string{"."}}, + {[]string{"--color", "."}, "always", []string{"."}}, + {[]string{"hook", "session-start", "--color=auto"}, "auto", []string{"hook", "session-start"}}, + {[]string{"."}, "", []string{"."}}, + } + for _, tc := range cases { + opts, args, err := ParseGlobalRootOptions(tc.args) + if err != nil { + t.Fatalf("ParseGlobalRootOptions(%#v) error: %v", tc.args, err) + } + if opts.Color != tc.wantColor { + t.Fatalf("ParseGlobalRootOptions(%#v).Color = %q, want %q", tc.args, opts.Color, tc.wantColor) + } + if opts.Active() { + t.Fatalf("--color alone must not activate root options: %#v", opts) + } + if !reflect.DeepEqual(args, tc.wantArgs) { + t.Fatalf("args = %#v, want %#v", args, tc.wantArgs) + } + } + if _, _, err := ParseGlobalRootOptions([]string{"--color=sometimes"}); err == nil { + t.Fatal("ParseGlobalRootOptions(--color=sometimes) unexpectedly succeeded") + } + if _, _, err := ParseGlobalRootOptions([]string{"-C", "--color=never"}); err == nil { + t.Fatal("ParseGlobalRootOptions(-C --color=never) unexpectedly succeeded") + } + }) + for _, args := range [][]string{{"-C"}, {"--project-root"}, {"--project-root="}, {"--setup-root"}, {"--setup-root="}} { args := args t.Run(strings.Join(args, "_"), func(t *testing.T) { diff --git a/color_e2e_test.go b/color_e2e_test.go new file mode 100644 index 0000000..28ee11e --- /dev/null +++ b/color_e2e_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// runPipedCodemap runs the built binary with stdout captured in a pipe (never a +// terminal), the way agents, hooks and shells pipes see it. +func runPipedCodemap(t *testing.T, dir string, env []string, args ...string) (string, string) { + t.Helper() + command := exec.Command(codemapTestBinaryPath, args...) + command.Dir = dir + command.Env = append(os.Environ(), env...) + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + t.Fatalf("codemap %s failed: %v\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), err, stdout.String(), stderr.String()) + } + return stdout.String(), stderr.String() +} + +func TestColorEndToEnd(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "src/main.go": "package main\n", + "src/util.go": "package main\n", + "README.md": "# demo\n", + "web/app.html": "\n", + } { + if err := os.MkdirAll(filepath.Dir(filepath.Join(repo, name)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + + t.Run("piped output carries no ANSI escapes", func(t *testing.T) { + out, _ := runPipedCodemap(t, repo, nil, ".") + if strings.Contains(out, "\x1b[") { + t.Fatalf("piped `codemap .` emitted ANSI escapes:\n%q", out) + } + for _, want := range []string{"src/", "main", "util", "app.html"} { + if !strings.Contains(out, want) { + t.Fatalf("piped output lost %q:\n%s", want, out) + } + } + }) + + t.Run("color=always restores ANSI escapes even with NO_COLOR", func(t *testing.T) { + out, _ := runPipedCodemap(t, repo, []string{"NO_COLOR=1"}, "--color=always", ".") + if !strings.Contains(out, "\x1b[1;34m src/\x1b[0m") { + t.Fatalf("`codemap --color=always .` lacks the blue directory escape:\n%q", out) + } + }) + + t.Run("color=never and NO_COLOR are quiet", func(t *testing.T) { + for _, tc := range []struct { + env []string + args []string + }{ + {nil, []string{"--color=never", "."}}, + {nil, []string{"--color", "never", "."}}, + {[]string{"NO_COLOR=1"}, []string{"."}}, + {[]string{"TERM=dumb"}, []string{"."}}, + } { + out, _ := runPipedCodemap(t, repo, tc.env, tc.args...) + if strings.Contains(out, "\x1b[") { + t.Fatalf("codemap %v with env %v emitted ANSI escapes:\n%q", tc.args, tc.env, out) + } + } + }) + + t.Run("json is identical regardless of color mode", func(t *testing.T) { + auto, _ := runPipedCodemap(t, repo, nil, "--json", ".") + always, _ := runPipedCodemap(t, repo, nil, "--color=always", "--json", ".") + never, _ := runPipedCodemap(t, repo, nil, "--color=never", "--json", ".") + if auto != always || auto != never { + t.Fatalf("--json output differs across colour modes:\nauto:\n%s\nalways:\n%s\nnever:\n%s", auto, always, never) + } + if strings.Contains(auto, "\x1b[") { + t.Fatalf("--json output contains ANSI escapes:\n%q", auto) + } + }) + + t.Run("unknown color mode is rejected", func(t *testing.T) { + command := exec.Command(codemapTestBinaryPath, "--color=sometimes", ".") + command.Dir = repo + out, err := command.CombinedOutput() + if err == nil { + t.Fatalf("`codemap --color=sometimes` unexpectedly succeeded:\n%s", out) + } + if !strings.Contains(string(out), "--color: unknown mode") { + t.Fatalf("unexpected error text:\n%s", out) + } + }) +} diff --git a/main.go b/main.go index 7079b1d..5fae139 100644 --- a/main.go +++ b/main.go @@ -96,6 +96,8 @@ func main() { } hookName := os.Args[2] root, _ := os.Getwd() + // Hook stdout is an agent prompt, never a terminal: no colour, ever. + render.SetColorMode(render.ColorNever) hookAgent := "claude" hookIntegration := "" for _, arg := range os.Args[3:] { @@ -262,6 +264,7 @@ func main() { fmt.Println(" --version Show build version") fmt.Println(" -C, --project-root Operate on code in .") fmt.Println(" --setup-root Reuse state from /.codemap.") + fmt.Println(" --color Colour output: auto (default: only on a terminal, off when NO_COLOR is set), always, never") fmt.Println(" --skyline City skyline visualization") fmt.Println(" --animate Animated skyline (use with --skyline)") fmt.Println(" --deps Dependency flow map (functions & imports)") @@ -545,6 +548,11 @@ func applyGlobalRootOptions(args []string) ([]string, bool, error) { if err != nil { return nil, false, err } + colorMode, err := render.ParseColorMode(opts.Color) + if err != nil { + return nil, false, err + } + render.SetColorMode(colorMode) if !opts.Active() { launchDir, err := os.Getwd() if err != nil { diff --git a/render/color_mode.go b/render/color_mode.go new file mode 100644 index 0000000..ce3bc13 --- /dev/null +++ b/render/color_mode.go @@ -0,0 +1,124 @@ +package render + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +// ColorMode selects whether renderers emit ANSI colour escapes. +type ColorMode int + +const ( + // ColorAuto emits colour only when stdout is a terminal, NO_COLOR is unset, + // and TERM is not "dumb". It is the default. + ColorAuto ColorMode = iota + // ColorAlways emits colour regardless of where stdout goes (for `less -R`). + ColorAlways + // ColorNever emits no colour escapes at all. + ColorNever +) + +// String returns the flag spelling of the mode. +func (m ColorMode) String() string { + switch m { + case ColorAlways: + return "always" + case ColorNever: + return "never" + default: + return "auto" + } +} + +// ParseColorMode parses a --color value. The empty string means auto. +func ParseColorMode(value string) (ColorMode, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "auto": + return ColorAuto, nil + case "always", "force", "yes", "on": + return ColorAlways, nil + case "never", "no", "off", "none": + return ColorNever, nil + } + return ColorAuto, fmt.Errorf("--color: unknown mode %q (want auto, always or never)", value) +} + +// stdoutIsTerminal reports whether the process's stdout is a terminal. Tests +// override it to simulate piped output. +var stdoutIsTerminal = func() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +// colorEnabled is the resolved state; read it through ColorEnabled. +var colorEnabled bool + +func init() { + SetColorMode(ColorAuto) +} + +// SetColorMode resolves mode and switches every colour variable in this +// package to its escape sequence (enabled) or the empty string (disabled). +// ColorAuto is resolved with autoColorEnabled against the real stdout and +// environment. +func SetColorMode(mode ColorMode) { + switch mode { + case ColorAlways: + applyColors(true) + case ColorNever: + applyColors(false) + default: + applyColors(autoColorEnabled(stdoutIsTerminal(), os.LookupEnv)) + } +} + +// ColorEnabled reports whether renderers currently emit colour escapes. +func ColorEnabled() bool { + return colorEnabled +} + +// autoColorEnabled implements ColorAuto: colour needs a terminal on stdout, an +// unset NO_COLOR (https://no-color.org), and a TERM other than "dumb". +func autoColorEnabled(isTerminal bool, lookupEnv func(string) (string, bool)) bool { + if !isTerminal { + return false + } + if _, set := lookupEnv("NO_COLOR"); set { + return false + } + if value, _ := lookupEnv("TERM"); value == "dumb" { + return false + } + return true +} + +func applyColors(enabled bool) { + colorEnabled = enabled + pick := func(code string) string { + if enabled { + return code + } + return "" + } + Reset = pick(ansiReset) + Bold = pick(ansiBold) + Dim = pick(ansiDim) + White = pick(ansiWhite) + Cyan = pick(ansiCyan) + Yellow = pick(ansiYellow) + Magenta = pick(ansiMagenta) + Green = pick(ansiGreen) + Red = pick(ansiRed) + Blue = pick(ansiBlue) + BoldWhite = pick(ansiBoldWhite) + BoldRed = pick(ansiBoldRed) + BoldBlue = pick(ansiBoldBlue) + DimWhite = pick(ansiDimWhite) + BoldGreen = pick(ansiBoldGreen) + buildingColors = buildingColors[:0] + for _, code := range buildingPalette { + buildingColors = append(buildingColors, pick(code)) + } +} diff --git a/render/color_mode_test.go b/render/color_mode_test.go new file mode 100644 index 0000000..ad35441 --- /dev/null +++ b/render/color_mode_test.go @@ -0,0 +1,176 @@ +package render + +import ( + "bytes" + "os" + "strings" + "testing" + + "codemap/scanner" +) + +// withStdoutTerminal stubs the stdout TTY check for one test. +func withStdoutTerminal(t *testing.T, isTTY bool) { + t.Helper() + prev := stdoutIsTerminal + stdoutIsTerminal = func() bool { return isTTY } + t.Cleanup(func() { + stdoutIsTerminal = prev + SetColorMode(ColorAuto) + }) +} + +func colorModeFixture() scanner.Project { + return scanner.Project{ + Name: "demo", + Root: "/tmp/demo", + Files: []scanner.FileInfo{ + {Path: "src/app/main.go", Ext: ".go", Size: 120}, + {Path: "src/app/util.go", Ext: ".go", Size: 80}, + {Path: "web/index.html", Ext: ".html", Size: 40}, + {Path: "README.md", Ext: ".md", Size: 30}, + }, + } +} + +func TestTreePipedOutputHasNoANSIEscapes(t *testing.T) { + withStdoutTerminal(t, false) + t.Setenv("NO_COLOR", "") // registers restore; then unset so NO_COLOR is not what disables colour + if err := os.Unsetenv("NO_COLOR"); err != nil { + t.Fatal(err) + } + SetColorMode(ColorAuto) + if ColorEnabled() { + t.Fatal("ColorEnabled() = true with stdout piped, want false") + } + + var buf bytes.Buffer + Tree(&buf, colorModeFixture()) + out := buf.String() + if strings.Contains(out, "\x1b[") { + t.Fatalf("piped tree output contains ANSI escapes:\n%q", out) + } + for _, want := range []string{"demo", "src/app/", "main", "util", "README.md"} { + if !strings.Contains(out, want) { + t.Fatalf("piped tree output lost %q:\n%s", want, out) + } + } +} + +func TestTreeColorAlwaysRestoresANSIEscapes(t *testing.T) { + withStdoutTerminal(t, false) + t.Setenv("NO_COLOR", "1") + SetColorMode(ColorAlways) + if !ColorEnabled() { + t.Fatal("ColorEnabled() = false under ColorAlways, want true") + } + + var buf bytes.Buffer + Tree(&buf, colorModeFixture()) + out := buf.String() + if !strings.Contains(out, ansiBoldBlue+" src/app/"+ansiReset) { + t.Fatalf("--color=always output lacks the directory colour:\n%q", out) + } + if !strings.Contains(out, ansiBold+"demo"+ansiReset) { + t.Fatalf("--color=always output lacks the bold project name:\n%q", out) + } + if stripTreeANSI(out) == out { + t.Fatal("--color=always output has no ANSI escapes at all") + } +} + +func TestTreeColorNeverOnTerminal(t *testing.T) { + withStdoutTerminal(t, true) + SetColorMode(ColorNever) + var buf bytes.Buffer + Tree(&buf, colorModeFixture()) + if strings.Contains(buf.String(), "\x1b[") { + t.Fatalf("--color=never output contains ANSI escapes:\n%q", buf.String()) + } +} + +func TestSkylineStaticHonoursColorMode(t *testing.T) { + withStdoutTerminal(t, false) + project := colorModeFixture() + + SetColorMode(ColorNever) + var off bytes.Buffer + Skyline(&off, project, false) + if strings.Contains(off.String(), "\x1b[") { + t.Fatalf("skyline with colour off contains ANSI escapes:\n%q", off.String()) + } + for _, code := range buildingColors { + if code != "" { + t.Fatalf("buildingColors still carries %q with colour off", code) + } + } + + SetColorMode(ColorAlways) + var on bytes.Buffer + Skyline(&on, project, false) + if !strings.Contains(on.String(), "\x1b[") { + t.Fatalf("skyline with colour on has no ANSI escapes:\n%q", on.String()) + } + if len(buildingColors) != len(buildingPalette) { + t.Fatalf("buildingColors has %d entries, want %d", len(buildingColors), len(buildingPalette)) + } + for i, code := range buildingColors { + if code != buildingPalette[i] { + t.Fatalf("buildingColors[%d] = %q, want %q", i, code, buildingPalette[i]) + } + } +} + +func TestAutoColorEnabled(t *testing.T) { + env := func(vars map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := vars[key] + return value, ok + } + } + tests := []struct { + name string + isTTY bool + vars map[string]string + want bool + }{ + {"terminal", true, map[string]string{"TERM": "xterm-256color"}, true}, + {"piped", false, map[string]string{"TERM": "xterm-256color"}, false}, + {"terminal NO_COLOR=1", true, map[string]string{"NO_COLOR": "1"}, false}, + {"terminal NO_COLOR set but empty", true, map[string]string{"NO_COLOR": ""}, false}, + {"terminal TERM=dumb", true, map[string]string{"TERM": "dumb"}, false}, + {"terminal no TERM", true, map[string]string{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := autoColorEnabled(tt.isTTY, env(tt.vars)); got != tt.want { + t.Fatalf("autoColorEnabled(%v, %v) = %v, want %v", tt.isTTY, tt.vars, got, tt.want) + } + }) + } +} + +func TestParseColorMode(t *testing.T) { + good := map[string]ColorMode{ + "": ColorAuto, "auto": ColorAuto, "AUTO": ColorAuto, + "always": ColorAlways, "force": ColorAlways, "on": ColorAlways, "yes": ColorAlways, + "never": ColorNever, "off": ColorNever, "no": ColorNever, "none": ColorNever, + } + for in, want := range good { + got, err := ParseColorMode(in) + if err != nil || got != want { + t.Fatalf("ParseColorMode(%q) = %v, %v; want %v", in, got, err, want) + } + } + for _, bad := range []string{"sometimes", "1", "true"} { + if _, err := ParseColorMode(bad); err == nil { + t.Fatalf("ParseColorMode(%q) accepted an unknown mode", bad) + } + } + for _, m := range []ColorMode{ColorAuto, ColorAlways, ColorNever} { + back, err := ParseColorMode(m.String()) + if err != nil || back != m { + t.Fatalf("ParseColorMode(%q) = %v, %v; want round trip", m.String(), back, err) + } + } +} diff --git a/render/colors.go b/render/colors.go index 10e7c19..2f1c55e 100644 --- a/render/colors.go +++ b/render/colors.go @@ -7,23 +7,47 @@ import ( "golang.org/x/term" ) -// ANSI color codes +// ANSI color codes. These are variables, not constants: every one of them is +// the escape sequence below while colour is enabled and the empty string while +// it is disabled, so renderers can concatenate them unconditionally. See +// SetColorMode for the switch; the default resolves ColorAuto against stdout +// when the package initializes. +var ( + Reset = ansiReset + Bold = ansiBold + Dim = ansiDim + White = ansiWhite + Cyan = ansiCyan + Yellow = ansiYellow + Magenta = ansiMagenta + Green = ansiGreen + Red = ansiRed + Blue = ansiBlue + BoldWhite = ansiBoldWhite + BoldRed = ansiBoldRed + BoldBlue = ansiBoldBlue + DimWhite = ansiDimWhite + BoldGreen = ansiBoldGreen +) + +// The palette: the escape sequences the exported variables take while colour +// is enabled. const ( - Reset = "\033[0m" - Bold = "\033[1m" - Dim = "\033[2m" - White = "\033[37m" - Cyan = "\033[36m" - Yellow = "\033[33m" - Magenta = "\033[35m" - Green = "\033[32m" - Red = "\033[31m" - Blue = "\033[34m" - BoldWhite = "\033[1;37m" - BoldRed = "\033[1;31m" - BoldBlue = "\033[1;34m" - DimWhite = "\033[2;37m" - BoldGreen = "\033[1;32m" + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiDim = "\033[2m" + ansiWhite = "\033[37m" + ansiCyan = "\033[36m" + ansiYellow = "\033[33m" + ansiMagenta = "\033[35m" + ansiGreen = "\033[32m" + ansiRed = "\033[31m" + ansiBlue = "\033[34m" + ansiBoldWhite = "\033[1;37m" + ansiBoldRed = "\033[1;31m" + ansiBoldBlue = "\033[1;34m" + ansiDimWhite = "\033[2;37m" + ansiBoldGreen = "\033[1;32m" ) // Asset extensions to exclude from "top large files" diff --git a/render/colors_test.go b/render/colors_test.go index 21aa4b9..b553116 100644 --- a/render/colors_test.go +++ b/render/colors_test.go @@ -5,7 +5,15 @@ import ( "testing" ) +// withColorMode forces a colour mode for one test and restores auto afterwards. +func withColorMode(t *testing.T, mode ColorMode) { + t.Helper() + SetColorMode(mode) + t.Cleanup(func() { SetColorMode(ColorAuto) }) +} + func TestGetFileColor(t *testing.T) { + withColorMode(t, ColorAlways) tests := []struct { ext string expected string @@ -74,6 +82,7 @@ func TestGetFileColor(t *testing.T) { } func TestGetFileColorCaseInsensitive(t *testing.T) { + withColorMode(t, ColorAlways) // Test that color detection is case-insensitive tests := []string{".GO", ".Go", ".go", ".PY", ".Py", ".py"} @@ -190,7 +199,8 @@ func TestGetTerminalWidth(t *testing.T) { } func TestANSIConstants(t *testing.T) { - // Verify ANSI constants are properly defined escape sequences + // Verify the colour variables carry proper escape sequences while colour is on + withColorMode(t, ColorAlways) constants := map[string]string{ "Reset": Reset, "Bold": Bold, diff --git a/render/skyline.go b/render/skyline.go index 8344b32..d86e188 100644 --- a/render/skyline.go +++ b/render/skyline.go @@ -43,8 +43,11 @@ const ( skyHeight = 6 ) -// Building colors -var buildingColors = []string{ +// Building colours while colour is enabled; buildingColors is the live list, +// blanked by applyColors when colour is off. +var buildingColors []string + +var buildingPalette = []string{ "\033[36m", // cyan "\033[33m", // yellow "\033[35m", // magenta