Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 42 additions & 14 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type Item struct {
SlashCommand string
Execute ExecuteFunc
Hidden bool // Hidden commands work as slash commands but don't appear in the palette
// Immediate marks that the command can be executed immediately as it does not
// interrupt any ongoing stream.
Immediate bool
}

func builtInSessionCommands() []Item {
Expand All @@ -43,6 +46,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/clear",
Description: "Clear the current tab and start a new session",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ClearSessionMsg{})
},
Expand All @@ -53,6 +57,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/attach",
Description: "Attach a file to your message (usage: /attach [path])",
Category: "Session",
Immediate: true,
Execute: func(arg string) tea.Cmd {
return core.CmdHandler(messages.AttachFileMsg{FilePath: arg})
},
Expand All @@ -63,6 +68,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/compact",
Description: "Summarize the current conversation (usage: /compact [additional instructions])",
Category: "Session",
Immediate: true,
Execute: func(arg string) tea.Cmd {
return core.CmdHandler(messages.CompactSessionMsg{AdditionalPrompt: arg})
},
Expand All @@ -73,6 +79,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/copy",
Description: "Copy the current conversation to the clipboard",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.CopySessionToClipboardMsg{})
},
Expand All @@ -83,6 +90,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/copy-last",
Description: "Copy the last assistant message to the clipboard",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.CopyLastResponseToClipboardMsg{})
},
Expand All @@ -93,6 +101,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/cost",
Description: "Show detailed cost breakdown for this session",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ShowCostDialogMsg{})
},
Expand All @@ -103,6 +112,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/eval",
Description: "Create an evaluation report (usage: /eval [filename])",
Category: "Session",
Immediate: true,
Execute: func(arg string) tea.Cmd {
return core.CmdHandler(messages.EvalSessionMsg{Filename: arg})
},
Expand All @@ -113,6 +123,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/exit",
Description: "Exit the application",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ExitSessionMsg{})
},
Expand All @@ -123,6 +134,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/quit",
Description: "Quit the application (alias for /exit)",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ExitSessionMsg{})
},
Expand All @@ -134,6 +146,7 @@ func builtInSessionCommands() []Item {
Hidden: true,
Description: "Quit the application (alias for /exit)",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ExitSessionMsg{})
},
Expand All @@ -144,6 +157,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/export",
Description: "Export the session as HTML (usage: /export [filename])",
Category: "Session",
Immediate: true,
Execute: func(arg string) tea.Cmd {
return core.CmdHandler(messages.ExportSessionMsg{Filename: arg})
},
Expand All @@ -154,6 +168,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/model",
Description: "Change the model for the current agent",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.OpenModelPickerMsg{})
},
Expand All @@ -164,6 +179,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/new",
Description: "Start a new conversation",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.NewSessionMsg{})
},
Expand All @@ -174,6 +190,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/permissions",
Description: "Show tool permission rules for this session",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ShowPermissionsDialogMsg{})
},
Expand All @@ -184,6 +201,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/sessions",
Description: "Browse and load past sessions",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.OpenSessionBrowserMsg{})
},
Expand All @@ -194,6 +212,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/shell",
Description: "Start a shell",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.StartShellMsg{})
},
Expand All @@ -204,6 +223,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/star",
Description: "Toggle star on current session",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ToggleSessionStarMsg{})
},
Expand All @@ -215,6 +235,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/tools",
Description: "Show all tools available to the current agent",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ShowToolsDialogMsg{})
},
Expand All @@ -225,6 +246,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/title",
Description: "Set or regenerate session title (usage: /title [new title])",
Category: "Session",
Immediate: true,
Execute: func(arg string) tea.Cmd {
arg = strings.TrimSpace(arg)
if arg == "" {
Expand All @@ -241,6 +263,7 @@ func builtInSessionCommands() []Item {
SlashCommand: "/yolo",
Description: "Toggle automatic approval of tool calls",
Category: "Session",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ToggleYoloMsg{})
},
Expand All @@ -263,6 +286,7 @@ func builtInSettingsCommands() []Item {
SlashCommand: "/split-diff",
Description: "Toggle split diff view mode",
Category: "Settings",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.ToggleSplitDiffMsg{})
},
Expand All @@ -273,6 +297,7 @@ func builtInSettingsCommands() []Item {
SlashCommand: "/theme",
Description: "Change the color theme",
Category: "Settings",
Immediate: true,
Execute: func(string) tea.Cmd {
return core.CmdHandler(messages.OpenThemePickerMsg{})
},
Expand Down Expand Up @@ -478,27 +503,30 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor
return categories
}

