From f45764c614cfdcf544959ffeca009348957a52e5 Mon Sep 17 00:00:00 2001 From: brunoantonieto Date: Fri, 27 Jun 2025 16:45:10 -0300 Subject: [PATCH 1/2] feat: enhance terminal color support and add color profile detection --- internal/diff/diff.go | 131 ++++++++++++-- internal/tui/styles/background.go | 101 ++++++++++- internal/tui/terminal/capability.go | 189 ++++++++++++++++++++ internal/tui/terminal/capability_test.go | 215 +++++++++++++++++++++++ 4 files changed, 620 insertions(+), 16 deletions(-) create mode 100644 internal/tui/terminal/capability.go create mode 100644 internal/tui/terminal/capability_test.go diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 8f5e669d..7c589f34 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/tui/terminal" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/sergi/go-diff/diffmatchpatch" ) @@ -542,7 +543,10 @@ func getColor(adaptiveColor lipgloss.AdaptiveColor) string { // highlightLine applies syntax highlighting to a single line func highlightLine(fileName string, line string, bg lipgloss.TerminalColor) string { var buf bytes.Buffer - err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg) + // Use terminal-appropriate formatter instead of hardcoding terminal16m + colorProfile := terminal.GetColorProfile() + formatter := colorProfile.ChromaFormatter() + err := SyntaxHighlight(&buf, line, fileName, formatter, bg) if err != nil { return line } @@ -614,9 +618,36 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, inSelection := false currentPos := 0 - // Get the appropriate color based on terminal background - bgColor := lipgloss.Color(getColor(highlightBg)) - fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background())) + // Get the appropriate color based on terminal background and capabilities + bgColorHex := getColor(highlightBg) + fgColorHex := getColor(theme.CurrentTheme().Background()) + + // Generate appropriate color sequences based on terminal capabilities + colorProfile := terminal.GetColorProfile() + var bgColorSeq, fgColorSeq string + + switch colorProfile { + case terminal.ProfileTrueColor: + // Use 24-bit true color + bgColor := lipgloss.Color(bgColorHex) + fgColor := lipgloss.Color(fgColorHex) + r, g, b, _ := fgColor.RGBA() + fgColorSeq = fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r>>8, g>>8, b>>8) + r, g, b, _ = bgColor.RGBA() + bgColorSeq = fmt.Sprintf("\x1b[48;2;%d;%d;%dm", r>>8, g>>8, b>>8) + case terminal.Profile256Color: + // Use 256-color palette - convert RGB to closest 256-color index + bgColorSeq = fmt.Sprintf("\x1b[48;5;%dm", rgbTo256Color(bgColorHex)) + fgColorSeq = fmt.Sprintf("\x1b[38;5;%dm", rgbTo256Color(fgColorHex)) + case terminal.Profile16Color: + // Use basic 16-color palette - map to closest ANSI color + bgColorSeq = fmt.Sprintf("\x1b[%dm", rgbTo16ColorBg(bgColorHex)) + fgColorSeq = fmt.Sprintf("\x1b[%dm", rgbTo16ColorFg(fgColorHex)) + default: + // No color support - use default highlighting + bgColorSeq = "\x1b[7m" // Reverse video + fgColorSeq = "" + } for i := 0; i < len(content); { // Check if we're at an ANSI sequence @@ -653,15 +684,17 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, currentStyle := ansiSequences[currentPos] // Apply foreground and background highlight - sb.WriteString("\x1b[38;2;") - r, g, b, _ := fgColor.RGBA() - sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) - sb.WriteString("\x1b[48;2;") - r, g, b, _ = bgColor.RGBA() - sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8)) + sb.WriteString(fgColorSeq) + sb.WriteString(bgColorSeq) sb.WriteString(char) - // Reset foreground and background - sb.WriteString("\x1b[39m") + + // Reset colors appropriately + if colorProfile >= terminal.Profile256Color { + sb.WriteString("\x1b[39m") // Reset foreground + sb.WriteString("\x1b[49m") // Reset background + } else { + sb.WriteString("\x1b[0m") // Full reset for simpler terminals + } // Reapply the original ANSI sequence sb.WriteString(currentStyle) @@ -677,6 +710,80 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType, return sb.String() } +// rgbTo256Color converts a hex color to the closest 256-color palette index +func rgbTo256Color(hexColor string) int { + // Remove # if present + hexColor = strings.TrimPrefix(hexColor, "#") + + // Parse RGB values + r, _ := strconv.ParseInt(hexColor[0:2], 16, 0) + g, _ := strconv.ParseInt(hexColor[2:4], 16, 0) + b, _ := strconv.ParseInt(hexColor[4:6], 16, 0) + + // Convert to 6x6x6 color cube (colors 16-231) + if r == g && g == b { + // Grayscale (colors 232-255) + gray := int(r) + if gray < 8 { + return 16 // Black + } else if gray > 248 { + return 231 // White + } else { + return 232 + (gray-8)/10 + } + } + + // Convert to 6-level values + r6 := int(r) * 5 / 255 + g6 := int(g) * 5 / 255 + b6 := int(b) * 5 / 255 + + return 16 + 36*r6 + 6*g6 + b6 +} + +// rgbTo16ColorFg converts a hex color to the closest 16-color foreground ANSI code +func rgbTo16ColorFg(hexColor string) int { + return rgbTo16ColorBase(hexColor, 30) // Foreground colors start at 30 +} + +// rgbTo16ColorBg converts a hex color to the closest 16-color background ANSI code +func rgbTo16ColorBg(hexColor string) int { + return rgbTo16ColorBase(hexColor, 40) // Background colors start at 40 +} + +// rgbTo16ColorBase converts a hex color to the closest 16-color ANSI code +func rgbTo16ColorBase(hexColor string, baseCode int) int { + // Remove # if present + hexColor = strings.TrimPrefix(hexColor, "#") + + // Parse RGB values + r, _ := strconv.ParseInt(hexColor[0:2], 16, 0) + g, _ := strconv.ParseInt(hexColor[2:4], 16, 0) + b, _ := strconv.ParseInt(hexColor[4:6], 16, 0) + + // Calculate brightness + brightness := (r*299 + g*587 + b*114) / 1000 + + // Map to closest ANSI color + if brightness < 64 { + return baseCode + 0 // Black + } else if r > g && r > b { + return baseCode + 1 // Red + } else if g > r && g > b { + return baseCode + 2 // Green + } else if (r + g) > b*2 { + return baseCode + 3 // Yellow + } else if b > r && b > g { + return baseCode + 4 // Blue + } else if (r + b) > g*2 { + return baseCode + 5 // Magenta + } else if (g + b) > r*2 { + return baseCode + 6 // Cyan + } else { + return baseCode + 7 // White + } +} + // renderLeftColumn formats the left side of a side-by-side diff func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string { t := theme.CurrentTheme() diff --git a/internal/tui/styles/background.go b/internal/tui/styles/background.go index 2fbb34ef..30c480a4 100644 --- a/internal/tui/styles/background.go +++ b/internal/tui/styles/background.go @@ -3,9 +3,11 @@ package styles import ( "fmt" "regexp" + "strconv" "strings" "github.com/charmbracelet/lipgloss" + "github.com/opencode-ai/opencode/internal/tui/terminal" ) var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") @@ -24,12 +26,103 @@ func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) { return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8) } +// rgbTo256Color converts a hex color to the closest 256-color palette index +func rgbTo256Color(hexColor string) int { + // Remove # if present + hexColor = strings.TrimPrefix(hexColor, "#") + + // Parse RGB values + r, _ := strconv.ParseInt(hexColor[0:2], 16, 0) + g, _ := strconv.ParseInt(hexColor[2:4], 16, 0) + b, _ := strconv.ParseInt(hexColor[4:6], 16, 0) + + // Convert to 6x6x6 color cube (colors 16-231) + if r == g && g == b { + // Grayscale (colors 232-255) + gray := int(r) + if gray < 8 { + return 16 // Black + } else if gray > 248 { + return 231 // White + } else { + return 232 + (gray-8)/10 + } + } + + // Convert to 6-level values + r6 := int(r) * 5 / 255 + g6 := int(g) * 5 / 255 + b6 := int(b) * 5 / 255 + + return 16 + 36*r6 + 6*g6 + b6 +} + +// rgbTo16ColorBg converts a hex color to the closest 16-color background ANSI code +func rgbTo16ColorBg(hexColor string) int { + return rgbTo16ColorBase(hexColor, 40) // Background colors start at 40 +} + +// rgbTo16ColorBase converts a hex color to the closest 16-color ANSI code +func rgbTo16ColorBase(hexColor string, baseCode int) int { + // Remove # if present + hexColor = strings.TrimPrefix(hexColor, "#") + + // Parse RGB values + r, _ := strconv.ParseInt(hexColor[0:2], 16, 0) + g, _ := strconv.ParseInt(hexColor[2:4], 16, 0) + b, _ := strconv.ParseInt(hexColor[4:6], 16, 0) + + // Calculate brightness + brightness := (r*299 + g*587 + b*114) / 1000 + + // Map to closest ANSI color + if brightness < 64 { + return baseCode + 0 // Black + } else if r > g && r > b { + return baseCode + 1 // Red + } else if g > r && g > b { + return baseCode + 2 // Green + } else if (r + g) > b*2 { + return baseCode + 3 // Yellow + } else if b > r && b > g { + return baseCode + 4 // Blue + } else if (r + b) > g*2 { + return baseCode + 5 // Magenta + } else if (g + b) > r*2 { + return baseCode + 6 // Cyan + } else { + return baseCode + 7 // White + } +} + // ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes -// in `input` with a single 24‑bit background (48;2;R;G;B). +// in `input` with a background color that's appropriate for the terminal's capabilities. func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string { - // Precompute our new-bg sequence once - r, g, b := getColorRGB(newBgColor) - newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b) + // Use terminal-appropriate color format + colorProfile := terminal.GetColorProfile() + var newBg string + + switch colorProfile { + case terminal.ProfileTrueColor: + // Use 24-bit true color + r, g, b := getColorRGB(newBgColor) + newBg = fmt.Sprintf("48;2;%d;%d;%d", r, g, b) + case terminal.Profile256Color: + // Convert to 256-color palette + r, g, b := getColorRGB(newBgColor) + hexColor := fmt.Sprintf("#%02x%02x%02x", r, g, b) + colorIndex := rgbTo256Color(hexColor) + newBg = fmt.Sprintf("48;5;%d", colorIndex) + case terminal.Profile16Color: + // Convert to 16-color palette + r, g, b := getColorRGB(newBgColor) + hexColor := fmt.Sprintf("#%02x%02x%02x", r, g, b) + colorCode := rgbTo16ColorBg(hexColor) + newBg = fmt.Sprintf("%d", colorCode) + default: + // No color support - use reverse video + newBg = "7" + } return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string { const ( diff --git a/internal/tui/terminal/capability.go b/internal/tui/terminal/capability.go new file mode 100644 index 00000000..78e7dbe3 --- /dev/null +++ b/internal/tui/terminal/capability.go @@ -0,0 +1,189 @@ +package terminal + +import ( + "os" + "strconv" + "strings" + + "github.com/muesli/termenv" +) + +// ColorProfile represents the color capabilities of the terminal +type ColorProfile int + +const ( + // ProfileNoColor represents terminals with no color support + ProfileNoColor ColorProfile = iota + // Profile16Color represents terminals with 16 color support + Profile16Color + // Profile256Color represents terminals with 256 color support + Profile256Color + // ProfileTrueColor represents terminals with true color (16 million colors) support + ProfileTrueColor +) + +// String returns a string representation of the color profile +func (p ColorProfile) String() string { + switch p { + case ProfileNoColor: + return "no-color" + case Profile16Color: + return "16-color" + case Profile256Color: + return "256-color" + case ProfileTrueColor: + return "true-color" + default: + return "unknown" + } +} + +// ChromaFormatter returns the appropriate chroma formatter for the color profile +func (p ColorProfile) ChromaFormatter() string { + switch p { + case ProfileNoColor: + return "terminal" + case Profile16Color: + return "terminal16" + case Profile256Color: + return "terminal256" + case ProfileTrueColor: + return "terminal16m" + default: + return "terminal256" // Safe fallback + } +} + +// DetectColorProfile detects the color capabilities of the current terminal +func DetectColorProfile() ColorProfile { + // Check if color is explicitly disabled + if os.Getenv("NO_COLOR") != "" { + return ProfileNoColor + } + + // First try manual detection for explicit settings + manual := detectColorProfileManual() + + // If manual detection found explicit color settings, use that + if manual == ProfileTrueColor || + (manual == Profile256Color && (os.Getenv("COLORTERM") != "" || strings.Contains(strings.ToLower(os.Getenv("TERM")), "256"))) || + (manual == Profile16Color && os.Getenv("COLORS") != "") || + (manual == Profile256Color && os.Getenv("COLORS") != "") || + (manual == ProfileTrueColor && os.Getenv("COLORS") != "") { + return manual + } + + // Use termenv to detect color profile as fallback + profile := termenv.EnvColorProfile() + + switch profile { + case termenv.Ascii: + // If termenv says no color but we have indicators of color support, use manual detection + term := strings.ToLower(os.Getenv("TERM")) + if os.Getenv("COLORTERM") != "" || + strings.Contains(term, "color") || + strings.Contains(term, "xterm") || + strings.Contains(term, "screen") || + os.Getenv("COLORS") != "" { + return manual + } + // For terminals like vt100 that don't have explicit color indicators, + // use the conservative fallback from manual detection instead of no color + if term == "dumb" { + return ProfileNoColor + } + return manual + case termenv.ANSI: + return Profile16Color + case termenv.ANSI256: + return Profile256Color + case termenv.TrueColor: + return ProfileTrueColor + default: + // Use manual detection as fallback + return manual + } +} + +// detectColorProfileManual performs additional manual detection +func detectColorProfileManual() ColorProfile { + // Check COLORTERM environment variable + colorterm := strings.ToLower(os.Getenv("COLORTERM")) + if colorterm == "truecolor" || colorterm == "24bit" { + return ProfileTrueColor + } + + // Check TERM environment variable + term := strings.ToLower(os.Getenv("TERM")) + + // True color support + if strings.Contains(term, "truecolor") || + strings.Contains(term, "24bit") || + strings.Contains(term, "direct") { + return ProfileTrueColor + } + + // 256 color support + if strings.Contains(term, "256") || + strings.Contains(term, "256color") { + return Profile256Color + } + + // Check for specific terminal types known to support different color levels + switch { + case strings.Contains(term, "xterm"): + // Modern xterm variants usually support 256 colors + return Profile256Color + case strings.Contains(term, "screen"): + // Screen/tmux usually supports 256 colors if configured properly + return Profile256Color + case term == "dumb": + return ProfileNoColor + case strings.Contains(term, "color"): + // If "color" is in the term name, assume at least 16 colors + return Profile16Color + } + + // Check COLORS environment variable + if colorsStr := os.Getenv("COLORS"); colorsStr != "" { + if colors, err := strconv.Atoi(colorsStr); err == nil { + switch { + case colors >= 16777216: // 24-bit + return ProfileTrueColor + case colors >= 256: + return Profile256Color + case colors >= 16: + return Profile16Color + case colors > 0: + return Profile16Color + default: + return ProfileNoColor + } + } + } + + // Conservative fallback - assume 256 colors for most modern terminals + // This is safer than assuming true color which might not work + return Profile256Color +} + +// GetColorProfile returns the detected color profile for the current terminal +func GetColorProfile() ColorProfile { + return DetectColorProfile() +} + +// HasTrueColorSupport returns true if the terminal supports true color +func HasTrueColorSupport() bool { + return GetColorProfile() == ProfileTrueColor +} + +// Has256ColorSupport returns true if the terminal supports at least 256 colors +func Has256ColorSupport() bool { + profile := GetColorProfile() + return profile == Profile256Color || profile == ProfileTrueColor +} + +// HasColorSupport returns true if the terminal supports any color +func HasColorSupport() bool { + return GetColorProfile() != ProfileNoColor +} diff --git a/internal/tui/terminal/capability_test.go b/internal/tui/terminal/capability_test.go new file mode 100644 index 00000000..21831f65 --- /dev/null +++ b/internal/tui/terminal/capability_test.go @@ -0,0 +1,215 @@ +package terminal + +import ( + "os" + "testing" +) + +func TestDetectColorProfile(t *testing.T) { + // Save original environment + originalNoColor := os.Getenv("NO_COLOR") + originalColorterm := os.Getenv("COLORTERM") + originalTerm := os.Getenv("TERM") + originalColors := os.Getenv("COLORS") + + // Restore environment after test + defer func() { + setOrUnset("NO_COLOR", originalNoColor) + setOrUnset("COLORTERM", originalColorterm) + setOrUnset("TERM", originalTerm) + setOrUnset("COLORS", originalColors) + }() + + tests := []struct { + name string + noColor string + colorterm string + term string + colors string + expected ColorProfile + }{ + { + name: "NO_COLOR set", + noColor: "1", + expected: ProfileNoColor, + }, + { + name: "COLORTERM=truecolor", + colorterm: "truecolor", + term: "xterm-256color", // Add a valid term to avoid termenv fallback + expected: ProfileTrueColor, + }, + { + name: "COLORTERM=24bit", + colorterm: "24bit", + term: "xterm-256color", + expected: ProfileTrueColor, + }, + { + name: "TERM=xterm-256color", + term: "xterm-256color", + expected: Profile256Color, + }, + { + name: "TERM=screen-256color", + term: "screen-256color", + expected: Profile256Color, + }, + { + name: "TERM=xterm", + term: "xterm", + expected: Profile256Color, + }, + { + name: "TERM=dumb", + term: "dumb", + expected: ProfileNoColor, + }, + { + name: "TERM=vt100", + term: "vt100", + expected: Profile256Color, // Conservative fallback + }, + { + name: "COLORS=16777216", + term: "unknown", + colors: "16777216", + expected: ProfileTrueColor, + }, + { + name: "COLORS=256", + term: "unknown", + colors: "256", + expected: Profile256Color, + }, + { + name: "COLORS=16", + term: "unknown", + colors: "16", + expected: Profile16Color, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment + os.Unsetenv("NO_COLOR") + os.Unsetenv("COLORTERM") + os.Unsetenv("TERM") + os.Unsetenv("COLORS") + + // Set test environment + if tt.noColor != "" { + os.Setenv("NO_COLOR", tt.noColor) + } + if tt.colorterm != "" { + os.Setenv("COLORTERM", tt.colorterm) + } + if tt.term != "" { + os.Setenv("TERM", tt.term) + } + if tt.colors != "" { + os.Setenv("COLORS", tt.colors) + } + + result := DetectColorProfile() + if result != tt.expected { + t.Errorf("DetectColorProfile() = %v (%s), expected %v (%s)", + result, result.String(), tt.expected, tt.expected.String()) + } + }) + } +} + +func TestColorProfileChromaFormatter(t *testing.T) { + tests := []struct { + profile ColorProfile + expected string + }{ + {ProfileNoColor, "terminal"}, + {Profile16Color, "terminal16"}, + {Profile256Color, "terminal256"}, + {ProfileTrueColor, "terminal16m"}, + } + + for _, tt := range tests { + t.Run(tt.profile.String(), func(t *testing.T) { + result := tt.profile.ChromaFormatter() + if result != tt.expected { + t.Errorf("ChromaFormatter() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestHasColorSupport(t *testing.T) { + // Save original environment + originalNoColor := os.Getenv("NO_COLOR") + originalTerm := os.Getenv("TERM") + + // Restore environment after test + defer func() { + setOrUnset("NO_COLOR", originalNoColor) + setOrUnset("TERM", originalTerm) + }() + + // Test NO_COLOR disables colors + os.Setenv("NO_COLOR", "1") + os.Unsetenv("TERM") + if HasColorSupport() { + t.Error("HasColorSupport() should return false when NO_COLOR is set") + } + + // Test color terminal + os.Unsetenv("NO_COLOR") + os.Setenv("TERM", "xterm-256color") + if !HasColorSupport() { + t.Error("HasColorSupport() should return true for color terminal") + } +} + +func TestTerminalCapabilities(t *testing.T) { + // Save original environment + originalColorterm := os.Getenv("COLORTERM") + originalTerm := os.Getenv("TERM") + originalNoColor := os.Getenv("NO_COLOR") + + // Restore environment after test + defer func() { + setOrUnset("COLORTERM", originalColorterm) + setOrUnset("TERM", originalTerm) + setOrUnset("NO_COLOR", originalNoColor) + }() + + // Clear NO_COLOR to ensure we're testing color capabilities + os.Unsetenv("NO_COLOR") + + // Test true color support + os.Setenv("COLORTERM", "truecolor") + os.Setenv("TERM", "xterm-256color") + if !HasTrueColorSupport() { + t.Error("HasTrueColorSupport() should return true for truecolor terminal") + } + + // Test 256 color support + os.Unsetenv("COLORTERM") + os.Setenv("TERM", "xterm-256color") + if !Has256ColorSupport() { + t.Error("Has256ColorSupport() should return true for 256-color terminal") + } + + // Test basic terminal + os.Setenv("TERM", "dumb") + if HasTrueColorSupport() || Has256ColorSupport() { + t.Error("Dumb terminal should not support advanced colors") + } +} + +// Helper function to set environment variable or unset if empty +func setOrUnset(key, value string) { + if value == "" { + os.Unsetenv(key) + } else { + os.Setenv(key, value) + } +} From dd7c104dfbc842bbb2d661ff9c089c3f2de4e13c Mon Sep 17 00:00:00 2001 From: brunoantonieto Date: Fri, 27 Jun 2025 17:04:21 -0300 Subject: [PATCH 2/2] chore: remove obsolete terminal capability tests --- internal/tui/terminal/capability_test.go | 215 ----------------------- 1 file changed, 215 deletions(-) delete mode 100644 internal/tui/terminal/capability_test.go diff --git a/internal/tui/terminal/capability_test.go b/internal/tui/terminal/capability_test.go deleted file mode 100644 index 21831f65..00000000 --- a/internal/tui/terminal/capability_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package terminal - -import ( - "os" - "testing" -) - -func TestDetectColorProfile(t *testing.T) { - // Save original environment - originalNoColor := os.Getenv("NO_COLOR") - originalColorterm := os.Getenv("COLORTERM") - originalTerm := os.Getenv("TERM") - originalColors := os.Getenv("COLORS") - - // Restore environment after test - defer func() { - setOrUnset("NO_COLOR", originalNoColor) - setOrUnset("COLORTERM", originalColorterm) - setOrUnset("TERM", originalTerm) - setOrUnset("COLORS", originalColors) - }() - - tests := []struct { - name string - noColor string - colorterm string - term string - colors string - expected ColorProfile - }{ - { - name: "NO_COLOR set", - noColor: "1", - expected: ProfileNoColor, - }, - { - name: "COLORTERM=truecolor", - colorterm: "truecolor", - term: "xterm-256color", // Add a valid term to avoid termenv fallback - expected: ProfileTrueColor, - }, - { - name: "COLORTERM=24bit", - colorterm: "24bit", - term: "xterm-256color", - expected: ProfileTrueColor, - }, - { - name: "TERM=xterm-256color", - term: "xterm-256color", - expected: Profile256Color, - }, - { - name: "TERM=screen-256color", - term: "screen-256color", - expected: Profile256Color, - }, - { - name: "TERM=xterm", - term: "xterm", - expected: Profile256Color, - }, - { - name: "TERM=dumb", - term: "dumb", - expected: ProfileNoColor, - }, - { - name: "TERM=vt100", - term: "vt100", - expected: Profile256Color, // Conservative fallback - }, - { - name: "COLORS=16777216", - term: "unknown", - colors: "16777216", - expected: ProfileTrueColor, - }, - { - name: "COLORS=256", - term: "unknown", - colors: "256", - expected: Profile256Color, - }, - { - name: "COLORS=16", - term: "unknown", - colors: "16", - expected: Profile16Color, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Clear environment - os.Unsetenv("NO_COLOR") - os.Unsetenv("COLORTERM") - os.Unsetenv("TERM") - os.Unsetenv("COLORS") - - // Set test environment - if tt.noColor != "" { - os.Setenv("NO_COLOR", tt.noColor) - } - if tt.colorterm != "" { - os.Setenv("COLORTERM", tt.colorterm) - } - if tt.term != "" { - os.Setenv("TERM", tt.term) - } - if tt.colors != "" { - os.Setenv("COLORS", tt.colors) - } - - result := DetectColorProfile() - if result != tt.expected { - t.Errorf("DetectColorProfile() = %v (%s), expected %v (%s)", - result, result.String(), tt.expected, tt.expected.String()) - } - }) - } -} - -func TestColorProfileChromaFormatter(t *testing.T) { - tests := []struct { - profile ColorProfile - expected string - }{ - {ProfileNoColor, "terminal"}, - {Profile16Color, "terminal16"}, - {Profile256Color, "terminal256"}, - {ProfileTrueColor, "terminal16m"}, - } - - for _, tt := range tests { - t.Run(tt.profile.String(), func(t *testing.T) { - result := tt.profile.ChromaFormatter() - if result != tt.expected { - t.Errorf("ChromaFormatter() = %v, expected %v", result, tt.expected) - } - }) - } -} - -func TestHasColorSupport(t *testing.T) { - // Save original environment - originalNoColor := os.Getenv("NO_COLOR") - originalTerm := os.Getenv("TERM") - - // Restore environment after test - defer func() { - setOrUnset("NO_COLOR", originalNoColor) - setOrUnset("TERM", originalTerm) - }() - - // Test NO_COLOR disables colors - os.Setenv("NO_COLOR", "1") - os.Unsetenv("TERM") - if HasColorSupport() { - t.Error("HasColorSupport() should return false when NO_COLOR is set") - } - - // Test color terminal - os.Unsetenv("NO_COLOR") - os.Setenv("TERM", "xterm-256color") - if !HasColorSupport() { - t.Error("HasColorSupport() should return true for color terminal") - } -} - -func TestTerminalCapabilities(t *testing.T) { - // Save original environment - originalColorterm := os.Getenv("COLORTERM") - originalTerm := os.Getenv("TERM") - originalNoColor := os.Getenv("NO_COLOR") - - // Restore environment after test - defer func() { - setOrUnset("COLORTERM", originalColorterm) - setOrUnset("TERM", originalTerm) - setOrUnset("NO_COLOR", originalNoColor) - }() - - // Clear NO_COLOR to ensure we're testing color capabilities - os.Unsetenv("NO_COLOR") - - // Test true color support - os.Setenv("COLORTERM", "truecolor") - os.Setenv("TERM", "xterm-256color") - if !HasTrueColorSupport() { - t.Error("HasTrueColorSupport() should return true for truecolor terminal") - } - - // Test 256 color support - os.Unsetenv("COLORTERM") - os.Setenv("TERM", "xterm-256color") - if !Has256ColorSupport() { - t.Error("Has256ColorSupport() should return true for 256-color terminal") - } - - // Test basic terminal - os.Setenv("TERM", "dumb") - if HasTrueColorSupport() || Has256ColorSupport() { - t.Error("Dumb terminal should not support advanced colors") - } -} - -// Helper function to set environment variable or unset if empty -func setOrUnset(key, value string) { - if value == "" { - os.Unsetenv(key) - } else { - os.Setenv(key, value) - } -}