Skip to content

Honor NO_COLOR/color-profile degradation for Lip Gloss v2 output paths#42945

Merged
pelikhan merged 10 commits into
mainfrom
copilot/go-fan-review-lipgloss-v2
Jul 2, 2026
Merged

Honor NO_COLOR/color-profile degradation for Lip Gloss v2 output paths#42945
pelikhan merged 10 commits into
mainfrom
copilot/go-fan-review-lipgloss-v2

Conversation

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Lip Gloss v2 no longer auto-detects terminal capabilities, so styled output can emit ANSI even when users request no color and can over-target truecolor on limited terminals. This change routes styled write paths through colorprofile.Writer and applies the small follow-up cleanups identified in the module review.

  • Output-path colorprofile integration (console + logger)

    • Added a shared stderrWriter() wrapper in pkg/console backed by colorprofile.NewWriter(os.Stderr, os.Environ()) (with wasm passthrough stub).
    • Updated key styled stderr emitters (banner, confirm, list, terminal, verbose, composed section rendering, spinner messages) to use the wrapped writer.
    • Updated logger lipgloss.Fprintf calls to use a colorprofile-wrapped stderr writer.
  • Lip Gloss style consistency cleanup

    • Replaced non-TTY fallback lipgloss.Style{} with lipgloss.NewStyle() in compile_schedule_calendar.go for consistency with the rest of the codebase.
  • Theme API guidance cleanup

    • Added a focused comment in pkg/styles/theme.go clarifying when to use package-level adaptiveColor vs per-render lipgloss.LightDark(...).
  • Logger palette maintenance cleanup

    • Refactored logger namespace palette construction to derive from a compact base color slice, preserving behavior while reducing drift risk from duplicated entries.
  • Focused regression coverage

    • Added/updated tests to assert ANSI is stripped when NO_COLOR=1 via the new colorprofile writer path.
func stderrWriter() io.Writer {
	return colorprofile.NewWriter(os.Stderr, os.Environ())
}

Generated by 👨‍🍳 PR Sous Chef · 5.87 AIC · ⌖ 21.7 AIC · ⊞ 6.4K ·

Copilot AI linked an issue Jul 2, 2026 that may be closed by this pull request
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Review Go module charm.land/lipgloss/v2 Honor NO_COLOR/color-profile degradation for Lip Gloss v2 output paths Jul 2, 2026
Copilot AI requested a review from pelikhan July 2, 2026 12:20
@pelikhan pelikhan marked this pull request as ready for review July 2, 2026 12:28
Copilot AI review requested due to automatic review settings July 2, 2026 12:28
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

Copilot AI left a comment

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.

Pull request overview

Routes stderr output that may include Lip Gloss v2 styling through colorprofile.Writer so NO_COLOR and terminal color-capability degradation are honored consistently, plus a few small style/theme cleanups and targeted regression tests.

Changes:

  • Added a stderrWriter() abstraction (and wasm passthrough) and updated multiple console stderr output paths (banner, verbose, list/confirm prompts, terminal helpers, composed sections, spinner output) to write through it.
  • Updated logger output to write through a colorprofile-wrapped stderr writer, and refactored the namespace color palette construction to reduce duplication.
  • Added focused tests asserting ANSI is stripped when NO_COLOR=1 for the new writer path (console + logger), and made a small Lip Gloss style consistency adjustment.
Show a summary per file
File Description
pkg/styles/theme.go Adds guidance comment clarifying when to use package-level adaptive colors vs lipgloss.LightDark(...).
pkg/logger/logger.go Refactors namespace color palette creation and routes logger stderr writes through a colorprofile-wrapped writer.
pkg/logger/logger_test.go Adds a regression test ensuring the colorprofile writer strips ANSI when NO_COLOR=1.
pkg/console/verbose.go Routes verbose stderr output through stderrWriter().
pkg/console/terminal.go Routes terminal/banner-related stderr writes through stderrWriter().
pkg/console/spinner.go Switches spinner output to an io.Writer and routes output through stderrWriter().
pkg/console/list.go Routes non-interactive list rendering stderr output through stderrWriter().
pkg/console/console.go Routes composed section rendering stderr output through stderrWriter().
pkg/console/confirm.go Routes non-interactive confirm prompt stderr output through stderrWriter().
pkg/console/colorprofile_writer.go Introduces non-wasm stderrWriter() backed by colorprofile.NewWriter.
pkg/console/colorprofile_writer_wasm.go Adds wasm/js stub that bypasses colorprofile and returns the underlying writer/stderr.
pkg/console/colorprofile_writer_test.go Adds regression test for ANSI stripping with NO_COLOR=1 on the console writer.
pkg/console/banner.go Routes banner printing to stderr through stderrWriter().
pkg/cli/compile_schedule_calendar.go Replaces lipgloss.Style{} with lipgloss.NewStyle() for consistency in non-terminal output.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread pkg/logger/logger.go
Comment on lines 13 to 17
lipgloss "charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
"github.com/github/gh-aw/pkg/styles"
"github.com/github/gh-aw/pkg/timeutil"
)
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 2 test(s): 2 design, 0 implementation, 0 violation(s).

