diff --git a/actions/setup/sh/install_copilot_cli.sh b/actions/setup/sh/install_copilot_cli.sh index 6b29e47b68b..8a15fd08c74 100755 --- a/actions/setup/sh/install_copilot_cli.sh +++ b/actions/setup/sh/install_copilot_cli.sh @@ -326,14 +326,18 @@ find_cached_copilot_bin() { requested_version_normalized="$(normalize_version "$requested_version")" fi - for tool_cache_root in \ - "${RUNNER_TOOL_CACHE:-}" \ - /opt/hostedtoolcache \ - /home/runner/work/_tool - do - if [ -z "$tool_cache_root" ]; then - continue - fi + local -a tool_cache_roots + + if [ -n "${RUNNER_TOOL_CACHE:-}" ]; then + tool_cache_roots=("${RUNNER_TOOL_CACHE}") + else + tool_cache_roots=( + /opt/hostedtoolcache + /home/runner/work/_tool + ) + fi + + for tool_cache_root in "${tool_cache_roots[@]}"; do if [ ! -d "${tool_cache_root}/copilot-cli" ]; then echo " Toolcache root ${tool_cache_root}/copilot-cli not found, skipping" >&2 continue diff --git a/go.mod b/go.mod index 35d5eda1740..71787711abd 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( charm.land/bubbletea/v2 v2.0.7 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.4 - github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/x/exp/golden v0.0.0-20260602025833-85a30b5e440a + github.com/charmbracelet/x/term v0.2.2 github.com/cli/go-gh/v2 v2.13.0 github.com/creack/pty v1.1.24 github.com/fsnotify/fsnotify v1.10.1 @@ -42,13 +42,13 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20251106172358-54469c29c2bc // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/cli/safeexec v1.0.1 // indirect diff --git a/pkg/styles/spec_test.go b/pkg/styles/spec_test.go index 18dfd115a92..06a7f45860a 100644 --- a/pkg/styles/spec_test.go +++ b/pkg/styles/spec_test.go @@ -64,7 +64,7 @@ func TestSpec_Constants_DocumentedSemanticColorHexValues(t *testing.T) { // adaptive color variables are defined with both Light and Dark hex values as // listed in the package README.md adaptive color table. // -// Specification: "These variables provide compat.AdaptiveColor values that +// Specification: "These variables provide adaptiveColor values that // auto-select the correct shade at render time." func TestSpec_Constants_AllElevenAdaptiveColors(t *testing.T) { type colorDef struct { diff --git a/pkg/styles/theme.go b/pkg/styles/theme.go index 92845694b8e..6e21b17e4fd 100644 --- a/pkg/styles/theme.go +++ b/pkg/styles/theme.go @@ -4,8 +4,9 @@ // // # Adaptive Color System // -// This package uses compat.AdaptiveColor to automatically adapt colors based on the -// terminal background, ensuring good readability in both light and dark terminal themes. +// This package defines an adaptiveColor type that automatically selects between +// light and dark color variants based on the terminal background, ensuring good +// readability in both light and dark terminal themes. // Each color constant includes both Light and Dark variants that are automatically // selected based on the user's terminal configuration. // @@ -47,48 +48,58 @@ package styles import ( + "image/color" "os" "runtime" lipgloss "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/compat" - "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/term" ) -func configureLipglossCompat() { - compat.HasDarkBackground = lipgloss.HasDarkBackground(os.Stdin, os.Stderr) - compat.Profile = colorprofile.Detect(os.Stderr, os.Environ()) +// hasDarkBackground tracks whether the terminal has a dark background. +// Default is true (dark), which suits the vast majority of modern terminals. +// On non-Windows platforms it is updated at startup by configureHasDarkBackground. +// On Windows the probe is skipped entirely: the lipgloss background-color query +// can crash (STATUS_DLL_INIT_FAILED) or hang under ConPTY and other +// pseudo-terminal environments, so the safe default is always used instead. +var hasDarkBackground = true + +// adaptiveColor selects between a light and a dark color variant based on the +// terminal background detected at startup. +type adaptiveColor struct { + Light color.Color + Dark color.Color } -func shouldConfigureLipglossCompat(goos string, stderrMode os.FileMode, statErr error) bool { - // On Windows, querying terminal capabilities against redirected/pipe handles - // can hang under some wrapper environments. Skip startup probing unless stderr - // is attached to a character device. +// RGBA satisfies the color.Color interface. +func (c adaptiveColor) RGBA() (uint32, uint32, uint32, uint32) { + if hasDarkBackground { + return c.Dark.RGBA() + } + return c.Light.RGBA() +} + +type backgroundDetector func(term.File, term.File) bool + +func configureHasDarkBackground(detector backgroundDetector) { + hasDarkBackground = detector(os.Stdin, os.Stderr) +} + +func shouldProbeTerminalBackground(goos string) bool { + // On Windows, the lipgloss background-color query can crash + // (STATUS_DLL_INIT_FAILED) or hang under ConPTY and other pseudo-terminal + // environments. Skip the probe entirely and use the default (dark background). + // A Windows-safe background detector can be added later without changing the + // startup-safety contract in this package. if goos == "windows" { - if statErr != nil { - return false - } - return stderrMode&(os.ModeDevice|os.ModeCharDevice) == (os.ModeDevice | os.ModeCharDevice) + return false } return true } func init() { - stderrInfo, statErr := os.Stderr.Stat() - // Defensive fallback: Stat should not normally return (nil, nil). Treat that - // impossible state as an invalid stderr handle so Windows startup probing is - // safely skipped. - if statErr == nil && stderrInfo == nil { - statErr = os.ErrInvalid - } - // Zero mode means "unknown/unset"; on Windows this keeps the startup probe - // disabled unless stderr is explicitly confirmed as a character device. - stderrMode := os.FileMode(0) - if stderrInfo != nil { - stderrMode = stderrInfo.Mode() - } - if shouldConfigureLipglossCompat(runtime.GOOS, stderrMode, statErr) { - configureLipglossCompat() + if shouldProbeTerminalBackground(runtime.GOOS) { + configureHasDarkBackground(lipgloss.HasDarkBackground) } } @@ -125,67 +136,67 @@ const ( // Dark variants use brighter colors (Dracula theme inspired) for dark backgrounds. var ( // ColorError is used for error messages and critical issues. - ColorError = compat.AdaptiveColor{ + ColorError = adaptiveColor{ Light: lipgloss.Color(hexColorErrorLight), // Darker red for light backgrounds Dark: lipgloss.Color(hexColorErrorDark), // Bright red for dark backgrounds (Dracula) } // ColorWarning is used for warning messages and cautionary information. - ColorWarning = compat.AdaptiveColor{ + ColorWarning = adaptiveColor{ Light: lipgloss.Color(hexColorWarningLight), // Darker orange for light backgrounds Dark: lipgloss.Color(hexColorWarningDark), // Bright orange for dark backgrounds (Dracula) } // ColorSuccess is used for success messages and confirmations. - ColorSuccess = compat.AdaptiveColor{ + ColorSuccess = adaptiveColor{ Light: lipgloss.Color(hexColorSuccessLight), // Darker green for light backgrounds Dark: lipgloss.Color(hexColorSuccessDark), // Bright green for dark backgrounds (Dracula) } // ColorInfo is used for informational messages - ColorInfo = compat.AdaptiveColor{ + ColorInfo = adaptiveColor{ Light: lipgloss.Color(hexColorInfoLight), // Darker cyan/blue for light backgrounds Dark: lipgloss.Color(hexColorInfoDark), // Bright cyan for dark backgrounds (Dracula) } // ColorPurple is used for file paths, commands, and highlights - ColorPurple = compat.AdaptiveColor{ + ColorPurple = adaptiveColor{ Light: lipgloss.Color(hexColorPurpleLight), // Darker purple for light backgrounds Dark: lipgloss.Color(hexColorPurpleDark), // Bright purple for dark backgrounds (Dracula) } // ColorYellow is used for progress messages and attention-grabbing content - ColorYellow = compat.AdaptiveColor{ + ColorYellow = adaptiveColor{ Light: lipgloss.Color(hexColorYellowLight), // Darker yellow/gold for light backgrounds Dark: lipgloss.Color(hexColorYellowDark), // Bright yellow for dark backgrounds (Dracula) } // ColorComment is used for secondary/muted information like line numbers - ColorComment = compat.AdaptiveColor{ + ColorComment = adaptiveColor{ Light: lipgloss.Color(hexColorCommentLight), // Muted gray-blue for light backgrounds Dark: lipgloss.Color(hexColorCommentDark), // Muted purple-gray for dark backgrounds (Dracula) } // ColorForeground is used for primary text content - ColorForeground = compat.AdaptiveColor{ + ColorForeground = adaptiveColor{ Light: lipgloss.Color(hexColorForegroundLight), // Dark gray for light backgrounds Dark: lipgloss.Color(hexColorForegroundDark), // Light gray/white for dark backgrounds (Dracula) } // ColorBackground is used for highlighted backgrounds - ColorBackground = compat.AdaptiveColor{ + ColorBackground = adaptiveColor{ Light: lipgloss.Color(hexColorBackgroundLight), // Light gray for light backgrounds Dark: lipgloss.Color(hexColorBackgroundDark), // Dark purple/gray for dark backgrounds (Dracula) } // ColorBorder is used for table borders and dividers - ColorBorder = compat.AdaptiveColor{ + ColorBorder = adaptiveColor{ Light: lipgloss.Color(hexColorBorderLight), // Light gray border for light backgrounds Dark: lipgloss.Color(hexColorBorderDark), // Dark purple border for dark backgrounds (Dracula) } // ColorTableAltRow is used for alternating row backgrounds in tables (zebra striping) - ColorTableAltRow = compat.AdaptiveColor{ + ColorTableAltRow = adaptiveColor{ Light: lipgloss.Color(hexColorTableAltRowLight), // Subtle light gray for light backgrounds Dark: lipgloss.Color(hexColorTableAltRowDark), // Subtle darker background for dark backgrounds } diff --git a/pkg/styles/theme_test.go b/pkg/styles/theme_test.go index 583d0d09a36..ea8c42bcc2f 100644 --- a/pkg/styles/theme_test.go +++ b/pkg/styles/theme_test.go @@ -8,8 +8,7 @@ import ( "testing" lipgloss "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/compat" - "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/term" ) // TestAdaptiveColorsHaveBothVariants verifies that all adaptive colors @@ -229,11 +228,11 @@ func TestDarkColorsAreOriginalDracula(t *testing.T) { } } -// TestAdaptiveColorVarsUseHexConstants verifies that the exported AdaptiveColor vars +// TestAdaptiveColorVarsUseHexConstants verifies that the exported adaptive color vars // are backed by the expected hex constants (spot-check a few key colors). func TestAdaptiveColorVarsUseHexConstants(t *testing.T) { - // Verify that the compat.AdaptiveColor vars hold non-nil color values. - colors := map[string]compat.AdaptiveColor{ + // Verify that the adaptiveColor vars hold non-nil color values. + colors := map[string]adaptiveColor{ "ColorError": ColorError, "ColorWarning": ColorWarning, "ColorSuccess": ColorSuccess, @@ -259,79 +258,80 @@ func TestAdaptiveColorVarsUseHexConstants(t *testing.T) { } } -func TestConfigureLipglossCompatUsesStderr(t *testing.T) { - originalProfile := compat.Profile - originalHasDarkBackground := compat.HasDarkBackground +func TestAdaptiveColorRGBASwitching(t *testing.T) { + original := hasDarkBackground t.Cleanup(func() { - compat.Profile = originalProfile - compat.HasDarkBackground = originalHasDarkBackground + hasDarkBackground = original }) - configureLipglossCompat() + c := adaptiveColor{ + Light: lipgloss.Color("#123456"), + Dark: lipgloss.Color("#abcdef"), + } + + hasDarkBackground = true + r, g, b, a := c.RGBA() + wantR, wantG, wantB, wantA := c.Dark.RGBA() + if r != wantR || g != wantG || b != wantB || a != wantA { + t.Fatalf("dark background RGBA = (%d,%d,%d,%d), want (%d,%d,%d,%d)", r, g, b, a, wantR, wantG, wantB, wantA) + } + + hasDarkBackground = false + r, g, b, a = c.RGBA() + wantR, wantG, wantB, wantA = c.Light.RGBA() + if r != wantR || g != wantG || b != wantB || a != wantA { + t.Fatalf("light background RGBA = (%d,%d,%d,%d), want (%d,%d,%d,%d)", r, g, b, a, wantR, wantG, wantB, wantA) + } +} - expectedHasDarkBackground := lipgloss.HasDarkBackground(os.Stdin, os.Stderr) - expectedProfile := colorprofile.Detect(os.Stderr, os.Environ()) +func TestConfigureHasDarkBackgroundUsesStderr(t *testing.T) { + original := hasDarkBackground + t.Cleanup(func() { + hasDarkBackground = original + }) + + var gotReader term.File + var gotWriter term.File + configureHasDarkBackground(func(terminalInput term.File, terminalOutput term.File) bool { + gotReader = terminalInput + gotWriter = terminalOutput + return false + }) - if compat.HasDarkBackground != expectedHasDarkBackground { - t.Fatalf("compat.HasDarkBackground = %v, want %v", compat.HasDarkBackground, expectedHasDarkBackground) + if gotReader != os.Stdin { + t.Fatalf("reader = %v, want os.Stdin", gotReader) } - if compat.Profile != expectedProfile { - t.Fatalf("compat.Profile = %v, want %v", compat.Profile, expectedProfile) + if gotWriter != os.Stderr { + t.Fatalf("writer = %v, want os.Stderr", gotWriter) + } + if hasDarkBackground { + t.Fatalf("hasDarkBackground = %v, want false", hasDarkBackground) } } -func TestShouldConfigureLipglossCompat(t *testing.T) { +func TestShouldProbeTerminalBackground(t *testing.T) { tests := []struct { - name string - goos string - stderrMode os.FileMode - statErr error - want bool + name string + goos string + want bool }{ { - name: "windows character device", - goos: "windows", - stderrMode: os.ModeDevice | os.ModeCharDevice, - want: true, - }, - { - name: "windows redirected pipe", - goos: "windows", - stderrMode: os.ModeNamedPipe, - want: false, - }, - { - name: "windows zero mode stat success", - goos: "windows", - stderrMode: os.FileMode(0), - want: false, - }, - { - name: "windows stat error", - goos: "windows", - statErr: os.ErrPermission, - want: false, - }, - { - name: "windows ErrInvalid fallback", - goos: "windows", - statErr: os.ErrInvalid, - want: false, + name: "windows always skips startup probe", + goos: "windows", + want: false, }, { - name: "non-windows still configures", - goos: "linux", - stderrMode: os.ModeNamedPipe, - statErr: os.ErrPermission, - want: true, + name: "non-windows still probes", + goos: "linux", + want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := shouldConfigureLipglossCompat(tt.goos, tt.stderrMode, tt.statErr) + got := shouldProbeTerminalBackground(tt.goos) if got != tt.want { - t.Fatalf("shouldConfigureLipglossCompat(%q, %v, %v) = %v, want %v", tt.goos, tt.stderrMode, tt.statErr, got, tt.want) + t.Fatalf("shouldProbeTerminalBackground(%q) = %v, want %v", tt.goos, got, tt.want) } }) }