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
181 changes: 104 additions & 77 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
tea "charm.land/bubbletea/v2"

"github.com/docker/docker-agent/pkg/app"
"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/feedback"
mcptools "github.com/docker/docker-agent/pkg/tools/mcp"
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
"github.com/docker/docker-agent/pkg/tui/core"
"github.com/docker/docker-agent/pkg/tui/messages"
Expand Down Expand Up @@ -498,6 +500,105 @@ func removeByIDs(items []Item, ids map[string]bool) []Item {
return out
}

// newAgentCommandItem builds the command-palette / slash-command Item for a
// single agent command. SlashCommand and Immediate are set so the command
// dispatches from the keyboard (not only from the palette); Execute forwards
// the slash command, with any argument appended, to the agent as an
// AgentCommandMsg.
func newAgentCommandItem(commandName string, cmd types.Command) Item {
return Item{
ID: "agent.command." + commandName,
Label: commandName,
Description: toolcommon.TruncateText(cmd.DisplayText(), 60),
Category: "Agent Commands",
SlashCommand: "/" + commandName,
Immediate: true,
Execute: func(arg string) tea.Cmd {
input := "/" + commandName
if arg = strings.TrimSpace(arg); arg != "" {
input += " " + arg
}
return core.CmdHandler(messages.AgentCommandMsg{Command: input})
},
}
}

// newMCPPromptItem builds the command-palette / slash-command Item for a single
// MCP prompt. SlashCommand and Immediate are set so the prompt dispatches from
// the keyboard (not only from the palette dialog); Execute maps a non-empty
// argument string to the prompt's first declared argument, and otherwise runs
// with empty arguments (no required args) or opens the input dialog (a required
// argument is missing).
func newMCPPromptItem(promptName string, promptInfo mcptools.PromptInfo) Item {
// Build description with argument info
description := promptInfo.Description
if len(promptInfo.Arguments) > 0 {
// Count required arguments
requiredCount := 0
for _, arg := range promptInfo.Arguments {
if arg.Required {
requiredCount++
}
}

if requiredCount > 0 {
if description != "" {
description += " "
}
if requiredCount == 1 {
description += "(1 required arg)"
} else {
description += fmt.Sprintf("(%d required args)", requiredCount)
}
}
}

// Truncate long descriptions to fit on one line
description = toolcommon.TruncateText(description, 55)

return Item{
ID: "mcp.prompt." + promptName,
Label: promptName,
Description: description,
Category: "MCP Prompts",
SlashCommand: "/" + promptName,
Immediate: true,
Execute: func(arg string) tea.Cmd {
arg = strings.TrimSpace(arg)

// Slash command with argument: map to the first declared prompt argument.
if arg != "" && len(promptInfo.Arguments) > 0 {
return core.CmdHandler(messages.MCPPromptMsg{
PromptName: promptName,
Arguments: map[string]string{promptInfo.Arguments[0].Name: arg},
})
}

// No arg provided (palette click or slash with no arg): original behavior.
hasRequiredArgs := false
for _, a := range promptInfo.Arguments {
if a.Required {
hasRequiredArgs = true
break
}
}

if !hasRequiredArgs {
// Execute prompt with empty arguments
return core.CmdHandler(messages.MCPPromptMsg{
PromptName: promptName,
Arguments: make(map[string]string),
})
}
// Show parameter input dialog for prompts with required arguments
return core.CmdHandler(messages.ShowMCPPromptInputMsg{
PromptName: promptName,
PromptInfo: promptInfo,
})
},
}
}

