Skip to content

feat: enhance terminal color support and add color profile detection #269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
131 changes: 119 additions & 12 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
101 changes: 97 additions & 4 deletions internal/tui/styles/background.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 (
Expand Down
Loading