// ParseSlashCommand checks if the input matches a known slash command and returns
// the tea.Cmd to execute it. Returns nil if not a slash command or not recognized.
// This function only handles built-in session commands, not agent commands or MCP prompts.
func ParseSlashCommand(input string) tea.Cmd {
type Parser struct {
categories []Category
}

func NewParser(categories ...Category) *Parser {
return &Parser{
categories: categories,
}
}

func (p *Parser) Parse(input string) tea.Cmd {
if input == "" || input[0] != '/' {
return nil
}

// Split into command and argument
cmd, arg, _ := strings.Cut(input, " ")

// Search through built-in commands
for _, item := range builtInSessionCommands() {
if item.SlashCommand == cmd {
return item.Execute(arg)
}
}

for _, item := range builtInSettingsCommands() {
if item.SlashCommand == cmd {
return item.Execute(arg)
// Search through all categories and commands
for _, category := range p.categories {
for _, item := range category.Commands {
if item.SlashCommand == cmd && item.Immediate {
return item.Execute(arg)
}
}
}

Expand Down
34 changes: 22 additions & 12 deletions pkg/tui/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ import (
"github.com/docker/docker-agent/pkg/tui/messages"
)

func newTestParser() *Parser {
return NewParser(
Category{Name: "Session", Commands: builtInSessionCommands()},
Category{Name: "Settings", Commands: builtInSettingsCommands()},
)
}

func TestParseSlashCommand_Title(t *testing.T) {
t.Parallel()
parser := newTestParser()

t.Run("title with argument sets title", func(t *testing.T) {
t.Parallel()

cmd := ParseSlashCommand("/title My Custom Title")
cmd := parser.Parse("/title My Custom Title")
require.NotNil(t, cmd, "should return a command for /title with argument")

// Execute the command and check the message type
Expand All @@ -28,7 +36,7 @@ func TestParseSlashCommand_Title(t *testing.T) {
t.Run("title without argument regenerates", func(t *testing.T) {
t.Parallel()

cmd := ParseSlashCommand("/title")
cmd := parser.Parse("/title")
require.NotNil(t, cmd, "should return a command for /title without argument")

// Execute the command and check the message type
Expand All @@ -40,7 +48,7 @@ func TestParseSlashCommand_Title(t *testing.T) {
t.Run("title with only whitespace regenerates", func(t *testing.T) {
t.Parallel()

cmd := ParseSlashCommand("/title ")
cmd := parser.Parse("/title ")
require.NotNil(t, cmd, "should return a command for /title with whitespace")

// Execute the command and check the message type
Expand All @@ -52,10 +60,11 @@ func TestParseSlashCommand_Title(t *testing.T) {

func TestParseSlashCommand_OtherCommands(t *testing.T) {
t.Parallel()
parser := newTestParser()

t.Run("exit command", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("/exit")
cmd := parser.Parse("/exit")
require.NotNil(t, cmd)
msg := cmd()
_, ok := msg.(messages.ExitSessionMsg)
Expand All @@ -64,7 +73,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {

t.Run("new command", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("/new")
cmd := parser.Parse("/new")
require.NotNil(t, cmd)
msg := cmd()
_, ok := msg.(messages.NewSessionMsg)
Expand All @@ -73,7 +82,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {

t.Run("clear command", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("/clear")
cmd := parser.Parse("/clear")
require.NotNil(t, cmd)
msg := cmd()
_, ok := msg.(messages.ClearSessionMsg)
Expand All @@ -82,7 +91,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {

t.Run("star command", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("/star")
cmd := parser.Parse("/star")
require.NotNil(t, cmd)
msg := cmd()
_, ok := msg.(messages.ToggleSessionStarMsg)
Expand All @@ -91,29 +100,30 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {

t.Run("unknown command returns nil", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("/unknown")
cmd := parser.Parse("/unknown")
assert.Nil(t, cmd)
})

t.Run("non-slash input returns nil", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("hello world")
cmd := parser.Parse("hello world")
assert.Nil(t, cmd)
})

t.Run("empty input returns nil", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("")
cmd := parser.Parse("")
assert.Nil(t, cmd)
})
}

func TestParseSlashCommand_Compact(t *testing.T) {
t.Parallel()
parser := newTestParser()

t.Run("compact without argument", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("/compact")
cmd := parser.Parse("/compact")
require.NotNil(t, cmd)
msg := cmd()
compactMsg, ok := msg.(messages.CompactSessionMsg)
Expand All @@ -123,7 +133,7 @@ func TestParseSlashCommand_Compact(t *testing.T) {

t.Run("compact with argument", func(t *testing.T) {
t.Parallel()
cmd := ParseSlashCommand("/compact focus on the API design")
cmd := parser.Parse("/compact focus on the API design")
require.NotNil(t, cmd)
msg := cmd()
compactMsg, ok := msg.(messages.CompactSessionMsg)
Expand Down
10 changes: 4 additions & 6 deletions pkg/tui/components/editor/completions/command.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
package completions

import (
"context"
"slices"
"strings"

"github.com/docker/docker-agent/pkg/app"
"github.com/docker/docker-agent/pkg/tui/commands"
"github.com/docker/docker-agent/pkg/tui/components/completion"
)

type commandCompletion struct {
app *app.App
categories []commands.Category
}

func NewCommandCompletion(a *app.App) Completion {
func NewCommandCompletion(categories []commands.Category) Completion {
return &commandCompletion{
app: a,
categories: categories,
}
}

Expand All @@ -35,7 +33,7 @@ func (c *commandCompletion) Trigger() string {
func (c *commandCompletion) Items() []completion.Item {
var items []completion.Item

for _, cmd := range commands.BuildCommandCategories(context.Background(), c.app) {
for _, cmd := range c.categories {
for _, command := range cmd.Commands {
items = append(items, completion.Item{
Label: command.Label,
Expand Down
Loading