mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* feat(tui): interactive theme picker + color theme catalog
Bare `/theme` now opens a popup that live-previews each palette as the cursor
moves and applies on select (Esc reverts), matching /model and /effort. Adds ten
built-in color themes alongside the dark/light built-ins — dracula, nord, gruvbox,
tokyo-night, catppuccin, one-dark, solarized-dark, rose-pine, everforest, and
solarized-light — plus a reworked light palette. Every palette is contrast-audited
to WCAG AA; the contrast tests now loop the whole registry.
Palettes and an ordered registry live in a new internal/tui/theme_palettes.go (now
the sole home of hex literals); selection, the picker, /theme <name>, --theme, and
ZERO_THEME all resolve through it. The whole frame is painted with the active
theme's surface (tea.View.BackgroundColor) so every theme is legible on any
terminal — a light theme's dark text no longer lands invisibly on a dark terminal,
and color themes show their real surface.
The chosen theme persists to user config (preferences.theme) and is reapplied at
startup, below the --theme flag and ZERO_THEME.
* fix(tui): address CodeRabbit review on the theme catalog
- CHANGELOG: reconcile the --theme bullet with the named themes (was still
documenting {auto|dark|light}).
- /theme usage string advertises the list and auto forms (/theme list is still
accepted) so /help matches the command surface.
- Theme-persistence test parses the written config and asserts preferences.theme
directly instead of a raw substring, so a wrong-key regression fails.
230 lines
6.4 KiB
Go
230 lines
6.4 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return FileConfig{}, fmt.Errorf("config path is required")
|
|
}
|
|
profile.Name = strings.TrimSpace(profile.Name)
|
|
if profile.Name == "" {
|
|
return FileConfig{}, fmt.Errorf("provider name is required")
|
|
}
|
|
|
|
cfg := FileConfig{}
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
|
|
mergeProvider(&cfg, profile)
|
|
if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" {
|
|
cfg.ActiveProvider = profile.Name
|
|
}
|
|
|
|
if err := writeConfigFile(path, cfg); err != nil {
|
|
return FileConfig{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func SetActiveProvider(path string, name string) (FileConfig, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return FileConfig{}, fmt.Errorf("config path is required")
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return FileConfig{}, fmt.Errorf("provider name is required")
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
|
|
cfg := FileConfig{}
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
|
|
for _, provider := range cfg.Providers {
|
|
if strings.EqualFold(provider.Name, name) {
|
|
cfg.ActiveProvider = provider.Name
|
|
if err := writeConfigFile(path, cfg); err != nil {
|
|
return FileConfig{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
}
|
|
|
|
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
|
}
|
|
|
|
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return FileConfig{}, fmt.Errorf("config path is required")
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return FileConfig{}, fmt.Errorf("provider name is required")
|
|
}
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return FileConfig{}, fmt.Errorf("model is required")
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
|
|
cfg := FileConfig{}
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
|
|
for index := range cfg.Providers {
|
|
if strings.EqualFold(cfg.Providers[index].Name, name) {
|
|
cfg.Providers[index].Model = model
|
|
if err := writeConfigFile(path, cfg); err != nil {
|
|
return FileConfig{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
}
|
|
|
|
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
|
}
|
|
|
|
func SetFavoriteModels(path string, models []string) (FileConfig, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return FileConfig{}, fmt.Errorf("config path is required")
|
|
}
|
|
|
|
cfg := FileConfig{}
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
|
|
cfg.Preferences.FavoriteModels = normalizeFavoriteModels(models)
|
|
if err := writeConfigFile(path, cfg); err != nil {
|
|
return FileConfig{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// SetRecapsEnabled persists the post-turn recap preference, mirroring
|
|
// SetFavoriteModels (read-modify-atomic-write).
|
|
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return FileConfig{}, fmt.Errorf("config path is required")
|
|
}
|
|
cfg := FileConfig{}
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
v := enabled
|
|
cfg.Preferences.Recaps = &v
|
|
if err := writeConfigFile(path, cfg); err != nil {
|
|
return FileConfig{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
|
|
// (read-modify-atomic-write). A blank theme clears the stored preference.
|
|
func SetTheme(path string, theme string) (FileConfig, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return FileConfig{}, fmt.Errorf("config path is required")
|
|
}
|
|
cfg := FileConfig{}
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
cfg.Preferences.Theme = strings.TrimSpace(theme)
|
|
if err := writeConfigFile(path, cfg); err != nil {
|
|
return FileConfig{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func normalizeFavoriteModels(models []string) []string {
|
|
seen := map[string]bool{}
|
|
favorites := make([]string, 0, len(models))
|
|
for _, model := range models {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" || seen[model] {
|
|
continue
|
|
}
|
|
seen[model] = true
|
|
favorites = append(favorites, model)
|
|
}
|
|
sort.Strings(favorites)
|
|
return favorites
|
|
}
|
|
|
|
func writeConfigFile(path string, cfg FileConfig) error {
|
|
dir := filepath.Dir(path)
|
|
if dir != "." && dir != "" {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("create config directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode config JSON: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
// Write-to-temp + rename: an in-place write interrupted mid-way (crash,
|
|
// disk full) would leave the user's only config truncated or corrupt.
|
|
tmp, err := os.CreateTemp(dir, ".zero-config-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("secure config permissions %s: %w", path, err)
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|