From f7b7e70564d3d07c51b752778a4e5ea4f7d67ade Mon Sep 17 00:00:00 2001 From: David Gageot Date: Thu, 28 May 2026 14:09:50 +0200 Subject: [PATCH] feat: add --disable-commands flag to hide and disable slash commands in TUI --- cmd/root/run.go | 15 +++++--- pkg/tui/tui.go | 51 +++++++++++++++++++++++- pkg/tui/tui_helpers_test.go | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/cmd/root/run.go b/cmd/root/run.go index 38304c3ed..8a5d0a59c 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -62,11 +62,12 @@ type runExecFlags struct { outputJSON bool // Run only - hideToolResults bool - lean bool - appName string - listenAddr string - onEventSpecs []string + hideToolResults bool + lean bool + appName string + listenAddr string + onEventSpecs []string + disabledCommands []string // globalPermissions holds the user-level global permission checker built // from user config settings. Nil when no global permissions are configured. @@ -140,6 +141,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { _ = cmd.PersistentFlags().MarkHidden("force-tui") cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use a simplified TUI with minimal chrome") 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.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)") @@ -503,6 +505,9 @@ func (f *runExecFlags) tuiOpts() []tui.Option { if f.appName != "" { opts = append(opts, tui.WithAppName(f.appName)) } + if len(f.disabledCommands) > 0 { + opts = append(opts, tui.WithDisabledCommands(f.disabledCommands)) + } return opts } diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 075394ad8..86b5d952d 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -188,6 +188,10 @@ type appModel struct { appName string appVersion string + + // disabledCommands holds slash commands to hide and disable. + // Normalized to start with "/". + disabledCommands map[string]bool } // Transcriber is the speech-to-text interface used by the TUI. It is an @@ -230,6 +234,32 @@ func WithVersion(v string) Option { } } +// WithDisabledCommands hides and disables the given slash commands so they +// are stripped from the command palette, the slash-command parser, and +// completion. Each entry is normalized to start with "/" (so "cost" and +// "/cost" are equivalent) and lower-cased to match the registered slash +// command names (so "/Cost" and "/cost" are equivalent). +func WithDisabledCommands(slashCommands []string) Option { + return func(m *appModel) { + if len(slashCommands) == 0 { + return + } + if m.disabledCommands == nil { + m.disabledCommands = make(map[string]bool, len(slashCommands)) + } + for _, c := range slashCommands { + c = strings.ToLower(strings.TrimSpace(c)) + if c == "" { + continue + } + if !strings.HasPrefix(c, "/") { + c = "/" + c + } + m.disabledCommands[c] = true + } + } +} + // WithCommandBuilder builds the command categories shown in the command // palette from the given function. It overrides the default command category // builder. To include the default commands, the given function should call @@ -392,7 +422,26 @@ func (m *appModel) reapplyKeyboardEnhancements() { } func (m *appModel) commandCategories() []commands.Category { - return m.buildCommandCategories(context.Background(), m) + categories := m.buildCommandCategories(context.Background(), m) + if len(m.disabledCommands) == 0 { + return categories + } + filtered := make([]commands.Category, 0, len(categories)) + for _, cat := range categories { + items := make([]commands.Item, 0, len(cat.Commands)) + for _, item := range cat.Commands { + if m.disabledCommands[item.SlashCommand] { + continue + } + items = append(items, item) + } + if len(items) == 0 { + continue + } + cat.Commands = items + filtered = append(filtered, cat) + } + return filtered } // chatPageOpts returns the chat.PageOption slice derived from the current diff --git a/pkg/tui/tui_helpers_test.go b/pkg/tui/tui_helpers_test.go index 56d0d59c2..1435092bf 100644 --- a/pkg/tui/tui_helpers_test.go +++ b/pkg/tui/tui_helpers_test.go @@ -1,11 +1,13 @@ package tui import ( + "context" "strings" "testing" tea "charm.land/bubbletea/v2" + "github.com/docker/docker-agent/pkg/tui/commands" "github.com/docker/docker-agent/pkg/tui/components/statusbar" "github.com/docker/docker-agent/pkg/tui/components/tabbar" ) @@ -263,3 +265,78 @@ func TestFormatWindowTitle(t *testing.T) { }) } } + +func TestCommandCategories_DisabledCommandsFilter(t *testing.T) { + t.Parallel() + + build := func(context.Context, tea.Model) []commands.Category { + return []commands.Category{ + { + Name: "Session", + Commands: []commands.Item{ + {ID: "a", SlashCommand: "/cost"}, + {ID: "b", SlashCommand: "/eval"}, + {ID: "c", SlashCommand: "/exit"}, + }, + }, + { + Name: "Settings", + Commands: []commands.Item{ + {ID: "d", SlashCommand: "/theme"}, + }, + }, + } + } + + t.Run("no filter keeps everything", func(t *testing.T) { + t.Parallel() + m := &appModel{buildCommandCategories: build} + got := m.commandCategories() + if len(got) != 2 { + t.Fatalf("len(categories) = %d, want 2", len(got)) + } + }) + + t.Run("filters slash commands and drops empty categories", func(t *testing.T) { + t.Parallel() + m := &appModel{buildCommandCategories: build} + WithDisabledCommands([]string{"/cost", "eval", "/theme"})(m) + + got := m.commandCategories() + if len(got) != 1 { + t.Fatalf("len(categories) = %d, want 1 (Settings dropped, Session kept)", len(got)) + } + if got[0].Name != "Session" { + t.Fatalf("category = %q, want Session", got[0].Name) + } + if len(got[0].Commands) != 1 || got[0].Commands[0].SlashCommand != "/exit" { + t.Fatalf("session commands = %+v, want only /exit", got[0].Commands) + } + }) + + t.Run("blank entries are ignored", func(t *testing.T) { + t.Parallel() + m := &appModel{buildCommandCategories: build} + WithDisabledCommands([]string{"", " "})(m) + got := m.commandCategories() + if len(got) != 2 { + t.Fatalf("len(categories) = %d, want 2", len(got)) + } + }) + + t.Run("matching is case-insensitive", func(t *testing.T) { + t.Parallel() + m := &appModel{buildCommandCategories: build} + WithDisabledCommands([]string{"/Cost", "EVAL", "/Theme"})(m) + got := m.commandCategories() + if len(got) != 1 { + t.Fatalf("len(categories) = %d, want 1 (Settings dropped, Session kept)", len(got)) + } + if got[0].Name != "Session" { + t.Fatalf("category = %q, want Session", got[0].Name) + } + if len(got[0].Commands) != 1 || got[0].Commands[0].SlashCommand != "/exit" { + t.Fatalf("session commands = %+v, want only /exit", got[0].Commands) + } + }) +}