// BuildCommandCategories builds the list of command categories for the command palette
func BuildCommandCategories(ctx context.Context, application *app.App) []Category {
// Get session commands and filter based on model capabilities
Expand All @@ -515,24 +616,9 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor

agentCommands := application.CurrentAgentCommands(ctx)
if len(agentCommands) > 0 {
var commands []Item
commands := make([]Item, 0, len(agentCommands))
for name, cmd := range agentCommands {
commandName := name
commands = append(commands, Item{
ID: "agent.command." + commandName,
Label: commandName,
Description: toolcommon.TruncateText(cmd.DisplayText(), 60),
Category: "Agent Commands",
SlashCommand: "/" + commandName,
Immediate: true,
Execute: func(arg string) tea.Cmd {
input := "/" + commandName
if arg = strings.TrimSpace(arg); arg != "" {
input += " " + arg
}
return core.CmdHandler(messages.AgentCommandMsg{Command: input})
},
})
commands = append(commands, newAgentCommandItem(name, cmd))
}

categories = append(categories, Category{
Expand All @@ -545,66 +631,7 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor
if len(mcpPrompts) > 0 {
mcpCommands := make([]Item, 0, len(mcpPrompts))
for promptName, promptInfo := range mcpPrompts {
// Build description with argument info
description := promptInfo.Description
if len(promptInfo.Arguments) > 0 {
// Count required arguments
requiredCount := 0
for _, arg := range promptInfo.Arguments {
if arg.Required {
requiredCount++
}
}

if requiredCount > 0 {
if description != "" {
description += " "
}
if requiredCount == 1 {
description += "(1 required arg)"
} else {
description += fmt.Sprintf("(%d required args)", requiredCount)
}
}
}

// Truncate long descriptions to fit on one line
description = toolcommon.TruncateText(description, 55)

// Create closure variables to capture current iteration values
currentPromptName := promptName
currentPromptInfo := promptInfo

mcpCommands = append(mcpCommands, Item{
ID: "mcp.prompt." + promptName,
Label: promptName,
Description: description,
Category: "MCP Prompts",
Execute: func(string) tea.Cmd {
// If prompt has no required arguments, execute immediately
hasRequiredArgs := false
for _, arg := range currentPromptInfo.Arguments {
if arg.Required {
hasRequiredArgs = true
break
}
}

if !hasRequiredArgs {
// Execute prompt with empty arguments
return core.CmdHandler(messages.MCPPromptMsg{
PromptName: currentPromptName,
Arguments: make(map[string]string),
})
} else {
// Show parameter input dialog for prompts with required arguments
return core.CmdHandler(messages.ShowMCPPromptInputMsg{
PromptName: currentPromptName,
PromptInfo: currentPromptInfo,
})
}
},
})
mcpCommands = append(mcpCommands, newMCPPromptItem(promptName, promptInfo))
}

categories = append(categories, Category{
Expand Down
55 changes: 55 additions & 0 deletions pkg/tui/commands/commands_agent_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package commands

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/tui/messages"
)

// parserForAgentCommand wires a single agent-command item into a parser exactly
// as BuildCommandCategories does, so Parse exercises the real item produced by
// newAgentCommandItem.
func parserForAgentCommand(name string, cmd types.Command) *Parser {
return NewParser(Category{
Name: "Agent Commands",
Commands: []Item{newAgentCommandItem(name, cmd)},
})
}

// Regression guard: agent-command items must carry SlashCommand + Immediate, or
// Parser.Parse never matches them and the command falls through as plain chat
// text instead of being invoked.
func TestAgentCommandItem_WiredForSlashDispatch(t *testing.T) {
t.Parallel()
item := newAgentCommandItem("review", types.Command{Description: "Review the current diff"})
assert.Equal(t, "/review", item.SlashCommand)
assert.True(t, item.Immediate)
}

// A non-empty argument is appended to the forwarded slash command.
func TestAgentCommandItem_ParseForwardsCommandWithArg(t *testing.T) {
t.Parallel()
parser := parserForAgentCommand("review", types.Command{Description: "Review the current diff"})

cmd := parser.Parse("/review the diff")
require.NotNil(t, cmd)
msg, ok := cmd().(messages.AgentCommandMsg)
require.True(t, ok)
assert.Equal(t, "/review the diff", msg.Command)
}

// With no argument, the bare slash command is forwarded.
func TestAgentCommandItem_ParseForwardsCommandNoArg(t *testing.T) {
t.Parallel()
parser := parserForAgentCommand("review", types.Command{Description: "Review the current diff"})

cmd := parser.Parse("/review")
require.NotNil(t, cmd)
msg, ok := cmd().(messages.AgentCommandMsg)
require.True(t, ok)
assert.Equal(t, "/review", msg.Command)
}
83 changes: 83 additions & 0 deletions pkg/tui/commands/commands_mcp_prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package commands

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

mcptools "github.com/docker/docker-agent/pkg/tools/mcp"
"github.com/docker/docker-agent/pkg/tui/messages"
)

// parserForPrompt wires a single MCP-prompt item into a parser exactly as
// BuildCommandCategories does, so Parse exercises the real item produced by
// newMCPPromptItem.
func parserForPrompt(info mcptools.PromptInfo) *Parser {
return NewParser(Category{
Name: "MCP Prompts",
Commands: []Item{newMCPPromptItem(info.Name, info)},
})
}

// Regression guard: MCP-prompt items must carry SlashCommand + Immediate, or
// Parser.Parse never matches them and the prompt falls through as plain chat
// text instead of being invoked.
func TestMCPPromptItem_WiredForSlashDispatch(t *testing.T) {
t.Parallel()
item := newMCPPromptItem("summarize", mcptools.PromptInfo{Name: "summarize"})
assert.Equal(t, "/summarize", item.SlashCommand)
assert.True(t, item.Immediate)
}

// A non-empty argument string is mapped to the prompt's first declared argument.
func TestMCPPromptItem_ParseMapsArgToFirstArgument(t *testing.T) {
t.Parallel()
parser := parserForPrompt(mcptools.PromptInfo{
Name: "summarize",
Arguments: []mcptools.PromptArgument{
{Name: "topic"},
{Name: "tone"},
},
})

cmd := parser.Parse("/summarize the quarterly report")
require.NotNil(t, cmd)
msg, ok := cmd().(messages.MCPPromptMsg)
require.True(t, ok)
assert.Equal(t, "summarize", msg.PromptName)
assert.Equal(t, map[string]string{"topic": "the quarterly report"}, msg.Arguments)
}

// No argument and no required arguments: the prompt runs immediately with an
// empty argument map (the palette-click path).
func TestMCPPromptItem_ParseNoArgNoRequiredRunsEmpty(t *testing.T) {
t.Parallel()
parser := parserForPrompt(mcptools.PromptInfo{
Name: "summarize",
Arguments: []mcptools.PromptArgument{{Name: "topic"}}, // optional
})

cmd := parser.Parse("/summarize")
require.NotNil(t, cmd)
msg, ok := cmd().(messages.MCPPromptMsg)
require.True(t, ok)
assert.Equal(t, "summarize", msg.PromptName)
assert.Empty(t, msg.Arguments)
}

// No argument but a required argument is declared: fall back to the input
// dialog rather than invoking the prompt with a missing value.
func TestMCPPromptItem_ParseNoArgWithRequiredOpensDialog(t *testing.T) {
t.Parallel()
parser := parserForPrompt(mcptools.PromptInfo{
Name: "summarize",
Arguments: []mcptools.PromptArgument{{Name: "topic", Required: true}},
})

cmd := parser.Parse("/summarize")
require.NotNil(t, cmd)
msg, ok := cmd().(messages.ShowMCPPromptInputMsg)
require.True(t, ok)
assert.Equal(t, "summarize", msg.PromptName)
}
Loading