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", - "llama-3.3-70b-versatile", - "gpt-4.5-preview", - "openrouter.claude-3-opus", - "openrouter.claude-3.5-sonnet", - "o4-mini", - "gemini-2.0-flash-lite", - "azure.gpt-4.5-preview", - "openrouter.gpt-4o", - "o1", - "azure.gpt-4o", - "openrouter.gpt-4.1-nano", - "o3", - "gpt-4.1", - "azure.o1", - "claude-3-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", - "azure.o4-mini", + "claude-3-haiku", "o1-mini", "qwen-qwq", + "llama-3.3-70b-versatile", + "openrouter.claude-3.5-sonnet", + "o3-mini", + "o4-mini", + "gpt-4.1", + "azure.o3-mini", + "openrouter.gpt-4.1-nano", + "openrouter.gpt-4o", + "gemini-2.5", + "azure.gpt-4o", + "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", + "openrouter.claude-3.5-haiku", + "claude-3-opus", + "o1-pro", + "gemini-2.0-flash", + "azure.o4-mini", + "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", - "llama-3.3-70b-versatile", - "gpt-4.5-preview", - "openrouter.claude-3-opus", - "openrouter.claude-3.5-sonnet", - "o4-mini", - "gemini-2.0-flash-lite", - "azure.gpt-4.5-preview", - "openrouter.gpt-4o", - "o1", - "azure.gpt-4o", - "openrouter.gpt-4.1-nano", - "o3", - "gpt-4.1", - "azure.o1", - "claude-3-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", - "azure.o4-mini", + "claude-3-haiku", "o1-mini", "qwen-qwq", + "llama-3.3-70b-versatile", + "openrouter.claude-3.5-sonnet", + "o3-mini", + "o4-mini", + "gpt-4.1", + "azure.o3-mini", + "openrouter.gpt-4.1-nano", + "openrouter.gpt-4o", + "gemini-2.5", + "azure.gpt-4o", + "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", + "openrouter.claude-3.5-haiku", + "claude-3-opus", + "o1-pro", + "gemini-2.0-flash", + "azure.o4-mini", + "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" }