Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/agentic-auto-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ name: Agentic Auto-Upgrade

on:
schedule:
- cron: "21 3 * * 5" # Weekly (auto-upgrade)
- cron: "11 4 * * 6" # Weekly (auto-upgrade)
workflow_dispatch:

permissions:
Expand Down
56 changes: 56 additions & 0 deletions docs/adr/46197-hoist-palette-vars-and-honor-no-color-stdout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# ADR-46197: Hoist Palette Color Vars and Honor NO_COLOR on Stdout

**Date**: 2026-07-18
**Status**: Draft
**Deciders**: Unknown

---

### Context

The `pkg/styles` and `pkg/console` packages use lipgloss v2 for terminal styling. Two related defects were surfaced during a lipgloss v2 Go Fan review:

1. `huh_theme.go` called `lipgloss.Color(hexConstant)` inside `HuhTheme(isDark)` on every invocation, re-parsing the same hex strings that `theme.go` already parsed once at package init for its `adaptiveColor` vars — duplicating work and creating two independent `color.Color` objects from the same source constants.

2. `applyStyle` in `pkg/console` gated on TTY presence only (`isTTY()`), but did not consult the color-profile environment (`NO_COLOR`, `COLORTERM`, `TERM`). A terminal session with `NO_COLOR=1` still received raw ANSI escape sequences from string-returning format helpers, while the equivalent stderr path already honored the color profile via `stderrWriter()`.

### Decision

We will make two coordinated changes to address both defects:

1. Declare 22 package-level `color.Color` vars in `pkg/styles/theme.go`, parsed once from the existing hex constants at init time, and have both the `adaptiveColor` structs and `huh_theme.go`'s `LightDark` calls reference these shared vars — eliminating duplicate hex parsing and establishing a single source of truth for the palette.

2. Add `colorwriter.Stdout()` and `colorwriter.Degrade(s, environ)` to `pkg/colorwriter`, then update `applyStyle` in `pkg/console` to call `colorwriter.Degrade` when stdout is a TTY, routing rendered ANSI through a `colorprofile.Writer` that honors `NO_COLOR`, `COLORTERM`, and `TERM` — matching the existing stderr behavior.

### Alternatives Considered

#### Alternative 1: Keep Inline `lipgloss.Color()` Calls in `huh_theme.go`

Leave `huh_theme.go` parsing hex strings on each `HuhTheme(isDark)` call. This is self-contained and requires no structural change. Rejected because it duplicates parsing work already done in `theme.go` and creates two independent `color.Color` objects from the same constants, violating the single-source-of-truth principle and adding unnecessary per-render overhead.

#### Alternative 2: Route All Stdout Output Through an `io.Writer` Pipeline

Replace the string-returning `applyStyle` pattern with an io.Writer pipeline where all styling writes directly to `colorwriter.Stdout()`. This would eliminate the need for a separate `Degrade` step. Rejected because it would require refactoring every caller from string-returning helpers to an io.Writer-passing API — a significantly larger change that does not align with the cleanup scope of this PR.

#### Alternative 3: Check `NO_COLOR` Inline in `applyStyle`

Manually check `os.Getenv("NO_COLOR") != ""` inside `applyStyle` before applying styles. Simpler than using `colorwriter.Degrade`, but covers only `NO_COLOR` and ignores the full color-profile spec (`COLORTERM`, `TERM`, capability-level downgrading). Rejected because `colorprofile.Writer` already encodes the full spec, and partial coverage would leave users of non-standard terminals without proper degradation.

### Consequences

#### Positive
- Single source of truth for palette colors: hex-to-`color.Color` parsing happens exactly once at package init, shared by both the `adaptiveColor` (startup-probe) path and the `LightDark` (per-render) path in huh themes.
- `NO_COLOR`, `COLORTERM`, and `TERM` are now honored for all stdout-bound styled strings, matching the existing behavior on the stderr path.
- Wasm build remains fully functional: platform stubs for `Stdout()` and `Degrade()` pass through unchanged.

#### Negative
- `applyStyle` now calls `colorwriter.Degrade` on every TTY invocation, adding one extra string allocation and a `colorprofile.Writer` pass compared to the previous direct `style.Render(text)` return.
- Adding 22 package-level `color.Color` vars to `pkg/styles` marginally increases module initialization footprint.