📊 Metrics (2 tests)
Metric Value
Analyzed 2 (Go: 2, JS: 0)
✅ Design 2 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 2 (100%)
Duplicate clusters 0
Inflation No
🚨 Violations 0
Test File Classification Issues
TestColorProfileWriterStripsANSIWithNoColor pkg/console/colorprofile_writer_test.go:12 design_test — behavioral_contract, high_value None
TestColorProfileWriterStripsANSIWithNoColor pkg/logger/logger_test.go:326 design_test — behavioral_contract, high_value None

Note: Both new tests are near-identical in structure, testing the same NO_COLOR → ANSI-stripping invariant from separate packages (console and logger). They validate different code paths so this is acceptable rather than a duplication violation.

Verdict

Passed. 0% implementation tests (threshold: 30%). All new tests enforce user-visible behavioral contracts with error coverage and descriptive failure messages.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

🧪 Test quality analysis by Test Quality Sentinel · 36.6 AIC · ⌖ 17.1 AIC · ⊞ 6.8K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

✅ Test Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%).

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (147 new lines in pkg/ directories) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/42945-route-lipgloss-v2-output-through-colorprofile-writer.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI could not infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-42945: Route Lip Gloss v2 Output Through colorprofile.Writer

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 42945-route-lipgloss-v2-output-through-colorprofile-writer.md for PR #42945).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 49 AIC · ⌖ 12.5 AIC · ⊞ 8.4K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

REQUEST_CHANGES — The colorprofile integration is directionally correct, but three issues need addressing before merge.

### Blocking issues
  1. Spinner: two separate stderrWriter() calls at construction (spinner.go lines 113 + 119) — spinnerModel.output and tea.WithOutput(...) receive independent colorprofile.Writer instances. Direct writes (line 87) and tea-program output pass through different profile detections. Fix: capture one writer and share it.

  2. pkg/logger missing wasm build-tag stub (logger.go line 65) — pkg/console correctly gates colorprofile behind !js && !wasm. The logger duplicates the same functions with no build tag, so wasm builds of pkg/logger unconditionally call colorprofile.NewWriter. This diverges from the console package's explicit passthrough-on-wasm decision and couples the logger to an inconsistent platform behavior.

### Non-blocking but worth fixing
  • stderrWriter() / os.Environ() called on every write in Stop/StopWithMessage — profile should be captured once at spinner construction.
  • buildColorPalette unchecked index slice — silent panic risk at init() if basePaletteColors shrinks.
  • colorprofile_writer_test.go assertion passes vacuously if writer drops bytes — add a non-empty guard.

🔎 Code quality review by PR Code Quality Reviewer · 73.5 AIC · ⌖ 14.9 AIC · ⊞ 1.6K
Comment /review to run again

Comment thread pkg/console/spinner.go Outdated
// Ctrl+C is still handled via OS signal delivery (SIGINT), which bubbletea
// processes independently of the input reader.
s.program = tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutRenderer(), tea.WithInput(nil))
s.program = tea.NewProgram(model, tea.WithOutput(stderrWriter()), tea.WithoutRenderer(), tea.WithInput(nil))

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.

Two independent colorprofile.Writer instances wrap the same os.Stderr fdmodel.output and tea.WithOutput(...) use separate writers, giving them independent profile detections and potentially inconsistent ANSI handling.

💡 Details and fix

NewSpinner calls stderrWriter() twice:

output: stderrWriter(),           // line 113 — used in spinnerModel.Update direct writes (line 87)
...
tea.WithOutput(stderrWriter()),   // line 119 — used by tea program renderer

At line 87, the model writes directly via m.output:

fmt.Fprintf(m.output, "%s%s%s %s", ansiCarriageReturn, ...)

