From ca094ceb61bc6264225fb9ff714b7b7364bad6a3 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 29 May 2026 16:53:01 +0200 Subject: [PATCH 1/2] feat: add --theme flag to preselect TUI theme --- cmd/root/completion.go | 17 +++++++++++++++++ cmd/root/run.go | 15 +++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cmd/root/completion.go b/cmd/root/completion.go index 2ed061ce6..67596e53a 100644 --- a/cmd/root/completion.go +++ b/cmd/root/completion.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/userconfig" ) @@ -97,6 +98,22 @@ func completeMessage(cmd *cobra.Command, args []string, toComplete string) ([]st return candidates, cobra.ShellCompDirectiveNoFileComp } +func completeTheme(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + refs, err := styles.ListThemeRefs() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var candidates []string + for _, ref := range refs { + if strings.HasPrefix(ref, toComplete) { + candidates = append(candidates, ref) + } + } + + return candidates, cobra.ShellCompDirectiveNoFileComp +} + func completeAgentFilename(toComplete string) ([]string, cobra.ShellCompDirective) { dirPrefix, base := filepath.Split(toComplete) diff --git a/cmd/root/run.go b/cmd/root/run.go index ade051430..ef207b7d0 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -69,6 +69,7 @@ type runExecFlags struct { listenAddr string onEventSpecs []string disabledCommands []string + theme string // globalPermissions holds the user-level global permission checker built // from user config settings. Nil when no global permissions are configured. @@ -144,6 +145,8 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().StringVar(&flags.appName, "app-name", "", "Application name shown in the TUI in place of \"docker agent\"") cmd.PersistentFlags().StringSliceVar(&flags.disabledCommands, "disable-commands", nil, "Comma-separated list of slash commands to hide and disable in the TUI (e.g. /cost,/eval,/model)") cmd.PersistentFlags().BoolVar(&flags.sidebar, "sidebar", true, "Show the sidebar in the TUI (set --sidebar=false to hide it)") + cmd.PersistentFlags().StringVar(&flags.theme, "theme", "", "Preselect a TUI theme by name (overrides the theme from user config)") + _ = cmd.RegisterFlagCompletionFunc("theme", completeTheme) cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)") cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "docker/sandbox-templates:docker-agent", "Template image for the sandbox (passed to docker sandbox create -t)") cmd.PersistentFlags().BoolVar(&flags.sbx, "sbx", true, "Prefer the sbx CLI backend when available (set --sbx=false to force docker sandbox)") @@ -321,7 +324,7 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s return err } - applyTheme() + applyTheme(f.theme) opts, err := f.buildAppOpts(args) if err != nil { return err @@ -637,13 +640,17 @@ func stopToolSets(t toolStopper) { } } -// applyTheme applies the theme from user config, or the built-in default. -func applyTheme() { - // Resolve theme from user config > built-in default +// applyTheme applies the theme, resolving it from the --theme flag, then the +// user config, then the built-in default. +func applyTheme(themeOverride string) { + // Resolve theme from --theme flag > user config > built-in default themeRef := styles.DefaultThemeRef if userSettings := userconfig.Get(); userSettings.Theme != "" { themeRef = userSettings.Theme } + if themeOverride != "" { + themeRef = themeOverride + } theme, err := styles.LoadTheme(themeRef) if err != nil { From 753eefdb2e92d197f8c12997c205cf9394dc498c Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 29 May 2026 17:00:51 +0200 Subject: [PATCH 2/2] Improve --theme flag validation and add completion tests Validate explicit --theme values early with a new validateTheme helper so typos fail fast with a helpful message listing available themes, instead of silently falling back at TUI startup. Update flag help text to clarify the theme is ignored outside the interactive TUI. Add tests for validateTheme, applyTheme precedence, and theme completion. --- cmd/root/completion_test.go | 46 +++++++++++++++++++++++++ cmd/root/run.go | 25 +++++++++++++- cmd/root/run_theme_test.go | 68 +++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 cmd/root/run_theme_test.go diff --git a/cmd/root/completion_test.go b/cmd/root/completion_test.go index e0afbe523..194ef29ae 100644 --- a/cmd/root/completion_test.go +++ b/cmd/root/completion_test.go @@ -321,6 +321,52 @@ func TestCompleteRunExec(t *testing.T) { } } +func TestCompleteTheme(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toComplete string + wantSome []string + wantNone []string + }{ + { + name: "empty prefix lists default and built-ins", + toComplete: "", + wantSome: []string{"default", "nord", "dracula"}, + }, + { + name: "prefix filters to matching themes", + toComplete: "gruvbox", + wantSome: []string{"gruvbox-dark", "gruvbox-light"}, + wantNone: []string{"default", "nord"}, + }, + { + name: "non-matching prefix yields no themes", + toComplete: "this-theme-does-not-exist", + wantNone: []string{"default", "nord"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + completions, directive := completeTheme(nil, nil, tt.toComplete) + + for _, want := range tt.wantSome { + assert.Contains(t, completions, want) + } + for _, notWant := range tt.wantNone { + assert.NotContains(t, completions, notWant) + } + + assert.NotEqual(t, cobra.ShellCompDirective(0), directive&cobra.ShellCompDirectiveNoFileComp, + "expected NoFileComp directive to be set") + }) + } +} + func writeFile(t *testing.T, dir, name string) { t.Helper() require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o644)) diff --git a/cmd/root/run.go b/cmd/root/run.go index ef207b7d0..6a97f9aef 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "time" "github.com/mattn/go-isatty" @@ -145,7 +146,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().StringVar(&flags.appName, "app-name", "", "Application name shown in the TUI in place of \"docker agent\"") cmd.PersistentFlags().StringSliceVar(&flags.disabledCommands, "disable-commands", nil, "Comma-separated list of slash commands to hide and disable in the TUI (e.g. /cost,/eval,/model)") cmd.PersistentFlags().BoolVar(&flags.sidebar, "sidebar", true, "Show the sidebar in the TUI (set --sidebar=false to hide it)") - cmd.PersistentFlags().StringVar(&flags.theme, "theme", "", "Preselect a TUI theme by name (overrides the theme from user config)") + cmd.PersistentFlags().StringVar(&flags.theme, "theme", "", "Preselect a TUI theme by name (overrides the theme from user config; ignored outside the interactive TUI)") _ = cmd.RegisterFlagCompletionFunc("theme", completeTheme) cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)") cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "docker/sandbox-templates:docker-agent", "Template image for the sandbox (passed to docker sandbox create -t)") @@ -179,6 +180,15 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command }() } + // Validate an explicit --theme value early so a typo fails fast with a + // helpful message instead of silently falling back to the default theme + // once the TUI starts. + if f.theme != "" { + if err := validateTheme(f.theme); err != nil { + return err + } + } + // Resolve alias / runtime-declared sandbox opt-in before dispatch. // An explicit --sandbox= on the CLI always wins, so we only // consult the lower-priority sources when the flag wasn't set. @@ -640,6 +650,19 @@ func stopToolSets(t toolStopper) { } } +// validateTheme reports whether ref names a loadable theme. It is used to +// fail fast on an explicit --theme value, listing the available themes so the +// user can correct a typo. +func validateTheme(ref string) error { + if _, err := styles.LoadTheme(ref); err != nil { + if refs, listErr := styles.ListThemeRefs(); listErr == nil && len(refs) > 0 { + return fmt.Errorf("unknown theme %q; available themes: %s", ref, strings.Join(refs, ", ")) + } + return fmt.Errorf("unknown theme %q: %w", ref, err) + } + return nil +} + // applyTheme applies the theme, resolving it from the --theme flag, then the // user config, then the built-in default. func applyTheme(themeOverride string) { diff --git a/cmd/root/run_theme_test.go b/cmd/root/run_theme_test.go new file mode 100644 index 000000000..897fcb744 --- /dev/null +++ b/cmd/root/run_theme_test.go @@ -0,0 +1,68 @@ +package root + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/paths" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +func TestValidateTheme(t *testing.T) { + t.Parallel() + + t.Run("accepts built-in theme", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateTheme("nord")) + }) + + t.Run("accepts default theme", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateTheme(styles.DefaultThemeRef)) + }) + + t.Run("rejects unknown theme with helpful message", func(t *testing.T) { + t.Parallel() + err := validateTheme("does-not-exist") + require.Error(t, err) + assert.Contains(t, err.Error(), "does-not-exist") + assert.Contains(t, err.Error(), "available themes") + }) + + t.Run("rejects path traversal", func(t *testing.T) { + t.Parallel() + require.Error(t, validateTheme("../../etc/passwd")) + }) +} + +func TestApplyThemePrecedence(t *testing.T) { + // Not parallel: mutates the process-global applied theme via ApplyTheme. + // Isolate config/data dirs so a developer's real user config (which may + // pin a theme) cannot influence the precedence assertions. + dir := t.TempDir() + paths.SetConfigDir(dir) + paths.SetDataDir(dir) + t.Cleanup(func() { + paths.SetConfigDir("") + paths.SetDataDir("") + }) + + t.Run("override takes precedence and is applied", func(t *testing.T) { + applyTheme("nord") + assert.Equal(t, "nord", styles.CurrentTheme().Ref) + }) + + t.Run("invalid override falls back to default theme", func(t *testing.T) { + // applyTheme tolerates an invalid ref (validateTheme guards the CLI + // entry point); it must never panic and should apply the default. + applyTheme("does-not-exist") + assert.Equal(t, styles.DefaultThemeRef, styles.CurrentTheme().Ref) + }) + + t.Run("empty override applies default when no user config theme", func(t *testing.T) { + applyTheme("") + assert.Equal(t, styles.DefaultThemeRef, styles.CurrentTheme().Ref) + }) +}