#### Neutral
- The two rendering paths (`adaptiveColor` structs for startup-probe selection vs `lipgloss.LightDark` for per-render selection in huh themes) remain architecturally distinct; this PR makes the shared palette the common underpinning but does not unify the selection strategies.
- `applyStyleWithTTY` (stderr-bound helpers) is unchanged; those paths already obtain colorprofile degradation at print time through `stderrWriter()`.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
14 changes: 10 additions & 4 deletions pkg/colorwriter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@

The `colorwriter` package provides a factory for `io.Writer` values that adapt ANSI color output based on the current environment. On non-wasm platforms it wraps the given writer with [`github.com/charmbracelet/colorprofile`](https://pkg.go.dev/github.com/charmbracelet/colorprofile) so that `NO_COLOR`, `COLORTERM`, and terminal capability are consulted automatically. On wasm (`js` / `wasm` build tags) the package returns the writer unchanged, since color-profile detection is not supported on that platform.

It is imported by `pkg/console` and `pkg/logger` to obtain a consistent stderr writer.
It is imported by `pkg/console` and `pkg/logger` to obtain consistent stdout/stderr writers and to degrade already-rendered ANSI strings when output helpers must return a string instead of writing directly.

## Public API

| Symbol | Signature | Description |
|--------|-----------|-------------|
| `New` | `func(w io.Writer, environ []string) io.Writer` | Returns a color-profile-aware writer wrapping `w` using `environ` (e.g. `os.Environ()`) to detect `NO_COLOR`, `COLORTERM`, and terminal capabilities. On wasm, returns `w` unchanged. |
| `Stderr` | `func() io.Writer` | Convenience wrapper that calls `New(os.Stderr, os.Environ())`. On wasm, returns `os.Stderr` directly. |
| `Stdout` | `func() io.Writer` | Convenience wrapper that calls `New(os.Stdout, os.Environ())`. On wasm, returns `os.Stdout` directly. |
| `Degrade` | `func(s string, environ []string) string` | Routes a pre-rendered ANSI string through a color-profile-aware writer backed by a string builder, downgrading or stripping ANSI according to `environ`. On wasm, returns `s` unchanged. |

### Build variants

| Build constraint | Behavior |
|-----------------|----------|
| `!js && !wasm` (`colorprofile_writer.go`) | `New` delegates to `colorprofile.NewWriter`; `Stderr` wraps `os.Stderr` with the process environment. |
| `js \|\| wasm` (`colorprofile_writer_wasm.go`) | `New` returns `w` unchanged; `Stderr` returns `os.Stderr` directly. Color-profile detection is not supported on wasm. |
| `!js && !wasm` (`colorprofile_writer.go`) | `New` delegates to `colorprofile.NewWriter`; `Stderr` and `Stdout` wrap the corresponding standard streams with the process environment; `Degrade` transforms a rendered ANSI string through an in-memory color-profile-aware writer. |
| `js \|\| wasm` (`colorprofile_writer_wasm.go`) | `New` returns `w` unchanged; `Stderr` and `Stdout` return the corresponding standard streams directly; `Degrade` returns the original string unchanged. Color-profile detection is not supported on wasm. |

## Usage Examples

Expand All @@ -38,6 +40,10 @@ fmt.Fprintln(w, "styled output respects NO_COLOR and terminal capabilities")
// Obtain a ready-to-use stderr writer.
stderr := colorwriter.Stderr()
fmt.Fprintln(stderr, "styled output to os.Stderr")

// Degrade a rendered ANSI string before returning it to a stdout caller.
plain := colorwriter.Degrade("\x1b[31mwarning\x1b[0m", []string{"NO_COLOR=1", "TERM=xterm-256color"})
fmt.Println(plain)
```

## Dependencies
Expand All @@ -55,7 +61,7 @@ This appendix is generated from the current non-test Go source files in this pac
| Types | 0 |
| Constants | 0 |
| Variables | 0 |
| Functions and methods | 2 |
| Functions and methods | 4 |
| Additional symbols documented in this appendix | 0 |

The sections above already mention every exported top-level symbol in the current source tree.
Expand Down
23 changes: 23 additions & 0 deletions pkg/colorwriter/colorprofile_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package colorwriter
import (
"io"
"os"
"strings"

"github.com/charmbracelet/colorprofile"
)
Expand All @@ -20,3 +21,25 @@ func New(w io.Writer, environ []string) io.Writer {
func Stderr() io.Writer {
return New(os.Stderr, os.Environ())
}

// Stdout returns a color-profile-aware writer for os.Stdout using the current
// process environment.
func Stdout() io.Writer {
return New(os.Stdout, os.Environ())
}
Comment on lines +25 to +29

// Degrade returns s with ANSI sequences downgraded (or stripped) according to
// the current process environment (NO_COLOR, COLORTERM, TERM). It is intended
// for use with string-returning format helpers: render the style first, then
// call Degrade so that the caller's output honors the color profile.
func Degrade(s string, environ []string) string {
var buf strings.Builder
w := colorprofile.NewWriter(&buf, environ)
// colorprofile.Writer writes synchronously and does not buffer past Write,
// and strings.Builder writes cannot fail, so a write error would indicate an
// unexpected future behavior change; fall back to the original string then.
if _, err := io.WriteString(w, s); err != nil {
return s
}
return buf.String()
Comment on lines +35 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible incomplete output if colorprofile.Writer buffers: buf.String() is called immediately after fmt.Fprint(w, s) with no flush or close, so if colorprofile.Writer buffers internally, the returned string may be silently truncated or empty.

💡 Suggested fix

colorprofile.Writer wraps an io.Writer and may implement io.Closer or have a Flush method to drain any internal buffer before the underlying writer holds the final bytes. Without seeing the contract guaranteed by colorprofile.NewWriter, the safe pattern is:

func Degrade(s string, environ []string) string {
	var buf strings.Builder
	w := colorprofile.NewWriter(&buf, environ)
	fmt.Fprint(w, s)
	if c, ok := w.(io.Closer); ok {
		_ = c.Close()
	}
	return buf.String()
}

Or, if the library guarantees synchronous pass-through writes (no buffering), add a comment asserting that contract so the next reader does not have to reverse-engineer it.

}
11 changes: 11 additions & 0 deletions pkg/colorwriter/colorprofile_writer_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,14 @@ func New(w io.Writer, _ []string) io.Writer {
func Stderr() io.Writer {
return os.Stderr
}

// Stdout returns os.Stdout directly; color-profile detection is not supported
// on wasm.
func Stdout() io.Writer {
return os.Stdout
}

// Degrade returns s unchanged; color-profile detection is not supported on wasm.
func Degrade(s string, _ []string) string {
return s
}
49 changes: 49 additions & 0 deletions pkg/colorwriter/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,52 @@ func TestSpec_PublicAPI_Stderr(t *testing.T) {
require.NotNil(t, w, "Stderr should return a non-nil io.Writer")
assert.Implements(t, (*io.Writer)(nil), w, "Stderr should return an io.Writer as documented")
}

// TestSpec_PublicAPI_Stdout validates the documented behavior of Stdout from the
// README.md specification.
func TestSpec_PublicAPI_Stdout(t *testing.T) {
w := colorwriter.Stdout()
require.NotNil(t, w, "Stdout should return a non-nil io.Writer")
assert.Implements(t, (*io.Writer)(nil), w, "Stdout should return an io.Writer as documented")
}

// TestSpec_PublicAPI_Degrade validates the documented behavior of Degrade from the
// README.md specification.
func TestSpec_PublicAPI_Degrade(t *testing.T) {
const ansiRed = "\x1b[31mhello\x1b[0m"

tests := []struct {
name string
environ []string
want string
}{
{
name: "strips ansi when NO_COLOR is set",
environ: []string{"NO_COLOR=1", "TERM=xterm-256color"},
want: "hello",
},
{
name: "strips ansi for dumb terminals",
environ: []string{"TERM=dumb"},
want: "hello",
},
{
name: "preserves ansi for forced color profiles",
environ: []string{"TERM=xterm-256color", "CLICOLOR_FORCE=1"},
want: ansiRed,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := colorwriter.Degrade(ansiRed, tt.environ)
if tt.want == ansiRed {
assert.Contains(t, got, "hello")
assert.Contains(t, got, "\x1b[31m")
assert.Contains(t, got, "\x1b[m")
return
}
assert.Equal(t, tt.want, got)
})
}
}
Loading
Loading