These two colorprofile.Writer instances each call os.Environ() separately. If the environment could vary between the calls (e.g. in tests that manipulate env vars), the spinner renderer and the direct terminal-control writes operate under different profiles.

Fix: capture one writer and pass it to both:

out := stderrWriter()
model := spinnerModel{
    ...
    output: out,
}
s.program = tea.NewProgram(model, tea.WithOutput(out), tea.WithoutRenderer(), tea.WithInput(nil))

Comment thread pkg/console/spinner.go Outdated
s.program.Quit()
s.wg.Wait() // Wait for the goroutine to complete
fmt.Fprintf(os.Stderr, "%s%s", ansiCarriageReturn, ansiClearLine)
fmt.Fprintf(stderrWriter(), "%s%s", ansiCarriageReturn, ansiClearLine)

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.

stderrWriter() / os.Environ() called on every write in Stop and StopWithMessage — four fresh allocations per spinner lifecycle, none sharing a writer with the model constructed in NewSpinner.

💡 Details and fix

Lines 173, 192, 195, and 199 each call stderrWriter() independently. Every invocation calls os.Environ(), which copies the entire process environment as a []string. The profile is constant for the process lifetime; there is no reason to re-detect it on each write.

More critically, the writer used in Stop/StopWithMessage is a different instance from spinnerModel.output (set at line 113). Any profile state accumulated while the spinner was rendering is invisible to these stop-phase writes, meaning the profile can theoretically differ if env vars change between spinner construction and termination (common in tests).

Fix: store the captured writer in SpinnerWrapper and reuse it in Stop/StopWithMessage:

type SpinnerWrapper struct {
    ...
    out io.Writer  // captured once in NewSpinner via stderrWriter()
}

// In NewSpinner:
out := stderrWriter()
s.out = out
model := spinnerModel{..., output: out}
s.program = tea.NewProgram(model, tea.WithOutput(out), ...)

// In Stop:
fmt.Fprintf(s.out, "%s%s", ansiCarriageReturn, ansiClearLine)

Comment thread pkg/logger/logger.go Outdated
return colorprofile.NewWriter(w, environ)
}

func stderrWriter() io.Writer {

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.

pkg/logger duplicates stderrWriter/newColorProfileWriter without a wasm build-tag stub — unlike pkg/console, the logger always routes through colorprofile.Writer on all platforms, including wasm/js.

💡 Details

pkg/console correctly splits the implementation across two build-tag-gated files:

  • colorprofile_writer.go//go:build !js && !wasm — real colorprofile
  • colorprofile_writer_wasm.go//go:build js || wasm — passthrough

pkg/logger/logger.go has no build tag and imports colorprofile unconditionally. On wasm/js builds the logger will call colorprofile.NewWriter(os.Stderr, os.Environ()). Since WASM stderr maps to the browser console (which does not interpret ANSI), the stripping logic is both unnecessary and a divergence from the console package's explicit passthrough decision.

Additionally, maintaining two copies means any future change to profile-writer construction (options, library version, caching) must be made in both places.

Fix: extract a shared internal package (e.g. pkg/internal/profilewriter) with proper wasm/non-wasm stubs, and have both pkg/console and pkg/logger import from it.

Comment thread pkg/logger/logger.go
)

func buildColorPalette() []lipgloss.Style {
order := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 3}

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.

buildColorPalette indexes basePaletteColors with no bounds check — removing or reordering any entry in basePaletteColors causes a silent index-out-of-range panic at package init.

💡 Details and fix
order := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 3}  // max index = 8

basePaletteColors currently has exactly 9 entries (indices 0–8), so this works today. But the invariant is entirely implicit — a future maintainer adding or removing a color will produce a cryptic runtime: index out of range panic during init() with no indication of where to look.

Simplest fix: add an init-time guard:

for _, idx := range order {
    if idx >= len(basePaletteColors) {
        panic(fmt.Sprintf("logger: buildColorPalette index %d out of range (len=%d)", idx, len(basePaletteColors)))
    }
    palette = append(palette, lipgloss.NewStyle().Foreground(basePaletteColors[idx]))
}

Better fix: add a unit test TestPaletteIndicesInRange that iterates order and calls t.Fatalf if any index ≥ len(basePaletteColors). This catches the violation at test time rather than at runtime.

}

if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("expected ANSI-free output with NO_COLOR, got %q", buf.String())

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.

