Skip to content

Commit eba9421

Browse files
authored
Merge pull request #3724 from docker/fix/lean-mode-exclude-noop-commands
fix(tui): hide no-op slash commands (/settings) in lean mode
2 parents d771c13 + e8badc3 commit eba9421

2 files changed

Lines changed: 187 additions & 21 deletions

File tree

pkg/tui/tui.go

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"log/slog"
99
"os"
1010
"os/exec"
11+
"reflect"
1112
goruntime "runtime"
13+
"slices"
1214
"strings"
1315
"sync"
1416
"time"
@@ -288,9 +290,23 @@ type Option func(*appModel)
288290
func WithLeanMode() Option {
289291
return func(m *appModel) {
290292
m.leanMode = true
293+
m.addDisabledCommands(leanModeDisabledSlashCommands)
291294
}
292295
}
293296

297+
// leanModeDisabledSlashCommands lists the built-in slash commands that are
298+
// no-ops in lean mode: their Execute produces a message type that
299+
// leanModeDroppedMessageTypes (see appModel.update) silently drops there, so
300+
// running the command would do nothing. WithLeanMode seeds disabledCommands
301+
// from this list so they are also hidden from completion and the palette.
302+
//
303+
// TestLeanModeDisabledSlashCommandsMatchDroppedMessages cross-checks this
304+
// list against leanModeDroppedMessageTypes so the two can't silently drift
305+
// apart.
306+
var leanModeDisabledSlashCommands = []string{
307+
"/settings", // OpenSettingsDialogMsg
308+
}
309+
294310
// WithHideSidebar hides the chat sidebar. Unlike lean mode, the rest of
295311
// the chrome (tab bar, status bar, dialogs) remains visible. The user
296312
// cannot bring the sidebar back via the TUI.
@@ -360,22 +376,28 @@ func WithVersion(v string) Option {
360376
// command names (so "/Cost" and "/cost" are equivalent).
361377
func WithDisabledCommands(slashCommands []string) Option {
362378
return func(m *appModel) {
363-
if len(slashCommands) == 0 {
364-
return
379+
m.addDisabledCommands(slashCommands)
380+
}
381+
}
382+
383+
// addDisabledCommands normalizes slashCommands (lower-cased, "/"-prefixed)
384+
// and merges them into m.disabledCommands.
385+
func (m *appModel) addDisabledCommands(slashCommands []string) {
386+
if len(slashCommands) == 0 {
387+
return
388+
}
389+
if m.disabledCommands == nil {
390+
m.disabledCommands = make(map[string]bool, len(slashCommands))
391+
}
392+
for _, c := range slashCommands {
393+
c = strings.ToLower(strings.TrimSpace(c))
394+
if c == "" {
395+
continue
365396
}
366-
if m.disabledCommands == nil {
367-
m.disabledCommands = make(map[string]bool, len(slashCommands))
368-
}
369-
for _, c := range slashCommands {
370-
c = strings.ToLower(strings.TrimSpace(c))
371-
if c == "" {
372-
continue
373-
}
374-
if !strings.HasPrefix(c, "/") {
375-
c = "/" + c
376-
}
377-
m.disabledCommands[c] = true
397+
if !strings.HasPrefix(c, "/") {
398+
c = "/" + c
378399
}
400+
m.disabledCommands[c] = true
379401
}
380402
}
381403

@@ -778,15 +800,33 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
778800
return model, cmd
779801
}
780802

803+
// leanModeDroppedMessageTypes lists the message types that appModel.update
804+
// silently drops when leanMode is set, because the corresponding feature
805+
// (multi-tab sessions, sidebar, settings dialog) doesn't exist in lean mode.
806+
// It is the single source of truth for isLeanModeNoOp below, which in turn
807+
// TestLeanModeDisabledSlashCommandsMatchDroppedMessages uses to keep
808+
// leanModeDisabledSlashCommands (see WithLeanMode) from drifting out of sync.
809+
var leanModeDroppedMessageTypes = []reflect.Type{
810+
reflect.TypeFor[messages.SpawnSessionMsg](),
811+
reflect.TypeFor[messages.SwitchTabMsg](),
812+
reflect.TypeFor[messages.CloseTabMsg](),
813+
reflect.TypeFor[messages.ReorderTabMsg](),
814+
reflect.TypeFor[messages.ToggleSidebarMsg](),
815+
reflect.TypeFor[messages.OpenSettingsDialogMsg](),
816+
}
817+
818+
// isLeanModeNoOp reports whether msg is one of leanModeDroppedMessageTypes.
819+
func isLeanModeNoOp(msg tea.Msg) bool {
820+
return slices.Contains(leanModeDroppedMessageTypes, reflect.TypeOf(msg))
821+
}
822+
781823
func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) {
782824
// In lean mode, silently drop messages for features that don't exist.
783-
if m.leanMode {
784-
switch msg.(type) {
785-
case messages.SpawnSessionMsg, messages.SwitchTabMsg,
786-
messages.CloseTabMsg, messages.ReorderTabMsg,
787-
messages.ToggleSidebarMsg, messages.OpenSettingsDialogMsg:
788-
return m, nil
789-
}
825+
// leanModeDroppedMessageTypes is the single source of truth for what's
826+
// dropped here; leanModeDisabledSlashCommands (see WithLeanMode) hides
827+
// the built-in slash commands that would otherwise do nothing.
828+
if m.leanMode && isLeanModeNoOp(msg) {
829+
return m, nil
790830
}
791831

792832
switch msg := msg.(type) {

pkg/tui/tui_leanmode_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
tea "charm.land/bubbletea/v2"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/docker/docker-agent/pkg/app"
12+
"github.com/docker/docker-agent/pkg/session"
13+
"github.com/docker/docker-agent/pkg/tui/commands"
14+
"github.com/docker/docker-agent/pkg/tui/components/editor/completions"
15+
)
16+
17+
// leanModeTestCategories mirrors the real "Session"/"Settings" categories
18+
// closely enough to exercise lean-mode filtering: /settings is a no-op in
19+
// lean mode (OpenSettingsDialogMsg is dropped), /exit is not.
20+
func leanModeTestCategories(context.Context, tea.Model) []commands.Category {
21+
noop := func(string) tea.Cmd { return func() tea.Msg { return nil } }
22+
return []commands.Category{
23+
{
24+
Name: "Session",
25+
Commands: []commands.Item{
26+
{ID: "session.exit", Label: "Exit", SlashCommand: "/exit", Immediate: true, Execute: noop},
27+
},
28+
},
29+
{
30+
Name: "Settings",
31+
Commands: []commands.Item{
32+
{ID: "settings.open", Label: "Preferences", SlashCommand: "/settings", Immediate: true, Execute: noop},
33+
},
34+
},
35+
}
36+
}
37+
38+
func TestCommandCategories_LeanModeExcludesSettings(t *testing.T) {
39+
t.Parallel()
40+
41+
t.Run("lean mode drops /settings from categories, completion, and the parser", func(t *testing.T) {
42+
t.Parallel()
43+
44+
m := &appModel{ctx: t.Context, buildCommandCategories: leanModeTestCategories}
45+
WithLeanMode()(m)
46+
47+
categories := m.commandCategories()
48+
require.Len(t, categories, 1, "the now-empty Settings category should be dropped")
49+
assert.Equal(t, "Session", categories[0].Name)
50+
51+
items := completions.NewCommandCompletion(categories).Items()
52+
for _, item := range items {
53+
assert.NotEqual(t, "/settings", item.Value, "inline completion should not offer /settings in lean mode")
54+
}
55+
56+
parser := commands.NewParser(categories...)
57+
assert.Nil(t, parser.Parse("/settings"), "the palette parser should not run /settings in lean mode")
58+
})
59+
60+
t.Run("classic mode keeps /settings in categories, completion, and the parser", func(t *testing.T) {
61+
t.Parallel()
62+
63+
m := &appModel{ctx: t.Context, buildCommandCategories: leanModeTestCategories}
64+
65+
categories := m.commandCategories()
66+
require.Len(t, categories, 2)
67+
68+
items := completions.NewCommandCompletion(categories).Items()
69+
var sawSettings bool
70+
for _, item := range items {
71+
if item.Value == "/settings" {
72+
sawSettings = true
73+
}
74+
}
75+
assert.True(t, sawSettings, "inline completion should offer /settings in classic mode")
76+
77+
parser := commands.NewParser(categories...)
78+
cmd := parser.Parse("/settings")
79+
require.NotNil(t, cmd, "the palette parser should run /settings in classic mode")
80+
})
81+
}
82+
83+
// TestLeanModeDisabledSlashCommandsMatchDroppedMessages guards
84+
// leanModeDisabledSlashCommands against drifting out of sync with
85+
// leanModeDroppedMessageTypes (the lean-mode message-drop switch in
86+
// appModel.update): every built-in slash command whose Execute produces a
87+
// dropped message type must be listed, and every listed command must
88+
// actually produce one of those types.
89+
func TestLeanModeDisabledSlashCommandsMatchDroppedMessages(t *testing.T) {
90+
t.Parallel()
91+
92+
application := app.New(t.Context(), stubRuntime{}, session.New())
93+
categories := commands.BuildCommandCategories(t.Context(), application)
94+
95+
disabled := make(map[string]bool, len(leanModeDisabledSlashCommands))
96+
for _, c := range leanModeDisabledSlashCommands {
97+
disabled[c] = true
98+
}
99+
100+
matched := make(map[string]bool, len(leanModeDisabledSlashCommands))
101+
for _, category := range categories {
102+
for _, item := range category.Commands {
103+
if item.SlashCommand == "" || !item.Immediate || item.Execute == nil {
104+
continue
105+
}
106+
cmd := item.Execute("")
107+
if cmd == nil {
108+
continue
109+
}
110+
msg := cmd()
111+
112+
if isLeanModeNoOp(msg) {
113+
matched[item.SlashCommand] = true
114+
assert.True(t, disabled[item.SlashCommand],
115+
"%s produces %T, which lean mode drops, but is missing from leanModeDisabledSlashCommands", item.SlashCommand, msg)
116+
} else {
117+
assert.False(t, disabled[item.SlashCommand],
118+
"%s is listed in leanModeDisabledSlashCommands but produces %T, which lean mode does not drop", item.SlashCommand, msg)
119+
}
120+
}
121+
}
122+
123+
for _, c := range leanModeDisabledSlashCommands {
124+
assert.True(t, matched[c], "%s is listed in leanModeDisabledSlashCommands but no built-in command produces a dropped message type for it", c)
125+
}
126+
}

0 commit comments

Comments
 (0)