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 +}