The assertion can pass vacuously if colorprofile.Writer emits nothing — the test never verifies the payload was actually forwarded, only that no ANSI escapes are present.

💡 Suggested fix

If a future version of colorprofile.NewWriter drops unrecognised sequences instead of passing them through as plain text, buf.String() would be "" — still ANSI-free, so the test passes while the real behavior has silently regressed.

Add a non-empty check before the ANSI assertion:

if buf.Len() == 0 {
    t.Fatal("expected non-empty output; writer appears to have dropped all bytes")
}
if strings.Contains(buf.String(), "\x1b[") {
    t.Fatalf("expected ANSI-free output with NO_COLOR, got %q", buf.String())
}

The same gap exists in pkg/logger/logger_test.go TestColorProfileWriterStripsANSIWithNoColor.

@github-actions github-actions Bot left a comment

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.

Review: Honor NO_COLOR/color-profile degradation for Lip Gloss v2

The overall direction is sound and well-structured. Routing styled output through colorprofile.Writer is the right fix for Lip Gloss v2 losing auto-detection. The console-level build-tag split (native vs. wasm passthrough) is a good pattern.

Blocking issues:

  1. pkg/logger lacks the wasm build split (see inline comment on logger.go:65). pkg/console correctly uses //go:build !js && !wasm to gate the colorprofile import; pkg/logger adds the same helpers but unconditionally imports colorprofile. If logger is compiled for a wasm target this will break. Needs the same file-pair split.

  2. Spinner creates two independent colorprofile.Writer instances (inline on spinner.go:119). model.output and tea.WithOutput each call stderrWriter() separately. Capture a single writer in NewSpinner and pass it to both, then store it on SpinnerWrapper for use in Stop/StopWithMessage instead of calling stderrWriter() again after quit.

Non-blocking:

  1. buildColorPalette index slice (inline on logger.go:53) — the hardcoded order indices will silently panic if basePaletteColors is ever trimmed. A startup bounds check would make the invariant explicit.
What looks good
  • Build-tag split in pkg/console (native + wasm stub) is clean and correct.
  • Replacing lipgloss.Style{} with lipgloss.NewStyle() in the calendar file is a correct consistency fix.
  • The adaptiveColor vs lipgloss.LightDark comment in theme.go is useful guidance.
  • NO_COLOR tests in both pkg/console and pkg/logger are well-written.
  • Palette refactor reduces the duplicate list and is behaviorally equivalent.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 69.2 AIC · ⌖ 8.13 AIC · ⊞ 4.9K

Comment thread pkg/logger/logger.go Outdated
return colorprofile.NewWriter(w, environ)
}

func stderrWriter() io.Writer {

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.

Missing wasm build splitpkg/console correctly isolates the colorprofile import behind a //go:build !js && !wasm guard, with a passthrough stub in colorprofile_writer_wasm.go. This file adds the same newColorProfileWriter/stderrWriter helpers but with an unconditional colorprofile import and no wasm build tag.

If pkg/logger is compiled for wasm/js targets this will either fail to build or pull in colorprofile unexpectedly. Apply the same split here:

// logger_colorprofile.go   (go/redacted):build !js && !wasm
func stderrWriter() io.Writer { return colorprofile.NewWriter(os.Stderr, os.Environ()) }

// logger_colorprofile_wasm.go  (go/redacted):build js || wasm
func stderrWriter() io.Writer { return os.Stderr }

@copilot please address this.

Comment thread pkg/console/spinner.go Outdated
// Ctrl+C is still handled via OS signal delivery (SIGINT), which bubbletea
// processes independently of the input reader.
s.program = tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutRenderer(), tea.WithInput(nil))
s.program = tea.NewProgram(model, tea.WithOutput(stderrWriter()), tea.WithoutRenderer(), tea.WithInput(nil))

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.

Two separate colorprofile.Writer instances for the same logical outputmodel.output (line 113) and the tea.WithOutput writer (line 119) are created by two separate stderrWriter() calls, producing two independent buffered colorprofile.Writer wrappers around os.Stderr.

Both ultimately write to the same os.Stderr file descriptor, so output is not lost. However interleaved writes from two separate colorprofile.Writer wrappers could in principle produce inconsistent profile negotiation or redundant reset sequences.

Consider capturing a single writer and passing it to both:

output := stderrWriter()
model := spinnerModel{..., output: output}
s.program = tea.NewProgram(model, tea.WithOutput(output), ...)

@copilot please address this.

