From 3a5d50a919d2c532d508d50c70d31f8e87fa19e9 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Thu, 9 Jul 2026 22:37:09 -0700 Subject: [PATCH 1/2] fix(tui): wire MCP prompts into slash command dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP prompt items were added to the command palette but not to the slash command parser — they were missing SlashCommand and Immediate fields. As a result, an MCP server's prompts (e.g. /review, /summarize) passed through as plain text instead of invoking the MCP prompt. Add SlashCommand and Immediate to MCP prompt items, mirroring the existing pattern for agent commands and skills. Update Execute to map a non-empty arg string to the first declared prompt argument, so /summarize and /review work from the keyboard without the command palette dialog. --- pkg/tui/commands/commands.go | 138 +++++++++++-------- pkg/tui/commands/commands_mcp_prompt_test.go | 83 +++++++++++ 2 files changed, 161 insertions(+), 60 deletions(-) create mode 100644 pkg/tui/commands/commands_mcp_prompt_test.go diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 3296ab724..89b5909ca 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -10,6 +10,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "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 +499,82 @@ func removeByIDs(items []Item, ids map[string]bool) []Item { return out } +// 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 @@ -545,66 +622,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_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) +} From fb2e56a923c9e50d1eea818aafbe1e1ea2eba4ec Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Thu, 9 Jul 2026 19:53:54 -0700 Subject: [PATCH 2/2] refactor(tui): extract newAgentCommandItem for symmetry with newMCPPromptItem Factor the agent-command palette / slash Item construction out of BuildCommandCategories into a newAgentCommandItem helper, mirroring newMCPPromptItem. Behavior is unchanged; the extraction makes the item builder unit-testable without constructing an App, and the two builders now read symmetrically. Add unit tests covering the slash-command wiring and argument forwarding. --- pkg/tui/commands/commands.go | 43 +++++++++------ .../commands/commands_agent_command_test.go | 55 +++++++++++++++++++ 2 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 pkg/tui/commands/commands_agent_command_test.go diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 89b5909ca..28c85c029 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -9,6 +9,7 @@ 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" @@ -499,6 +500,29 @@ 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 @@ -592,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{ 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) +}