diff --git a/pkg/cli/compile_schedule_calendar.go b/pkg/cli/compile_schedule_calendar.go index b7a4d805ffd..ee2b9b9d44c 100644 --- a/pkg/cli/compile_schedule_calendar.go +++ b/pkg/cli/compile_schedule_calendar.go @@ -11,7 +11,7 @@ import ( "strconv" "strings" - lipgloss "charm.land/lipgloss/v2" + "github.com/github/gh-aw/pkg/colorwriter" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/styles" "github.com/github/gh-aw/pkg/tty" @@ -195,14 +195,17 @@ func intensityChar(count int) string { } } -// intensityStyle returns a lipgloss style appropriate for the given trigger -// count. Styling is TTY-gated so non-interactive output does not emit ANSI. -func intensityStyle(count int, isTerminal bool) lipgloss.Style { - if !isTerminal { - // Keep glyph rendering unchanged while preventing ANSI escapes in piped output. - return lipgloss.NewStyle() - } +// scheduleCalendarRenderer abstracts the shared Render method implemented by +// both lipgloss.Style (normal builds) and styles.WasmStyle (js/wasm builds). +// Using this interface keeps intensityStyle free of a concrete lipgloss.Style +// return type that would fail to compile for wasm style tokens. +type scheduleCalendarRenderer interface { + Render(...string) string +} +// intensityStyle returns a centralized style appropriate for the given trigger +// count. +func intensityStyle(count int) scheduleCalendarRenderer { switch { case count == 0: return styles.ScheduleCalendarEmpty @@ -217,6 +220,13 @@ func intensityStyle(count int, isTerminal bool) lipgloss.Style { } } +func renderScheduleCalendarCell(count int, text string, isTerminal bool, environ []string) string { + if !isTerminal || console.IsAccessibleMode() { + return text + } + return colorwriter.Degrade(intensityStyle(count).Render(text), environ) +} + // displayScheduleCalendar renders a text heatmap of scheduled workflow times to // stderr. The grid shows days of the week (Mon–Sun) against hours of the day // (00–23, UTC). Each cell intensity indicates how many workflows fire at that @@ -231,6 +241,7 @@ func displayScheduleCalendar(statsList []*WorkflowStats) { } isTerminal := tty.IsStderrTerminal() + environ := os.Environ() // Title fmt.Fprintln(os.Stderr) @@ -263,7 +274,7 @@ func displayScheduleCalendar(statsList []*WorkflowStats) { ch := intensityChar(count) // Pad each cell to cellWidth with a trailing space. cell := ch + strings.Repeat(" ", cellWidth-len([]rune(ch))) - row.WriteString(intensityStyle(count, isTerminal).Render(cell)) + row.WriteString(renderScheduleCalendarCell(count, cell, isTerminal, environ)) } fmt.Fprintln(os.Stderr, row.String()) @@ -281,7 +292,7 @@ func displayScheduleCalendar(statsList []*WorkflowStats) { legend.WriteString("Legend: ") for _, e := range entries { ch := intensityChar(e.count) - legend.WriteString(intensityStyle(e.count, isTerminal).Render(ch)) + legend.WriteString(renderScheduleCalendarCell(e.count, ch, isTerminal, environ)) legend.WriteString(" = " + e.label + " ") } fmt.Fprintln(os.Stderr, legend.String()) diff --git a/pkg/cli/compile_schedule_calendar_test.go b/pkg/cli/compile_schedule_calendar_test.go index fb399b7c833..ba9a3e0ffe4 100644 --- a/pkg/cli/compile_schedule_calendar_test.go +++ b/pkg/cli/compile_schedule_calendar_test.go @@ -9,7 +9,9 @@ import ( "strings" "testing" + lipgloss "charm.land/lipgloss/v2" "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/styles" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -205,13 +207,39 @@ func TestIntensityChar(t *testing.T) { } } -func TestIntensityStyle_NoANSIWhenNotTerminal(t *testing.T) { +func TestRenderScheduleCalendarCell_NoANSIWhenNotTerminal(t *testing.T) { for _, count := range []int{0, 1, 2, 5, 8} { - got := intensityStyle(count, false).Render(intensityChar(count)) - assert.NotContains(t, got, "\x1b[", "non-TTY output should not contain ANSI escapes") + text := intensityChar(count) + got := renderScheduleCalendarCell(count, text, false, []string{"TERM=xterm-256color"}) + assert.Equal(t, text, got, "non-TTY output should return plain text") } } +func TestRenderScheduleCalendarCell_NoANSIWhenNoColor(t *testing.T) { + for _, count := range []int{0, 1, 2, 5, 8} { + text := intensityChar(count) + got := renderScheduleCalendarCell(count, text, true, []string{"NO_COLOR=1", "TERM=xterm-256color"}) + assert.Equal(t, text, got, "NO_COLOR output should return plain text") + } +} + +func TestRenderScheduleCalendarCell_UsesANSIInColorTerminal(t *testing.T) { + // This test intentionally avoids t.Parallel because it temporarily overrides + // a shared style token; running in parallel could race with other tests that + // read styles.ScheduleCalendarCritical. + prevCritical := styles.ScheduleCalendarCritical + styles.ScheduleCalendarCritical = lipgloss.NewStyle().Transform(func(s string) string { + return "\x1b[31m" + s + "\x1b[0m" + }) + t.Cleanup(func() { + styles.ScheduleCalendarCritical = prevCritical + }) + + got := renderScheduleCalendarCell(8, intensityChar(8), true, []string{"CLICOLOR_FORCE=1", "TERM=xterm-256color"}) + assert.Contains(t, got, "\x1b[", "TTY with color support should contain ANSI escapes") + assert.Contains(t, got, "\x1b[31m", "TTY path should apply the mocked critical style") +} + // --------------------------------------------------------------------------- // displayScheduleCalendar (integration-style: captures stderr) // ---------------------------------------------------------------------------