summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-04-30 11:05:59 -0500
committeradamdottv <[email protected]>2025-04-30 11:05:59 -0500
commit91ae9b33d37df7a53bda958d787268ef0f917ffd (patch)
tree27d1ceb87f30c666e20ebf6c7baeb8e1bd34693a
parenta42175c067dd6b3e594d1e8de4f39a441bd9603b (diff)
downloadopencode-91ae9b33d37df7a53bda958d787268ef0f917ffd.tar.gz
opencode-91ae9b33d37df7a53bda958d787268ef0f917ffd.zip
feat: custom themes
-rw-r--r--README.md73
-rw-r--r--cmd/schema/main.go28
-rw-r--r--internal/config/config.go9
-rw-r--r--internal/tui/styles/icons.go1
-rw-r--r--internal/tui/theme/manager.go101
-rw-r--r--internal/tui/theme/theme.go51
-rw-r--r--opencode-schema.json209
7 files changed, 374 insertions, 98 deletions
diff --git a/README.md b/README.md
index e94c6cb3a..407e913e2 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ OpenCode looks for configuration in the following locations:
You can configure OpenCode using environment variables:
| Environment Variable | Purpose |
-|----------------------------|--------------------------------------------------------|
+| -------------------------- | ------------------------------------------------------ |
| `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini models |
@@ -79,7 +79,6 @@ You can configure OpenCode using environment variables:
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
-
### Configuration File Structure
```json
@@ -303,6 +302,76 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
| `sourcegraph` | Search code across public repositories | `query` (required), `count` (optional), `context_window` (optional), `timeout` (optional) |
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
+## Theming
+
+OpenCode supports multiple themes for customizing the appearance of the terminal interface.
+
+### Available Themes
+
+The following predefined themes are available:
+
+- `opencode` (default)
+- `catppuccin`
+- `dracula`
+- `flexoki`
+- `gruvbox`
+- `monokai`
+- `onedark`
+- `tokyonight`
+- `tron`
+- `custom` (user-defined)
+
+### Setting a Theme
+
+You can set a theme in your `.opencode.json` configuration file:
+
+```json
+{
+ "tui": {
+ "theme": "monokai"
+ }
+}
+```
+
+### Custom Themes
+
+You can define your own custom theme by setting the `theme` to `"custom"` and providing color definitions in the `customTheme` map:
+
+```json
+{
+ "tui": {
+ "theme": "custom",
+ "customTheme": {
+ "primary": "#ffcc00",
+ "secondary": "#00ccff",
+ "accent": { "dark": "#aa00ff", "light": "#ddccff" },
+ "error": "#ff0000"
+ }
+ }
+}
+```
+
+#### Color Definition Formats
+
+Custom theme colors support two formats:
+
+1. **Simple Hex String**: A single hex color string (e.g., `"#aabbcc"`) that will be used for both light and dark terminal backgrounds.
+
+2. **Adaptive Object**: An object with `dark` and `light` keys, each holding a hex color string. This allows for adaptive colors based on the terminal's background.
+
+#### Available Color Keys
+
+You can define any of the following color keys in your `customTheme`:
+
+- Base colors: `primary`, `secondary`, `accent`
+- Status colors: `error`, `warning`, `success`, `info`
+- Text colors: `text`, `textMuted`, `textEmphasized`
+- Background colors: `background`, `backgroundSecondary`, `backgroundDarker`
+- Border colors: `borderNormal`, `borderFocused`, `borderDim`
+- Diff view colors: `diffAdded`, `diffRemoved`, `diffContext`, etc.
+
+You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
+
## Architecture
OpenCode is built with a modular architecture:
diff --git a/cmd/schema/main.go b/cmd/schema/main.go
index adc2b4626..6340cb32f 100644
--- a/cmd/schema/main.go
+++ b/cmd/schema/main.go
@@ -116,6 +116,34 @@ func generateSchema() map[string]any {
"onedark",
"tokyonight",
"tron",
+ "custom",
+ },
+ },
+ "customTheme": map[string]any{
+ "type": "object",
+ "description": "Custom theme color definitions",
+ "additionalProperties": map[string]any{
+ "oneOf": []map[string]any{
+ {
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ },
+ {
+ "type": "object",
+ "properties": map[string]any{
+ "dark": map[string]any{
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ },
+ "light": map[string]any{
+ "type": "string",
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ },
+ },
+ "required": []string{"dark", "light"},
+ "additionalProperties": false,
+ },
+ },
},
},
},
diff --git a/internal/config/config.go b/internal/config/config.go
index 8d6ad39d8..88f3a1838 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -68,7 +68,8 @@ type LSPConfig struct {
// TUIConfig defines the configuration for the Terminal User Interface.
type TUIConfig struct {
- Theme string `json:"theme,omitempty"`
+ Theme string `json:"theme,omitempty"`
+ CustomTheme map[string]any `json:"customTheme,omitempty"`
}
// Config is the main configuration structure for the application.
@@ -747,16 +748,16 @@ func UpdateTheme(themeName string) error {
}
// Parse the JSON
- var configMap map[string]interface{}
+ var configMap map[string]any
if err := json.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Update just the theme value
- tuiConfig, ok := configMap["tui"].(map[string]interface{})
+ tuiConfig, ok := configMap["tui"].(map[string]any)
if !ok {
// TUI config doesn't exist yet, create it
- configMap["tui"] = map[string]interface{}{"theme": themeName}
+ configMap["tui"] = map[string]any{"theme": themeName}
} else {
// Update existing TUI config
tuiConfig["theme"] = themeName
diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go
index 314e8d85b..985465cc8 100644
--- a/internal/tui/styles/icons.go
+++ b/internal/tui/styles/icons.go
@@ -11,4 +11,3 @@ const (
SpinnerIcon string = "..."
LoadingIcon string = "⟳"
)
-
diff --git a/internal/tui/theme/manager.go b/internal/tui/theme/manager.go
index a81ba45c1..7bb887ffa 100644
--- a/internal/tui/theme/manager.go
+++ b/internal/tui/theme/manager.go
@@ -25,6 +25,9 @@ var globalManager = &Manager{
currentName: "",
}
+// Default theme instance for custom theme defaulting
+var defaultThemeColors = NewOpenCodeTheme()
+
// RegisterTheme adds a new theme to the registry.
// If this is the first theme registered, it becomes the default.
func RegisterTheme(name string, theme Theme) {
@@ -46,7 +49,22 @@ func SetTheme(name string) error {
defer globalManager.mu.Unlock()
delete(styles.Registry, "charm")
- if _, exists := globalManager.themes[name]; !exists {
+
+ // Handle custom theme
+ if name == "custom" {
+ cfg := config.Get()
+ if cfg == nil || cfg.TUI.CustomTheme == nil || len(cfg.TUI.CustomTheme) == 0 {
+ return fmt.Errorf("custom theme selected but no custom theme colors defined in config")
+ }
+
+ customTheme, err := LoadCustomTheme(cfg.TUI.CustomTheme)
+ if err != nil {
+ return fmt.Errorf("failed to load custom theme: %w", err)
+ }
+
+ // Register the custom theme
+ globalManager.themes["custom"] = customTheme
+ } else if _, exists := globalManager.themes[name]; !exists {
return fmt.Errorf("theme '%s' not found", name)
}
@@ -111,6 +129,87 @@ func GetTheme(name string) Theme {
return globalManager.themes[name]
}
+// LoadCustomTheme creates a new theme instance based on the custom theme colors
+// defined in the configuration. It uses the default OpenCode theme as a base
+// and overrides colors that are specified in the customTheme map.
+func LoadCustomTheme(customTheme map[string]any) (Theme, error) {
+ // Create a new theme based on the default OpenCode theme
+ theme := NewOpenCodeTheme()
+
+ // Process each color in the custom theme map
+ for key, value := range customTheme {
+ adaptiveColor, err := ParseAdaptiveColor(value)
+ if err != nil {
+ logging.Warn("Invalid color definition in custom theme", "key", key, "error", err)
+ continue // Skip this color but continue processing others
+ }
+
+ // Set the color in the theme based on the key
+ switch strings.ToLower(key) {
+ case "primary":
+ theme.PrimaryColor = adaptiveColor
+ case "secondary":
+ theme.SecondaryColor = adaptiveColor
+ case "accent":
+ theme.AccentColor = adaptiveColor
+ case "error":
+ theme.ErrorColor = adaptiveColor
+ case "warning":
+ theme.WarningColor = adaptiveColor
+ case "success":
+ theme.SuccessColor = adaptiveColor
+ case "info":
+ theme.InfoColor = adaptiveColor
+ case "text":
+ theme.TextColor = adaptiveColor
+ case "textmuted":
+ theme.TextMutedColor = adaptiveColor
+ case "textemphasized":
+ theme.TextEmphasizedColor = adaptiveColor
+ case "background":
+ theme.BackgroundColor = adaptiveColor
+ case "backgroundsecondary":
+ theme.BackgroundSecondaryColor = adaptiveColor
+ case "backgrounddarker":
+ theme.BackgroundDarkerColor = adaptiveColor
+ case "bordernormal":
+ theme.BorderNormalColor = adaptiveColor
+ case "borderfocused":
+ theme.BorderFocusedColor = adaptiveColor
+ case "borderdim":
+ theme.BorderDimColor = adaptiveColor
+ case "diffadded":
+ theme.DiffAddedColor = adaptiveColor
+ case "diffremoved":
+ theme.DiffRemovedColor = adaptiveColor
+ case "diffcontext":
+ theme.DiffContextColor = adaptiveColor
+ case "diffhunkheader":
+ theme.DiffHunkHeaderColor = adaptiveColor
+ case "diffhighlightadded":
+ theme.DiffHighlightAddedColor = adaptiveColor
+ case "diffhighlightremoved":
+ theme.DiffHighlightRemovedColor = adaptiveColor
+ case "diffaddedbg":
+ theme.DiffAddedBgColor = adaptiveColor
+ case "diffremovedbg":
+ theme.DiffRemovedBgColor = adaptiveColor
+ case "diffcontextbg":
+ theme.DiffContextBgColor = adaptiveColor
+ case "difflinenumber":
+ theme.DiffLineNumberColor = adaptiveColor
+ case "diffaddedlinenumberbg":
+ theme.DiffAddedLineNumberBgColor = adaptiveColor
+ case "diffremovedlinenumberbg":
+ theme.DiffRemovedLineNumberBgColor = adaptiveColor
+ default:
+ logging.Warn("Unknown color key in custom theme", "key", key)
+ }
+ }
+
+ return theme, nil
+}
+
// updateConfigTheme updates the theme setting in the configuration file
func updateConfigTheme(themeName string) error {
// Use the config package to update the theme
diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go
index 4ee14a07f..d0c64eb43 100644
--- a/internal/tui/theme/theme.go
+++ b/internal/tui/theme/theme.go
@@ -1,6 +1,9 @@
package theme
import (
+ "fmt"
+ "regexp"
+
"github.com/charmbracelet/lipgloss"
)
@@ -205,4 +208,50 @@ func (t *BaseTheme) SyntaxString() lipgloss.AdaptiveColor { return t.SyntaxStrin
func (t *BaseTheme) SyntaxNumber() lipgloss.AdaptiveColor { return t.SyntaxNumberColor }
func (t *BaseTheme) SyntaxType() lipgloss.AdaptiveColor { return t.SyntaxTypeColor }
func (t *BaseTheme) SyntaxOperator() lipgloss.AdaptiveColor { return t.SyntaxOperatorColor }
-func (t *BaseTheme) SyntaxPunctuation() lipgloss.AdaptiveColor { return t.SyntaxPunctuationColor } \ No newline at end of file
+func (t *BaseTheme) SyntaxPunctuation() lipgloss.AdaptiveColor { return t.SyntaxPunctuationColor }
+
+// ParseAdaptiveColor parses a color value from the config file into a lipgloss.AdaptiveColor.
+// It accepts either a string (hex color) or a map with "dark" and "light" keys.
+func ParseAdaptiveColor(value any) (lipgloss.AdaptiveColor, error) {
+ // Regular expression to validate hex color format
+ hexColorRegex := regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
+
+ // Case 1: String value (same color for both dark and light modes)
+ if hexColor, ok := value.(string); ok {
+ if !hexColorRegex.MatchString(hexColor) {
+ return lipgloss.AdaptiveColor{}, fmt.Errorf("invalid hex color format: %s", hexColor)
+ }
+ return lipgloss.AdaptiveColor{
+ Dark: hexColor,
+ Light: hexColor,
+ }, nil
+ }
+
+ // Case 2: Map with dark and light keys
+ if colorMap, ok := value.(map[string]any); ok {
+ darkVal, darkOk := colorMap["dark"]
+ lightVal, lightOk := colorMap["light"]
+
+ if !darkOk || !lightOk {
+ return lipgloss.AdaptiveColor{}, fmt.Errorf("color map must contain both 'dark' and 'light' keys")
+ }
+
+ darkHex, darkIsString := darkVal.(string)
+ lightHex, lightIsString := lightVal.(string)
+
+ if !darkIsString || !lightIsString {
+ return lipgloss.AdaptiveColor{}, fmt.Errorf("color values must be strings")
+ }
+
+ if !hexColorRegex.MatchString(darkHex) || !hexColorRegex.MatchString(lightHex) {
+ return lipgloss.AdaptiveColor{}, fmt.Errorf("invalid hex color format")
+ }
+
+ return lipgloss.AdaptiveColor{
+ Dark: darkHex,
+ Light: lightHex,
+ }, nil
+ }
+
+ return lipgloss.AdaptiveColor{}, fmt.Errorf("color must be either a hex string or an object with dark/light keys")
+} \ No newline at end of file
diff --git a/opencode-schema.json b/opencode-schema.json
index 7c7513d11..e9665eab5 100644
--- a/opencode-schema.json
+++ b/opencode-schema.json
@@ -12,63 +12,63 @@
"model": {
"description": "Model ID for the agent",
"enum": [
- "gpt-4o-mini",
- "o1-pro",
- "azure.gpt-4o-mini",
- "openrouter.gpt-4.1-mini",
- "openrouter.o1-mini",
- "bedrock.claude-3.7-sonnet",
- "meta-llama/llama-4-scout-17b-16e-instruct",
- "openrouter.gpt-4o-mini",
- "gemini-2.0-flash",
- "deepseek-r1-distill-llama-70b",
- "openrouter.claude-3.7-sonnet",
- "openrouter.gpt-4.5-preview",
- "azure.o3-mini",
- "openrouter.claude-3.5-haiku",
"azure.o1-mini",
- "openrouter.o1",
- "openrouter.gemini-2.5",
+ "openrouter.gemini-2.5-flash",
+ "claude-3-haiku",
+ "o1-mini",
+ "qwen-qwq",
"llama-3.3-70b-versatile",
- "gpt-4.5-preview",
- "openrouter.claude-3-opus",
"openrouter.claude-3.5-sonnet",
+ "o3-mini",
"o4-mini",
- "gemini-2.0-flash-lite",
- "azure.gpt-4.5-preview",
+ "gpt-4.1",
+ "azure.o3-mini",
+ "openrouter.gpt-4.1-nano",
"openrouter.gpt-4o",
- "o1",
+ "gemini-2.5",
"azure.gpt-4o",
- "openrouter.gpt-4.1-nano",
+ "azure.gpt-4o-mini",
+ "claude-3.7-sonnet",
+ "azure.gpt-4.1-nano",
+ "openrouter.o1",
+ "openrouter.claude-3-haiku",
+ "bedrock.claude-3.7-sonnet",
+ "gemini-2.5-flash",
+ "azure.o3",
+ "openrouter.gemini-2.5",
+ "openrouter.o3",
+ "openrouter.o3-mini",
+ "openrouter.gpt-4.1-mini",
+ "openrouter.gpt-4.5-preview",
+ "openrouter.gpt-4o-mini",
+ "gpt-4.1-mini",
+ "meta-llama/llama-4-scout-17b-16e-instruct",
+ "openrouter.o1-mini",
+ "gpt-4.5-preview",
"o3",
- "gpt-4.1",
- "azure.o1",
- "claude-3-haiku",
+ "openrouter.claude-3.5-haiku",
"claude-3-opus",
- "gpt-4.1-mini",
- "openrouter.o4-mini",
- "openrouter.gemini-2.5-flash",
- "claude-3.5-haiku",
- "o3-mini",
- "azure.o3",
- "gpt-4o",
- "azure.gpt-4.1",
- "openrouter.claude-3-haiku",
- "gpt-4.1-nano",
- "azure.gpt-4.1-nano",
- "claude-3.7-sonnet",
- "gemini-2.5",
+ "o1-pro",
+ "gemini-2.0-flash",
"azure.o4-mini",
- "o1-mini",
- "qwen-qwq",
+ "openrouter.o4-mini",
+ "claude-3.5-sonnet",
"meta-llama/llama-4-maverick-17b-128e-instruct",
+ "azure.o1",
"openrouter.gpt-4.1",
"openrouter.o1-pro",
- "openrouter.o3",
- "claude-3.5-sonnet",
- "gemini-2.5-flash",
+ "gpt-4.1-nano",
+ "azure.gpt-4.5-preview",
+ "openrouter.claude-3-opus",
+ "gpt-4o-mini",
+ "o1",
+ "deepseek-r1-distill-llama-70b",
+ "azure.gpt-4.1",
+ "gpt-4o",
"azure.gpt-4.1-mini",
- "openrouter.o3-mini"
+ "openrouter.claude-3.7-sonnet",
+ "claude-3.5-haiku",
+ "gemini-2.0-flash-lite"
],
"type": "string"
},
@@ -102,63 +102,63 @@
"model": {
"description": "Model ID for the agent",
"enum": [
- "gpt-4o-mini",
- "o1-pro",
- "azure.gpt-4o-mini",
- "openrouter.gpt-4.1-mini",
- "openrouter.o1-mini",
- "bedrock.claude-3.7-sonnet",
- "meta-llama/llama-4-scout-17b-16e-instruct",
- "openrouter.gpt-4o-mini",
- "gemini-2.0-flash",
- "deepseek-r1-distill-llama-70b",
- "openrouter.claude-3.7-sonnet",
- "openrouter.gpt-4.5-preview",
- "azure.o3-mini",
- "openrouter.claude-3.5-haiku",
"azure.o1-mini",
- "openrouter.o1",
- "openrouter.gemini-2.5",
+ "openrouter.gemini-2.5-flash",
+ "claude-3-haiku",
+ "o1-mini",
+ "qwen-qwq",
"llama-3.3-70b-versatile",
- "gpt-4.5-preview",
- "openrouter.claude-3-opus",
"openrouter.claude-3.5-sonnet",
+ "o3-mini",
"o4-mini",
- "gemini-2.0-flash-lite",
- "azure.gpt-4.5-preview",
+ "gpt-4.1",
+ "azure.o3-mini",
+ "openrouter.gpt-4.1-nano",
"openrouter.gpt-4o",
- "o1",
+ "gemini-2.5",
"azure.gpt-4o",
- "openrouter.gpt-4.1-nano",
+ "azure.gpt-4o-mini",
+ "claude-3.7-sonnet",
+ "azure.gpt-4.1-nano",
+ "openrouter.o1",
+ "openrouter.claude-3-haiku",
+ "bedrock.claude-3.7-sonnet",
+ "gemini-2.5-flash",
+ "azure.o3",
+ "openrouter.gemini-2.5",
+ "openrouter.o3",
+ "openrouter.o3-mini",
+ "openrouter.gpt-4.1-mini",
+ "openrouter.gpt-4.5-preview",
+ "openrouter.gpt-4o-mini",
+ "gpt-4.1-mini",
+ "meta-llama/llama-4-scout-17b-16e-instruct",
+ "openrouter.o1-mini",
+ "gpt-4.5-preview",
"o3",
- "gpt-4.1",
- "azure.o1",
- "claude-3-haiku",
+ "openrouter.claude-3.5-haiku",
"claude-3-opus",
- "gpt-4.1-mini",
- "openrouter.o4-mini",
- "openrouter.gemini-2.5-flash",
- "claude-3.5-haiku",
- "o3-mini",
- "azure.o3",
- "gpt-4o",
- "azure.gpt-4.1",
- "openrouter.claude-3-haiku",
- "gpt-4.1-nano",
- "azure.gpt-4.1-nano",
- "claude-3.7-sonnet",
- "gemini-2.5",
+ "o1-pro",
+ "gemini-2.0-flash",
"azure.o4-mini",
- "o1-mini",
- "qwen-qwq",
+ "openrouter.o4-mini",
+ "claude-3.5-sonnet",
"meta-llama/llama-4-maverick-17b-128e-instruct",
+ "azure.o1",
"openrouter.gpt-4.1",
"openrouter.o1-pro",
- "openrouter.o3",
- "claude-3.5-sonnet",
- "gemini-2.5-flash",
+ "gpt-4.1-nano",
+ "azure.gpt-4.5-preview",
+ "openrouter.claude-3-opus",
+ "gpt-4o-mini",
+ "o1",
+ "deepseek-r1-distill-llama-70b",
+ "azure.gpt-4.1",
+ "gpt-4o",
"azure.gpt-4.1-mini",
- "openrouter.o3-mini"
+ "openrouter.claude-3.7-sonnet",
+ "claude-3.5-haiku",
+ "gemini-2.0-flash-lite"
],
"type": "string"
},
@@ -354,6 +354,36 @@
"tui": {
"description": "Terminal User Interface configuration",
"properties": {
+ "customTheme": {
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ "type": "string"
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "dark": {
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ "type": "string"
+ },
+ "light": {
+ "pattern": "^#[0-9a-fA-F]{6}$",
+ "type": "string"
+ }
+ },
+ "required": [
+ "dark",
+ "light"
+ ],
+ "type": "object"
+ }
+ ]
+ },
+ "description": "Custom theme color definitions",
+ "type": "object"
+ },
"theme": {
"default": "opencode",
"description": "TUI theme name",
@@ -366,7 +396,8 @@
"monokai",
"onedark",
"tokyonight",
- "tron"
+ "tron",
+ "custom"
],
"type": "string"
}