diff --git a/pkg/tui/styles/testdata/themes/embedder.yaml b/pkg/tui/styles/testdata/themes/embedder.yaml new file mode 100644 index 000000000..c0520f194 --- /dev/null +++ b/pkg/tui/styles/testdata/themes/embedder.yaml @@ -0,0 +1,4 @@ +version: 1 +name: "Embedder" +colors: + accent: "#FF00AA" diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index c718b839c..7399f57e7 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -2,7 +2,9 @@ package styles import ( "embed" + "errors" "fmt" + "io/fs" "os" "path/filepath" "slices" @@ -38,8 +40,115 @@ var ( builtinRefsCache []string builtinRefsCacheOK bool builtinRefsCacheMu sync.Mutex + + // extraThemeFSes holds additional theme sources contributed by embedders via + // RegisterBuiltinThemes. They are consulted alongside the bundled themes. + extraThemeFSes []fs.FS + extraThemeFSesMu sync.RWMutex ) +// RegisterBuiltinThemes adds an additional source of built-in themes from fsys. +// It must contain theme files at "themes/.yaml" (or .yml) — the same layout +// and partial-override-onto-default semantics as cagent's bundled themes — so an +// embedder typically passes an embed.FS declared with //go:embed themes/*.yaml. +// +// Registered themes are treated exactly like cagent's own built-ins: they appear +// in ListThemeRefs and the theme picker, resolve via LoadTheme/ApplyThemeRef, and +// can be persisted as the user's selection. Registered sources take precedence +// over the bundled themes, so a registered ref overrides the bundled theme of the +// same name — including masking "default" with the embedder's own. Precedence is +// last-wins: when more than one registered source provides the same ref, a later +// RegisterBuiltinThemes call overrides an earlier one. An override is still merged +// onto cagent's pristine DefaultTheme() base, so a registered theme only needs to +// specify the fields it changes; DefaultTheme() itself stays the bundled merge +// base, and each name is listed once. +// +// Call this at startup, before applying any persisted theme, so that a persisted +// selection naming a registered theme resolves. +func RegisterBuiltinThemes(fsys fs.FS) error { + if fsys == nil { + return errors.New("register built-in themes: nil fs") + } + // Surface an unreadable source eagerly rather than at picker time. + if _, err := fs.ReadDir(fsys, "themes"); err != nil { + return fmt.Errorf("register built-in themes: %w", err) + } + + extraThemeFSesMu.Lock() + extraThemeFSes = append(extraThemeFSes, fsys) + extraThemeFSesMu.Unlock() + + // Newly registered themes must appear in subsequent listings. + builtinRefsCacheMu.Lock() + builtinRefsCacheOK = false + builtinRefsCacheMu.Unlock() + + // Drop any theme already resolved under a ref this source overrides (a bundled + // built-in, or "default"), so the registered override/mask wins even when it + // was loaded before registration — built-in cache entries are otherwise treated + // as permanently valid. DefaultTheme()'s own cache is separate and untouched, so + // it stays the pristine merge base. + InvalidateThemeCache("") + + return nil +} + +// registeredThemeFSes returns a snapshot of the embedder-contributed theme +// sources. +func registeredThemeFSes() []fs.FS { + extraThemeFSesMu.RLock() + defer extraThemeFSesMu.RUnlock() + return extraThemeFSes +} + +// readThemeRefsFromFS lists the theme refs (file basenames without extension) +// under the "themes" directory of fsys. +func readThemeRefsFromFS(fsys fs.FS) ([]string, error) { + entries, err := fs.ReadDir(fsys, "themes") + if err != nil { + return nil, err + } + var refs []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if before, ok := strings.CutSuffix(name, ".yaml"); ok { + refs = append(refs, before) + } else if before, ok := strings.CutSuffix(name, ".yml"); ok { + refs = append(refs, before) + } + } + return refs, nil +} + +// readThemeData returns the raw YAML for ref from fsys, trying .yaml then .yml. +func readThemeData(fsys fs.FS, ref string) ([]byte, bool) { + if data, err := fs.ReadFile(fsys, "themes/"+ref+".yaml"); err == nil { + return data, true + } + if data, err := fs.ReadFile(fsys, "themes/"+ref+".yml"); err == nil { + return data, true + } + return nil, false +} + +// readRegisteredThemeData returns the raw YAML for ref from the most recently +// registered source that provides it. Registered sources take precedence over +// cagent's bundled themes, so an embedder can override a built-in — including +// masking "default" with their own — and a later RegisterBuiltinThemes call wins +// a name collision with an earlier one (last-wins). +func readRegisteredThemeData(ref string) ([]byte, bool) { + fses := registeredThemeFSes() + for i := len(fses) - 1; i >= 0; i-- { // reverse: last-registered wins + if data, ok := readThemeData(fses[i], ref); ok { + return data, true + } + } + return nil, false +} + // InvalidateThemeCache clears the theme cache for a specific ref, or all if ref is empty. // This is primarily for testing; the cache is mtime-aware so it auto-invalidates on file changes. func InvalidateThemeCache(ref string) { @@ -267,23 +376,28 @@ func listBuiltinThemeRefs() ([]string, error) { return builtinRefsCache, nil } - var refs []string - - entries, err := builtinThemes.ReadDir("themes") + refs, err := readThemeRefsFromFS(builtinThemes) if err != nil { return nil, fmt.Errorf("reading embedded themes directory: %w", err) } - for _, entry := range entries { - if entry.IsDir() { - continue + // Append themes contributed by embedders, skipping the reserved "default" + // ref and any name that collides with an existing built-in. + seen := make(map[string]bool, len(refs)) + for _, r := range refs { + seen[r] = true + } + for _, fsys := range registeredThemeFSes() { + extraRefs, err := readThemeRefsFromFS(fsys) + if err != nil { + return nil, fmt.Errorf("reading registered themes: %w", err) } - name := entry.Name() - // Accept .yaml and .yml files - if before, ok := strings.CutSuffix(name, ".yaml"); ok { - refs = append(refs, before) - } else if before, ok := strings.CutSuffix(name, ".yml"); ok { - refs = append(refs, before) + for _, r := range extraRefs { + if r == DefaultThemeRef || seen[r] { + continue + } + seen[r] = true + refs = append(refs, r) } } @@ -524,18 +638,15 @@ func validateThemeRef(ref string) error { func loadBuiltinTheme(ref string) (*Theme, error) { base := DefaultTheme() - // Try .yaml first, then .yml - var data []byte - var err error - - yamlPath := "themes/" + ref + ".yaml" - ymlPath := "themes/" + ref + ".yml" - - data, err = builtinThemes.ReadFile(yamlPath) - if err != nil { - data, err = builtinThemes.ReadFile(ymlPath) + // Prefer embedder-registered sources over cagent's bundled themes, so an + // embedder can override a built-in — including masking "default" with their + // own. The override is still merged onto the bundled DefaultTheme() base, so + // a registered theme only needs to specify the fields it changes. + data, ok := readRegisteredThemeData(ref) + if !ok { + data, ok = readThemeData(builtinThemes, ref) } - if err != nil { + if !ok { return nil, fmt.Errorf("built-in theme %q not found", ref) } diff --git a/pkg/tui/styles/theme_test.go b/pkg/tui/styles/theme_test.go index 7fa40c745..8dbe95c74 100644 --- a/pkg/tui/styles/theme_test.go +++ b/pkg/tui/styles/theme_test.go @@ -1,16 +1,224 @@ package styles import ( + "embed" + "io/fs" "os" "path/filepath" "reflect" "testing" + "testing/fstest" "github.com/goccy/go-yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// embedderThemes mirrors how a downstream embedder ships themes: a //go:embed +// of a themes/ directory handed to RegisterBuiltinThemes. Here the files live +// under testdata/, so the test re-roots the FS with fs.Sub; an embedder that +// embeds at themes/*.yaml would pass its embed.FS directly. +// +//go:embed testdata/themes/*.yaml +var embedderThemes embed.FS + +// resetThemes clears embedder-registered theme sources and the theme caches +// before and after a test, so tests that register themes stay isolated. +func resetThemes(t *testing.T) { + t.Helper() + reset := func() { + extraThemeFSesMu.Lock() + extraThemeFSes = nil + extraThemeFSesMu.Unlock() + + builtinRefsCacheMu.Lock() + builtinRefsCacheOK = false + builtinRefsCache = nil + builtinRefsCacheMu.Unlock() + + InvalidateThemeCache("") + } + reset() + t.Cleanup(reset) +} + +// TestRegisterBuiltinThemes_Integration exercises the full embedder loop: +// register a theme from a real embed.FS, then discover, load, and apply it the +// way a downstream CLI/TUI would. The narrower tests below isolate individual +// behaviors (merge, precedence, errors) with synthetic sources. +func TestRegisterBuiltinThemes_Integration(t *testing.T) { + resetThemes(t) + original := CurrentTheme() + t.Cleanup(func() { ApplyTheme(original) }) + + themesFS, err := fs.Sub(embedderThemes, "testdata") + require.NoError(t, err) + require.NoError(t, RegisterBuiltinThemes(themesFS)) + + // The registered theme is discoverable and classified like a built-in. + refs, err := ListThemeRefs() + require.NoError(t, err) + assert.Contains(t, refs, "embedder") + assert.True(t, IsBuiltinTheme("embedder")) + + // It applies via the embedder entry point, merged onto the default theme so + // unspecified fields are inherited. + applied := ApplyThemeRef("embedder") + require.NotNil(t, applied) + assert.Equal(t, "embedder", applied.Ref) + assert.Equal(t, "Embedder", applied.Name) + assert.Equal(t, "#FF00AA", applied.Colors.Accent) + assert.Equal(t, DefaultTheme().Colors.Background, applied.Colors.Background) + assert.Equal(t, "embedder", CurrentTheme().Ref) +} + +// TestRegisterBuiltinThemes covers core registration: a registered theme is +// listed, classified as built-in, and loads merged onto the default theme. +func TestRegisterBuiltinThemes(t *testing.T) { + resetThemes(t) + + def := DefaultTheme() + + src := fstest.MapFS{ + "themes/branded.yaml": &fstest.MapFile{ + Data: []byte("name: Branded\ncolors:\n accent: \"#FF0000\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + refs, err := ListThemeRefs() + require.NoError(t, err) + assert.Contains(t, refs, "branded") + assert.True(t, IsBuiltinTheme("branded")) + + theme, err := LoadTheme("branded") + require.NoError(t, err) + assert.Equal(t, "Branded", theme.Name) + assert.Equal(t, "branded", theme.Ref) + assert.Equal(t, "#FF0000", theme.Colors.Accent) + assert.Equal(t, def.Colors.Background, theme.Colors.Background) + assert.Equal(t, def.Markdown.Heading, theme.Markdown.Heading) +} + +// TestRegisterBuiltinThemes_MultipleSources verifies the built-in set aggregates +// across more than one registered source, and that the later-registered source +// wins a name collision between two registered sources (last-wins). +func TestRegisterBuiltinThemes_MultipleSources(t *testing.T) { + resetThemes(t) + + first := fstest.MapFS{ + "themes/alpha.yaml": &fstest.MapFile{Data: []byte("name: Alpha\n")}, + "themes/shared.yaml": &fstest.MapFile{Data: []byte("name: First\n")}, + } + second := fstest.MapFS{ + "themes/beta.yaml": &fstest.MapFile{Data: []byte("name: Beta\n")}, + "themes/shared.yaml": &fstest.MapFile{Data: []byte("name: Second\n")}, + } + require.NoError(t, RegisterBuiltinThemes(first)) + require.NoError(t, RegisterBuiltinThemes(second)) + + refs, err := ListThemeRefs() + require.NoError(t, err) + assert.Contains(t, refs, "alpha") + assert.Contains(t, refs, "beta") + + // Later registration wins a collision between two registered sources (last-wins). + shared, err := LoadTheme("shared") + require.NoError(t, err) + assert.Equal(t, "Second", shared.Name) +} + +// TestRegisterBuiltinThemes_OverridesBuiltin verifies a registered source takes +// precedence over a bundled theme of the same name. +func TestRegisterBuiltinThemes_OverridesBuiltin(t *testing.T) { + resetThemes(t) + + src := fstest.MapFS{ + "themes/nord.yaml": &fstest.MapFile{ + Data: []byte("name: NotNord\ncolors:\n accent: \"#123456\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + got, err := LoadTheme("nord") + require.NoError(t, err) + assert.Equal(t, "#123456", got.Colors.Accent) + assert.Equal(t, "NotNord", got.Name) +} + +// TestRegisterBuiltinThemes_MasksDefault verifies an embedder can replace the +// "default" theme; the override merges onto cagent's pristine default base. +func TestRegisterBuiltinThemes_MasksDefault(t *testing.T) { + resetThemes(t) + + cagentDefault := DefaultTheme() + + src := fstest.MapFS{ + "themes/default.yaml": &fstest.MapFile{ + Data: []byte("name: Branded Default\ncolors:\n accent: \"#ABCDEF\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + got, err := LoadTheme(DefaultThemeRef) + require.NoError(t, err) + assert.Equal(t, "#ABCDEF", got.Colors.Accent) + assert.Equal(t, "Branded Default", got.Name) + assert.Equal(t, cagentDefault.Colors.Background, got.Colors.Background) + + // DefaultTheme() itself remains the pristine merge base. + assert.Equal(t, "Default", DefaultTheme().Name) +} + +// TestRegisterBuiltinThemes_OverrideAfterLoad verifies a registered override +// still wins when the bundled built-in (and "default") were already loaded — and +// therefore cached — before registration. LoadTheme treats built-in cache entries +// as permanently valid, so registration must drop them; otherwise the override is +// a silent no-op. +func TestRegisterBuiltinThemes_OverrideAfterLoad(t *testing.T) { + resetThemes(t) + + // Warm the theme cache with the bundled built-in and the bundled default. + bundledNord, err := LoadTheme("nord") + require.NoError(t, err) + require.NotEqual(t, "#123456", bundledNord.Colors.Accent) + + bundledDefault, err := LoadTheme(DefaultThemeRef) + require.NoError(t, err) + require.NotEqual(t, "#ABCDEF", bundledDefault.Colors.Accent) + + src := fstest.MapFS{ + "themes/nord.yaml": &fstest.MapFile{ + Data: []byte("name: NotNord\ncolors:\n accent: \"#123456\"\n"), + }, + "themes/default.yaml": &fstest.MapFile{ + Data: []byte("name: Branded Default\ncolors:\n accent: \"#ABCDEF\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + gotNord, err := LoadTheme("nord") + require.NoError(t, err) + assert.Equal(t, "#123456", gotNord.Colors.Accent) + assert.Equal(t, "NotNord", gotNord.Name) + + gotDefault, err := LoadTheme(DefaultThemeRef) + require.NoError(t, err) + assert.Equal(t, "#ABCDEF", gotDefault.Colors.Accent) + assert.Equal(t, "Branded Default", gotDefault.Name) +} + +// TestRegisterBuiltinThemes_Errors covers eager validation of the source. +func TestRegisterBuiltinThemes_Errors(t *testing.T) { + resetThemes(t) + + require.Error(t, RegisterBuiltinThemes(nil)) + // A source without a "themes" directory is rejected eagerly. + require.Error(t, RegisterBuiltinThemes(fstest.MapFS{ + "other/x.yaml": &fstest.MapFile{Data: []byte("{}")}, + })) +} + func TestDefaultThemeRef(t *testing.T) { t.Parallel()