Comment thread pkg/console/spinner.go Outdated
s.program.Quit()
s.wg.Wait() // Wait for the goroutine to complete
fmt.Fprintf(os.Stderr, "%s%s", ansiCarriageReturn, ansiClearLine)
fmt.Fprintf(stderrWriter(), "%s%s", ansiCarriageReturn, ansiClearLine)

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.

Fresh stderrWriter() calls after program.Quit() / wg.Wait() create a new colorprofile.Writer — the ANSI clear-line sequences at lines 173, 192, and 195 are written through freshly allocated writers that have no context about what the Bubble Tea program already rendered. This is harmless for plain-text sequences like \r / clear-line, but if colorprofile.Writer ever buffers profile state, the teardown writes will use a fresh negotiation rather than the one the spinner program used.

The same single output captured in NewSpinner should be plumbed into SpinnerWrapper and reused here, rather than calling stderrWriter() again at stop time.

@copilot please address this.

Comment thread pkg/logger/logger.go
)

func buildColorPalette() []lipgloss.Style {
order := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 3}

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.

buildColorPalette index hard-codes out-of-sync with basePaletteColors lengthbasePaletteColors has 9 entries (indices 0–8). The order slice uses index 8 (valid) but if basePaletteColors is ever shortened the out-of-bounds access will panic at startup. Consider adding a bounds guard or using a test/assertion:

for _, idx := range order {
    if idx >= len(basePaletteColors) {
        panic(fmt.Sprintf("buildColorPalette: index %d out of range (%d colors)", idx, len(basePaletteColors)))
    }
    ...
}

(Minor, but the refactor's goal is to reduce drift risk — an explicit guard makes the contract visible.)

@copilot please address this.

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — requesting changes on a few concrete issues before merging.

📋 Key Themes & Highlights

Key Themes

  • Spinner writer fragmentationNewSpinner allocates two independent colorprofile.Writer instances and Stop/StopWithMessage create fresh ones at call time. All four should share a single instance stored on the struct.
  • Duplicated stderrWriter / newColorProfileWriter — the same pair of functions exists in both pkg/console and pkg/logger. A shared export (or a thin pkg/colorprofile package) would prevent drift.
  • Test coverage gaps — only the NO_COLOR hard-strip case is tested; the positive (color preserved) and downgrade (truecolor → 256-color) cases are missing. The identical test in logger_test.go should be removed once the duplication is resolved.
  • Magic index sliceorder := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 3} in buildColorPalette is opaque; named indices would make the intent self-documenting.
  • Interactive huh path — it is worth confirming whether the huh-driven TTY forms also need a profile-writer wrapper; the PR only addresses the non-interactive / plain-text output paths.

Positive Highlights

  • ✅ Clean platform split via build tags (!js && !wasm vs js || wasm) with sensible pass-through stub
  • lipgloss.Style{}lipgloss.NewStyle() is the right idiomatic fix for Lip Gloss v2
  • ✅ Focused comment in theme.go clarifying adaptiveColor vs lipgloss.LightDark is genuinely useful
  • ✅ Logger palette refactor removes the duplicated literal entries and reduces future drift risk
  • ✅ Regression test is well-structured and covers the primary reported symptom

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 91.7 AIC · ⌖ 9.19 AIC · ⊞ 6.6K
Comment /matt to run again

Comments that could not be inline-anchored

pkg/console/spinner.go:119

[/diagnosing-bugs] NewSpinner calls stderrWriter() twice (lines 113 and 119), producing two separate colorprofile.Writer instances wrapping the same os.Stderr. If the two writers detect terminal capabilities independently, any state-dependent profiling could diverge mid-spinner.

<details>
<summary>💡 Suggestion</summary>

Capture the writer once and reuse it:

