From 1014ebbfc4e0d24937538b7f4aa76abe6d7015ce Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:53:28 -0400 Subject: [PATCH 1/2] feat(cli): Colour the verdict when stderr is a terminal [+] PASSED and [-] FAILED differ by one character, so scrolling a CI log a failure looked exactly like a success. The sigil vocabulary was already good and the README documents it; colour reinforces it rather than replacing it. The verdict is green or red and Error: is red. The four lines that lead up to a verdict -- [.] [:] [>] [~] -- are dimmed, so the verdict is what the eye lands on. Dimming only [.] and [:] would have left the rarer trace lines louder than the common ones. The failure list itself stays plain: those lines get copied out of terminals and into issues, and escapes would travel with them. --color takes auto, always or never and defaults to auto, which colours only when stderr is a character device. A pipe or a CI log therefore stays plain without anyone asking, which is the same test that keeps the existing end-to-end suite unaffected. NO_COLOR is honoured, and --color=always wins over it: a variable says what to do absent an instruction, and the flag is one. Detection is os.File.Stat against ModeCharDevice, so no dependency and no platform branching -- this repository has neither a build tag nor a GOOS check anywhere, and a legibility feature is a poor reason to introduce the first of either. ANSI is written on every platform when stderr is a terminal; a console that cannot render it is rarer than the code to detect one, and --color=never and NO_COLOR are both there for it. --color is deliberately not added to envFlags. NO_COLOR already covers setting this from the environment, and a second spelling would mean two ways to say one thing and a seventh entry in a list documented as six. Closes #98 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrknafJSP5hF8u865cbnqX --- README.md | 8 +++ color_test.go | 107 ++++++++++++++++++++++++++++++++++++ e2e_color_test.go | 75 +++++++++++++++++++++++++ main.go | 137 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 color_test.go create mode 100644 e2e_color_test.go diff --git a/README.md b/README.md index 71f2379..0dcacf6 100644 --- a/README.md +++ b/README.md @@ -407,10 +407,18 @@ way. Reading those bytes as plain text would be its own silent corruption. | `--verbose` | `-v` | Enable verbose logging (overrides `--log-level`) | | `--silent` | `-s` | Only log errors (overrides `-v`) | | `--log-level` | | Set log level (debug, info, warn, error) | +| `--color` | | Colour the verdict: `auto` (default), `always`, `never` | **Everything the tool prints goes to stderr; stdout is always empty.** Use `2>&1` when capturing output in a file or a pipe. +`auto` colours only when stderr is a terminal, so a pipe or a CI log stays +plain without being asked for. The verdict is green or red and the `[.]` `[:]` +`[>]` `[~]` lines are dimmed; the failure list itself stays plain so it can be +copied out of a terminal unchanged. `NO_COLOR` is honoured — any non-empty +value turns `auto` off — and `--color=always` overrides it, on the grounds that +the variable says what to do absent an instruction and the flag is one. + `warn` is accepted but currently logs exactly what `error` does; nothing in the tool logs at the warn level. diff --git a/color_test.go b/color_test.go new file mode 100644 index 0000000..f380309 --- /dev/null +++ b/color_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "strings" + "testing" +) + +// Test_shouldColor is the whole decision, which is why it takes its three +// inputs as arguments rather than reading a terminal and an environment. +func Test_shouldColor(t *testing.T) { + t.Parallel() + + tests := []struct { + Name string + Mode string + NoColor string + IsTTY bool + Want bool + WantErr bool + }{ + {Name: "auto on a terminal", Mode: "auto", IsTTY: true, Want: true}, + {Name: "auto in a pipe", Mode: "auto", IsTTY: false, Want: false}, + // The case the default exists for: a CI log is not a terminal, so it + // stays plain without anyone having to ask. + {Name: "auto with NO_COLOR on a terminal", Mode: "auto", NoColor: "1", IsTTY: true, Want: false}, + {Name: "auto with NO_COLOR set to anything", Mode: "auto", NoColor: "0", IsTTY: true, Want: false}, + // NO_COLOR's own wording: empty counts as unset. + {Name: "auto with an empty NO_COLOR", Mode: "auto", NoColor: "", IsTTY: true, Want: true}, + + {Name: "always in a pipe", Mode: "always", IsTTY: false, Want: true}, + // A variable says what to do absent an instruction; the flag is one. + {Name: "always beats NO_COLOR", Mode: "always", NoColor: "1", IsTTY: false, Want: true}, + + {Name: "never on a terminal", Mode: "never", IsTTY: true, Want: false}, + {Name: "never with NO_COLOR unset", Mode: "never", IsTTY: true, Want: false}, + + {Name: "an unknown mode is rejected", Mode: "purple", WantErr: true}, + {Name: "an empty mode is rejected", Mode: "", WantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + got, err := shouldColor(tc.Mode, tc.NoColor, tc.IsTTY) + if tc.WantErr { + if err == nil { + t.Fatalf("expected an error for %q, got nil", tc.Mode) + } + if !strings.Contains(err.Error(), "auto, always, never") { + t.Errorf("error = %q, want it to list the values", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if got != tc.Want { + t.Errorf("shouldColor(%q, %q, %v) = %v, want %v", + tc.Mode, tc.NoColor, tc.IsTTY, got, tc.Want) + } + }) + } +} + +// Test_paletteLine pins which lines are coloured and how. The sigil decides, +// so a new sigil that nobody adds here is left plain rather than mis-coloured. +func Test_paletteLine(t *testing.T) { + t.Parallel() + + on := palette{on: true} + + tests := []struct { + Name string + In string + Want string + }{ + {"the passing verdict is green", "[+] PASSED 1ms\n", ansiGreen + "[+] PASSED 1ms" + ansiReset + "\n"}, + {"the failing verdict is red", "[-] FAILED 1ms\n", ansiRed + "[-] FAILED 1ms" + ansiReset + "\n"}, + {"the request line is dimmed", "[.] GET /\n", ansiDim + "[.] GET /" + ansiReset + "\n"}, + {"the response line is dimmed", "[:] 200 OK\n", ansiDim + "[:] 200 OK" + ansiReset + "\n"}, + {"the redirect line is dimmed", "[>] /next\n", ansiDim + "[>] /next" + ansiReset + "\n"}, + {"the wait line is dimmed", "[~] waiting 1s\n", ansiDim + "[~] waiting 1s" + ansiReset + "\n"}, + {"an unsigilled line is left alone", "plain text\n", "plain text\n"}, + // The reset belongs before the blank line, or a terminal paints it. + {"trailing newlines stay outside the sequence", "[+] PASSED\n\n", ansiGreen + "[+] PASSED" + ansiReset + "\n\n"}, + } + + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + if got := on.line(tc.In); got != tc.Want { + t.Errorf("line(%q) = %q, want %q", tc.In, got, tc.Want) + } + }) + } + + // The zero value is the one every path before flag parsing uses. + t.Run("the zero palette writes no colour", func(t *testing.T) { + var off palette + for _, in := range []string{"[+] PASSED\n", "[-] FAILED\n", "[.] GET /\n"} { + if got := off.line(in); got != in { + t.Errorf("line(%q) = %q, want it unchanged", in, got) + } + } + if got := off.wrap(ansiRed, "Error:"); got != "Error:" { + t.Errorf("wrap = %q, want it unchanged", got) + } + }) +} diff --git a/e2e_color_test.go b/e2e_color_test.go new file mode 100644 index 0000000..c623ab9 --- /dev/null +++ b/e2e_color_test.go @@ -0,0 +1,75 @@ +package main_test + +import ( + "strings" + "testing" +) + +const esc = "\033[" + +// TestE2EColor covers what a caller can observe about colour (#98). +// +// The end-to-end suite captures output through a pipe, which is exactly the +// condition --color=auto exists to detect -- so the default staying plain here +// is the CI-log guarantee being tested, not an accident of the harness. +func TestE2EColor(t *testing.T) { + t.Run("the default is plain when output is not a terminal", func(t *testing.T) { + for _, u := range []string{url("/ok"), url("/500")} { + r := run(t, nil, "--assert-ok", u) + if strings.Contains(r.Output(), esc) { + t.Errorf("piped output carries ANSI: %q", r.Output()) + } + } + }) + + t.Run("--color=never is plain", func(t *testing.T) { + r := run(t, nil, "--color=never", "--assert-ok", url("/500")) + assertExit(t, r, exitAssertFail) + if strings.Contains(r.Output(), esc) { + t.Errorf("--color=never carries ANSI: %q", r.Output()) + } + }) + + t.Run("--color=always colours the verdict even in a pipe", func(t *testing.T) { + pass := run(t, nil, "--color=always", "--assert-ok", url("/ok")) + assertExit(t, pass, exitOK) + assertContains(t, pass, "\033[32m[+] PASSED") + + fail := run(t, nil, "--color=always", "--assert-ok", url("/500")) + assertExit(t, fail, exitAssertFail) + assertContains(t, fail, "\033[31m[-] FAILED") + assertContains(t, fail, "\033[31mError:\033[0m") + }) + + t.Run("--color=always dims the trace lines", func(t *testing.T) { + r := run(t, nil, "--color=always", "--assert-ok", url("/ok")) + assertContains(t, r, "\033[2m[.] ") + assertContains(t, r, "\033[2m[:] ") + }) + + // The failure list is copied out of terminals; escapes in it would travel. + t.Run("the assertion lines stay plain even with colour on", func(t *testing.T) { + r := run(t, nil, "--color=always", "--assert-status", "200", url("/500")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, "\n- status: expected 200, got 500") + }) + + // NO_COLOR only changes the answer when stderr is a terminal, and this + // harness pipes -- so "auto plus NO_COLOR is plain" would pass here even + // if NO_COLOR were ignored entirely. That branch is covered by + // Test_shouldColor, which takes the terminal as an argument instead. + // + // This one is observable: if NO_COLOR wrongly won, the output would be + // plain. A variable says what to do absent an instruction; the flag is one. + t.Run("--color=always overrides NO_COLOR", func(t *testing.T) { + r := run(t, map[string]string{"NO_COLOR": "1"}, "--color=always", "--assert-ok", url("/ok")) + assertContains(t, r, "\033[32m[+] PASSED") + }) + + t.Run("an unknown value is rejected", func(t *testing.T) { + r := run(t, nil, "--color=purple", "--assert-ok", url("/ok")) + assertExit(t, r, exitBadInvocation) + assertContains(t, r, "Invalid value for --color flag") + assertContains(t, r, "auto, always, never") + }) +} diff --git a/main.go b/main.go index f12cda3..220751a 100644 --- a/main.go +++ b/main.go @@ -203,7 +203,20 @@ Compression: Nothing is advertised in Accept-Encoding unless -H says so, and the response headers are reported exactly as they arrived -- so a body can be asserted on - and its Content-Encoding at the same time.`, + and its Content-Encoding at the same time. + +Colour: + --color decides whether the sigil lines and Error: carry ANSI colour. auto, + the default, colours only when stderr is a terminal, so a pipe or a CI log + stays plain without being asked. always and never say so outright. + + NO_COLOR is honoured: any non-empty value turns auto off. --color=always + still wins over it, on the grounds that a variable says what to do in the + absence of an instruction and the flag is one. + + The verdict is green or red; the [.] [:] [>] [~] lines leading up to it are + dimmed. Nothing else is coloured -- the failure list stays plain so it can be + copied out of a terminal unchanged.`, Example: ` # A health check: any non-error status passes http-assert --assert-ok https://example.com/health @@ -227,6 +240,11 @@ Compression: SilenceErrors: true, SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { + // Resolved first, so every message after this point -- including + // the ones dief writes about other flags -- is coloured the way + // the caller asked. + mustSetPalette(cmd) + insecure, _ := cmd.Flags().GetBool("insecure") maxTime, _ := cmd.Flags().GetInt("max-time") maphost, _ := cmd.Flags().GetStringArray("maphost") @@ -237,6 +255,7 @@ Compression: retryMaxTime, _ := cmd.Flags().GetDuration("retry-max-time") c := Client{ LogLevel: mustParseLogLevel(cmd), + Palette: errPalette, SkipSslChecks: insecure, Timeout: time.Duration(maxTime) * time.Second, HostMappings: mustParseHostMappings(maphost), @@ -310,6 +329,8 @@ Compression: "Be silent; log error messages only (same as --log-level error; overrides -v)") cmd.PersistentFlags().String("log-level", "", "Set log level; possible values: debug, info (default), warn, error") + cmd.PersistentFlags().String("color", "auto", + "Colour the verdict; possible values: auto (default), always, never") cmd.PersistentFlags().BoolP("insecure", "k", false, "Disable checking SSL certificates") cmd.PersistentFlags().IntP("max-time", "m", 20, "Maximum time in seconds that you allow each request to take") @@ -484,11 +505,117 @@ type exitError struct { func (e *exitError) Error() string { return e.msg } // dief formats a message to stderr and terminates the process with rc. +// ANSI colours, kept to the three the verdict needs. A 16-colour palette works +// on everything that renders escapes at all, so there is no capability to +// probe beyond "is anyone watching". +const ( + ansiReset = "\033[0m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiDim = "\033[2m" +) + +// palette decides whether a line is written with colour. The zero value writes +// none, so any path that runs before the flags are parsed stays plain. +type palette struct{ on bool } + +// errPalette colours the one line dief writes. It is a package variable +// because dief is reachable from flag parsing, before there is a Client to +// hang it on; until the flags resolve it is the zero value, so an error raised +// on the way there is plain rather than half-coloured. +var errPalette palette + +func (p palette) wrap(code, s string) string { + if !p.on || s == "" { + return s + } + + return code + s + ansiReset +} + +// line colours a log line by its sigil. +// +// The sigil vocabulary already says what each line is; colour only makes the +// distinction survive a scroll through a CI log, which is the whole complaint +// (#98). The verdict is green or red, and the four lines that lead up to it are +// dimmed so the verdict is what the eye lands on. Dimming [.] and [:] but not +// [>] and [~] would leave the rarer trace lines louder than the common ones. +func (p palette) line(s string) string { + if !p.on { + return s + } + + // The trailing newlines are outside the sequence: a reset after a blank + // line leaves the colour spanning it, which some terminals paint. + body := strings.TrimRight(s, "\n") + tail := s[len(body):] + + switch { + case strings.HasPrefix(body, "[+]"): + body = p.wrap(ansiGreen, body) + case strings.HasPrefix(body, "[-]"): + body = p.wrap(ansiRed, body) + case strings.HasPrefix(body, "[.]"), strings.HasPrefix(body, "[:]"), + strings.HasPrefix(body, "[>]"), strings.HasPrefix(body, "[~]"): + body = p.wrap(ansiDim, body) + } + + return body + tail +} + +// isTerminal reports whether anything is watching f. +// +// A character device is the stdlib's answer to the question, and it is the +// whole of the platform handling here: a terminal that cannot render escapes +// is rarer than the dependency needed to detect one, and --color=never and +// NO_COLOR both exist for it. +func isTerminal(f *os.File) bool { + st, err := f.Stat() + + return err == nil && st.Mode()&os.ModeCharDevice != 0 +} + +// shouldColor resolves --color against NO_COLOR and the terminal. +// +// Taking all three as arguments keeps the decision testable without a terminal +// or a mutated environment, which is what made it worth separating from the +// wiring at all. +// +// --color=always wins over NO_COLOR: the variable says what to do absent an +// instruction, and the flag is an instruction. NO_COLOR beats a bare terminal, +// which is the case it exists for. +func shouldColor(mode, noColor string, isTTY bool) (bool, error) { + switch mode { + case "never": + return false, nil + case "always": + return true, nil + case "auto": + return noColor == "" && isTTY, nil + } + + return false, fmt.Errorf("possible values: auto, always, never") +} + +// mustSetPalette resolves --color once and hands the answer to both writers. +// +// stderr is the subject because that is where every line this colours goes; +// asking about stdout would answer a question nobody is writing to. +func mustSetPalette(cmd *cobra.Command) { + mode, _ := cmd.Flags().GetString("color") + on, err := shouldColor(mode, os.Getenv("NO_COLOR"), isTerminal(os.Stderr)) + if err != nil { + dief(exitBadInvocation, "Invalid value for --color flag: %q: %s", mode, err) + } + + errPalette = palette{on: on} +} + func dief(rc int, format string, args ...interface{}) { if !strings.HasSuffix(format, "\n") { format += "\n" } - fmt.Fprintf(os.Stderr, "\nError: "+format, args...) + fmt.Fprintf(os.Stderr, "\n%s "+format, append([]interface{}{errPalette.wrap(ansiRed, "Error:")}, args...)...) os.Exit(rc) } @@ -847,7 +974,9 @@ func parseHeaderAssertions(vs []string, exactMatch bool) []Assertion { } type Client struct { - LogLevel LogLevel + LogLevel LogLevel + // Palette colours the sigil lines. The zero value writes none. + Palette palette SkipSslChecks bool Timeout time.Duration HostMappings []hostMapping @@ -1187,7 +1316,7 @@ func (c Client) log(l LogLevel, format string, args ...interface{}) { if !strings.HasSuffix(format, "\n") { format += "\n" } - fmt.Fprintf(os.Stderr, format, args...) + fmt.Fprint(os.Stderr, c.Palette.line(fmt.Sprintf(format, args...))) } type httpResponse struct { From b562b05ff5bcde1bf1b433ffa6eefe81c6ea0a34 Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:01:26 -0400 Subject: [PATCH 2/2] feat(cli): Colour the retry line yellow rather than dimming it A retry is the one trace line that reports trouble without being the verdict, and dimming it filed it with the request and response lines as background. A check that passed on the fourth attempt is not the same news as one that passed on the first, and the log should say so at a glance. Yellow rather than red, because the run has not failed: red is reserved for the verdict and for Error:, and spending it on an attempt that is about to be tried again would mean a passing run printed red lines. This leaves the mid-retry "[-] FAILED : " line red. It shares its sigil with the final verdict, so the colour cannot be chosen by prefix the way every other line's is, and stating it at the call site would be the first place the sigil stops deciding. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrknafJSP5hF8u865cbnqX --- README.md | 7 +++++-- color_test.go | 2 +- e2e_color_test.go | 23 +++++++++++++++++++++++ main.go | 28 ++++++++++++++++++---------- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0dcacf6..14fa74b 100644 --- a/README.md +++ b/README.md @@ -414,8 +414,11 @@ way. Reading those bytes as plain text would be its own silent corruption. `auto` colours only when stderr is a terminal, so a pipe or a CI log stays plain without being asked for. The verdict is green or red and the `[.]` `[:]` -`[>]` `[~]` lines are dimmed; the failure list itself stays plain so it can be -copied out of a terminal unchanged. `NO_COLOR` is honoured — any non-empty +`[>]` trace lines are dimmed. `[~]` is yellow — a retry is the one line that +reports trouble without being the verdict, and a check that passed on the +fourth attempt is not the same news as one that passed on the first. The +failure list itself stays plain so it can be copied out of a terminal +unchanged. `NO_COLOR` is honoured — any non-empty value turns `auto` off — and `--color=always` overrides it, on the grounds that the variable says what to do absent an instruction and the flag is one. diff --git a/color_test.go b/color_test.go index f380309..ce8623d 100644 --- a/color_test.go +++ b/color_test.go @@ -78,7 +78,7 @@ func Test_paletteLine(t *testing.T) { {"the request line is dimmed", "[.] GET /\n", ansiDim + "[.] GET /" + ansiReset + "\n"}, {"the response line is dimmed", "[:] 200 OK\n", ansiDim + "[:] 200 OK" + ansiReset + "\n"}, {"the redirect line is dimmed", "[>] /next\n", ansiDim + "[>] /next" + ansiReset + "\n"}, - {"the wait line is dimmed", "[~] waiting 1s\n", ansiDim + "[~] waiting 1s" + ansiReset + "\n"}, + {"the retry line is yellow", "[~] retry 1/3 in 1s\n", ansiYellow + "[~] retry 1/3 in 1s" + ansiReset + "\n"}, {"an unsigilled line is left alone", "plain text\n", "plain text\n"}, // The reset belongs before the blank line, or a terminal paints it. {"trailing newlines stay outside the sequence", "[+] PASSED\n\n", ansiGreen + "[+] PASSED" + ansiReset + "\n\n"}, diff --git a/e2e_color_test.go b/e2e_color_test.go index c623ab9..4d0dc36 100644 --- a/e2e_color_test.go +++ b/e2e_color_test.go @@ -66,6 +66,29 @@ func TestE2EColor(t *testing.T) { assertContains(t, r, "\033[32m[+] PASSED") }) + // A retry is the one trace line reporting trouble that is not the verdict, + // so it is neither dimmed with the rest of the trace nor red like a + // failure: a run that passed on the fourth attempt is not the same news as + // one that passed on the first. + t.Run("--color=always makes the retry line yellow", func(t *testing.T) { + r := run(t, nil, "--color=always", "--retry", "2", "--retry-delay", "10ms", + "--assert-ok", url("/500")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, "\033[33m[~] retry 1/2") + assertNotContains(t, r, "\033[2m[~]") + + // The verdict it leads to is still red, and still distinguishable. + assertContains(t, r, "\033[31m[-] FAILED") + }) + + t.Run("a retry that recovers still colours the retry yellow", func(t *testing.T) { + r := run(t, nil, "--color=always", "--retry", "3", "--retry-delay", "10ms", + "--assert-ok", flaky(t, "/flaky", 1)) + assertExit(t, r, exitOK) + assertContains(t, r, "\033[33m[~] retry 1/3") + assertContains(t, r, "\033[32m[+] PASSED") + }) + t.Run("an unknown value is rejected", func(t *testing.T) { r := run(t, nil, "--color=purple", "--assert-ok", url("/ok")) assertExit(t, r, exitBadInvocation) diff --git a/main.go b/main.go index 220751a..304f508 100644 --- a/main.go +++ b/main.go @@ -214,8 +214,9 @@ Colour: still wins over it, on the grounds that a variable says what to do in the absence of an instruction and the flag is one. - The verdict is green or red; the [.] [:] [>] [~] lines leading up to it are - dimmed. Nothing else is coloured -- the failure list stays plain so it can be + The verdict is green or red and the [.] [:] [>] trace lines are dimmed. [~] is + yellow: a retry is the one line that reports trouble without being the + verdict. Nothing else is coloured -- the failure list stays plain so it can be copied out of a terminal unchanged.`, Example: ` # A health check: any non-error status passes http-assert --assert-ok https://example.com/health @@ -509,10 +510,11 @@ func (e *exitError) Error() string { return e.msg } // on everything that renders escapes at all, so there is no capability to // probe beyond "is anyone watching". const ( - ansiReset = "\033[0m" - ansiRed = "\033[31m" - ansiGreen = "\033[32m" - ansiDim = "\033[2m" + ansiReset = "\033[0m" + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiDim = "\033[2m" ) // palette decides whether a line is written with colour. The zero value writes @@ -537,9 +539,13 @@ func (p palette) wrap(code, s string) string { // // The sigil vocabulary already says what each line is; colour only makes the // distinction survive a scroll through a CI log, which is the whole complaint -// (#98). The verdict is green or red, and the four lines that lead up to it are -// dimmed so the verdict is what the eye lands on. Dimming [.] and [:] but not -// [>] and [~] would leave the rarer trace lines louder than the common ones. +// (#98). The verdict is green or red and the trace lines are dimmed, so the +// verdict is what the eye lands on. +// +// [~] is the exception: a retry is the one trace line that reports something +// went wrong without being the verdict, and a run that passed on the fourth +// attempt is not the same news as one that passed on the first. Yellow says +// that without claiming the run failed. func (p palette) line(s string) string { if !p.on { return s @@ -555,8 +561,10 @@ func (p palette) line(s string) string { body = p.wrap(ansiGreen, body) case strings.HasPrefix(body, "[-]"): body = p.wrap(ansiRed, body) + case strings.HasPrefix(body, "[~]"): + body = p.wrap(ansiYellow, body) case strings.HasPrefix(body, "[.]"), strings.HasPrefix(body, "[:]"), - strings.HasPrefix(body, "[>]"), strings.HasPrefix(body, "[~]"): + strings.HasPrefix(body, "[>]"): body = p.wrap(ansiDim, body) }