diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 3296ab724..28c85c029 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -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" @@ -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 @@ -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{ @@ -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{ diff --git a/pkg/tui/commands/commands_agent_command_test.go b/pkg/tui/commands/commands_agent_command_test.go new file mode 100644 index 000000000..286d99363 --- /dev/null +++ b/pkg/tui/commands/commands_agent_command_test.go @@ -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) +} diff --git a/pkg/tui/commands/commands_mcp_prompt_test.go b/pkg/tui/commands/commands_mcp_prompt_test.go new file mode 100644 index 000000000..d522c6af8 --- /dev/null +++ b/pkg/tui/commands/commands_mcp_prompt_test.go @@ -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) +}