outputWriter := stderrWriter()
model := spinnerModel{
    ...
    output: outputWriter,
}
s.program = tea.NewProgram(…

</details>

<details><summary>pkg/console/spinner.go:173</summary>

**[/diagnosing-bugs]** `Stop()` and `StopWithMessage()` call `stderrWriter()` at the time of invocation, creating fresh `colorprofile.Writer` instances that are different from the one the spinner program was configured with (line 119). This means the terminal-clearing escape codes (`\r\033[K`) could be emitted by a different writer than the one used to render the spinner progress lines, potentially defeating the ANSI-stripping behavior under `NO_COLOR`.

&lt;details&gt;
&lt;summary&gt;💡 Suggestion&lt;/summar</details>

<details><summary>pkg/console/colorprofile_writer_test.go:20</summary>

**[/tdd]** The test verifies that ANSI escape codes are stripped with `NO_COLOR=1`, but does not test the positive casethat color codes are **preserved** when `NO_COLOR` is absent and the profile supports color. Adding a complementary assertion would make the test read as a full specification rather than just a guard.

&lt;details&gt;
&lt;summary&gt;💡 Suggested addition&lt;/summary&gt;

```go
func TestColorProfileWriterPreservesANSIWithColor(t *testing.T) {
    var buf bytes.Buffer
    w := newColorProfileWr…

</details>

<details><summary>pkg/logger/logger.go:65</summary>

**[/diagnosing-bugs]** `logger.go` defines its own `stderrWriter()` / `newColorProfileWriter()` that is an exact duplicate of the same pair in `pkg/console/colorprofile_writer.go`. Two independent copies of the same wrapping logic mean future drift (e.g. adding a `FORCE_COLOR` override) would require changes in two places.

&lt;details&gt;
&lt;summary&gt;💡 Suggestion&lt;/summary&gt;

Export a shared constructor from `pkg/console` (or a dedicated `pkg/colorprofile` package) and import it here:

```go
// pkg/cons…

</details>

<details><summary>pkg/logger/logger.go:53</summary>

**[/diagnosing-bugs]** The `order` slice `{0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 3}` is magic — the choice of 12 entries, and specifically repeating indices 0 (Info), 1 (Success), and 3 (Purple) but not others, requires a reader to cross-reference `basePaletteColors` to understand. This is an improvement over duplicated literal structs, but the intent is still opaque.

&lt;details&gt;
&lt;summary&gt;💡 Suggestion&lt;/summary&gt;

Either add an inline comment naming what each repeat represents, or use named constants:…

</details>

<details><summary>pkg/logger/logger_test.go:341</summary>

**[/tdd]** `TestColorProfileWriterStripsANSIWithNoColor` in the logger package is functionally identical to the same test in `pkg/console/colorprofile_writer_test.go`. Since the duplication follows from the duplicated `newColorProfileWriter` implementation (see related comment on logger.go:65), if that duplication is resolved the duplicate test should be removed as well — otherwise passing tests in one package can mask a failing implementation in the other.

@copilot please address this.

</details>

<details><summary>pkg/console/colorprofile_writer_test.go:12</summary>

**[/tdd]** There is no test for the `ANSI_COLORS_DISABLED=1` path (another `NO_COLOR`-equivalent that `colorprofile` honours), nor for the color-profile downgrade case (e.g. truecolor → 256-color when `COLORTERM` is absent). The test only exercises the hard strip case. Covering at least one downgrade scenario would give confidence that the profiler is wired correctly, not just that it can disable output.

&lt;details&gt;
&lt;summary&gt;💡 Example: 256-color downgrade&lt;/summary&gt;

```go
func TestColorProfileW…

</details>

<details><summary>pkg/console/colorprofile_writer.go:16</summary>

**[/codebase-design]** `stderrWriter()` is a package-private helper that calls `os.Environ()` and constructs a new `colorprofile.Writer` on every call. A call site like `RenderComposedSections` (console.go) captures `out := stderrWriter()` once per function, which is fine, but sites like `spinner.go` call it 3+ times for the same spinner lifetime. The repeated environment reads and writer allocations are cheap on most paths, but there is no way to inject an alternative writer in tests — the fun…

</details>

<details><summary>pkg/console/confirm.go:63</summary>

**[/diagnosing-bugs]** `showTextConfirm` now routes styled output through `stderrWriter()` which strips ANSI, but the prompt text (&quot;Enter y/yes/1 to confirm...&quot;) is plain text with no ANSI anyway. The real regression risk is that `showInteractiveConfirm` (the TTY path, which drives huh forms) is not touched by this PR — if huh renders styled output directly to its own `os.Stderr` reference without going through the profile writer, the fix is incomplete for interactive mode. Worth confirming whe…

</details>

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

🤖 PR Triage

Field Value
Category feature
Risk 🔴 High
Score 68 / 100
Action ✅ fast_track

Score breakdown: Impact 38 · Urgency 20 · Quality 10

CI blocked; 3× CHANGES_REQUESTED from automated reviews. Modifies 14 files across pkg/console, pkg/logger, pkg/styles, pkg/cli — all user-facing output paths. High-priority correctness fix for Lip Gloss v2 color-profile. Needs review to resolve requested changes before merge.

Run §28591321991

Generated by 🔧 PR Triage Agent · 76 AIC · ⌖ 9.74 AIC · ⊞ 1.6K ·

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill and address the remaining blocking review feedback before rerunning checks. Priorities:

  • link the ADR in the PR body so the design gate can pass
  • resolve the outstanding automated review requests
  • refresh the branch from main if needed after fixes

Run: https://github.com/github/gh-aw/actions/runs/28592810254

Generated by 👨‍🍳 PR Sous Chef · 17.5 AIC · ⌖ 10.4 AIC · ⊞ 4K ·

Copilot AI and others added 2 commits July 2, 2026 13:28
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Mirror the pkg/console pattern: move newColorProfileWriter/stderrWriter
into colorprofile_writer.go (!js && !wasm) and colorprofile_writer_wasm.go
(js || wasm) so the colorprofile import is excluded from js/wasm builds.

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill and address the remaining blocking review feedback before rerunning checks. Priorities:

  • link the ADR in the PR body so the design gate can pass
  • resolve ...

Done. Mirrored the pkg/console build-tag pattern in pkg/logger:

  • Added pkg/logger/colorprofile_writer.go (//go:build !js && !wasm) — uses colorprofile.NewWriter
  • Added pkg/logger/colorprofile_writer_wasm.go (//go:build js || wasm) — passthrough to the raw writer
  • Removed the colorprofile import and the two functions from logger.go

make fmt, make lint, and make test-unit all pass locally. Committed in the latest push.

Copilot AI requested a review from gh-aw-bot July 2, 2026 13:33
@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address unresolved review comments, and rerun checks once the branch is up to date.
Unresolved review feedback:

  • github-actions:
  • github-actions:
  • github-actions:

Generated by 👨‍🍳 PR Sous Chef · 5.87 AIC · ⌖ 21.7 AIC · ⊞ 6.4K ·

Copilot AI and others added 2 commits July 2, 2026 14:27
…st removal

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
… bounds guard, remove duplicate test

- spinner.go: capture stderrWriter() once in NewSpinner, store on SpinnerWrapper.out,
  share with spinnerModel.output, tea.WithOutput, and Stop/StopWithMessage calls
- logger.go: add explicit panic in buildColorPalette if an order index exceeds
  len(basePaletteColors), making the invariant visible at startup
- logger_test.go: remove TestColorProfileWriterStripsANSIWithNoColor (duplicate of
  the same test in pkg/console/colorprofile_writer_test.go)

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address unresolved review comments, and rerun checks once the branch is up to date.
Unresolved review feedback:

  • github-actions: ...

Addressed all three @copilot-tagged threads in the latest push:

  • Spinner writer sharing (spinner.go): stderrWriter() is now called once in NewSpinner, captured as out, passed to both spinnerModel.output and tea.WithOutput(out), and stored on SpinnerWrapper.out for reuse in Stop/StopWithMessage.
  • buildColorPalette bounds guard (logger.go): added an explicit panic when any order index ≥ len(basePaletteColors), surfacing the invariant at startup.
  • Duplicate test removed (logger_test.go): TestColorProfileWriterStripsANSIWithNoColor was identical to the test in pkg/console/colorprofile_writer_test.go and has been removed.

make fmt, make lint, and make test-unit all pass locally.

@pelikhan

pelikhan commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

@copilot merge main and recompile

…-lipgloss-v2

# Conflicts:
#	.github/workflows/plan.lock.yml

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot merge main and recompile

Done. Merged main (resolved the plan.lock.yml metadata conflict by taking main's updated engine versions). Recompile ran cleanly with no further changes needed.

@pelikhan

pelikhan commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

…brarycode linter

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@copilot lint go https://github.com/github/gh-aw/actions/runs/28600637256/job/84810141551

Fixed in the latest commit. The panicinlibrarycode linter was flagging the explicit panic I added in buildColorPalette. Removed it — Go's native index-out-of-bounds handles the invariant naturally since both order and basePaletteColors are hardcoded in the same file.

@pelikhan pelikhan merged commit 593ac0b into main Jul 2, 2026
30 checks passed
@pelikhan pelikhan deleted the copilot/go-fan-review-lipgloss-v2 branch July 2, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[go-fan] Go Module Review: charm.land/lipgloss/v2

4 participants