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
20 changes: 12 additions & 8 deletions actions/setup/sh/install_copilot_cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/styles/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
93 changes: 52 additions & 41 deletions pkg/styles/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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) {
Comment thread
pelikhan marked this conversation as resolved.
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
Comment thread
pelikhan marked this conversation as resolved.
}
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)
}
}

Expand Down Expand Up @@ -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
}
Expand Down
116 changes: 58 additions & 58 deletions pkg/styles/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
})
}
Expand Down
Loading