Files
kandev/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go
T
2026-09-24 14:45:00 +01:00

158 lines
6.0 KiB
Go

package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/settings/controller"
ws "github.com/kandev/kandev/pkg/websocket"
)
type createCustomTUIAgentRequest struct {
DisplayName string `json:"display_name"`
Model string `json:"model"`
Command string `json:"command"`
Description string `json:"description"`
// CommandArgs are appended to the argv built from Command. Unlike Command —
// which the registry splits on whitespace — each entry is one argv element,
// so an argument containing a space can only be expressed here.
CommandArgs []string `json:"command_args"`
// MCPStrategy names how the wrapped CLI loads MCP servers, so kandev can
// point it at the per-session server. Empty (the default) = no MCP tools.
MCPStrategy string `json:"mcp_strategy"`
// Protocol is the runtime kandev drives the command with: empty (the
// default) for terminal passthrough, "acp" for ACP on stdin/stdout.
Protocol string `json:"protocol"`
// DisableBracketedPaste selects paced unframed delivery for terminal TUIs
// that do not accept bracketed-paste delimiters.
DisableBracketedPaste bool `json:"disable_bracketed_paste"`
}
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
return controller.CreateCustomTUIAgentRequest{
DisplayName: r.DisplayName,
Model: r.Model,
Command: r.Command,
Description: r.Description,
CommandArgs: r.CommandArgs,
MCPStrategy: r.MCPStrategy,
Protocol: r.Protocol,
DisableBracketedPaste: r.DisableBracketedPaste,
}
}
func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
var body createCustomTUIAgentRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.DisplayName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "display_name is required"})
return
}
if body.Command == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "command is required"})
return
}
resp, err := h.controller.CreateCustomTUIAgent(c.Request.Context(), body.toControllerRequest())
if err != nil {
switch err {
case controller.ErrInvalidSlug, controller.ErrCommandRequired, controller.ErrUnknownMCPStrategy,
controller.ErrUnknownCustomAgentProtocol, controller.ErrMCPStrategyNotApplicable:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case controller.ErrAgentAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
h.logger.Error("failed to create custom TUI agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
// Broadcast updated available agents list and profile created event
if h.hub != nil {
availableResp, availErr := h.controller.ListAvailableAgents(c.Request.Context())
if availErr != nil {
h.logger.Error("failed to list available agents", zap.Error(availErr))
} else {
notification, _ := ws.NewNotification(ws.ActionAgentAvailableUpdated, gin.H{
"agents": availableResp.Agents,
})
h.hub.Broadcast(notification)
}
// Broadcast profile created so the create-task dialog picks up the new profile
if len(resp.Profiles) > 0 {
notification, _ := ws.NewNotification(ws.ActionAgentProfileCreated, gin.H{
"profile": resp.Profiles[0],
})
h.hub.Broadcast(notification)
}
}
c.JSON(http.StatusOK, resp)
}
type updateCustomTUIAgentMCPRequest struct {
MCPStrategy string `json:"mcp_strategy"`
}
// httpListMCPStrategies serves the selectable MCP injection strategies. The
// list is served rather than hardcoded in the client so a strategy added in Go
// becomes selectable without a matching frontend edit, and so the UI can show
// each strategy's own description of the mechanism it uses.
func (h *Handlers) httpListMCPStrategies(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"strategies": mcpconfig.StrategyOptions()})
}
// httpUpdateCustomTUIAgentMCP changes an existing custom TUI agent's MCP
// injection strategy. The rest of a tui_config stays immutable; this exists so
// agents created before the field existed can opt in without being deleted and
// rebuilt.
func (h *Handlers) httpUpdateCustomTUIAgentMCP(c *gin.Context) {
var body updateCustomTUIAgentMCPRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.SetCustomTUIAgentMCPStrategy(c.Request.Context(), c.Param("id"), body.MCPStrategy)
if err != nil {
switch err {
case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent,
controller.ErrMCPStrategyNotApplicable, controller.ErrUnknownCustomAgentProtocol:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case controller.ErrAgentNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
default:
h.logger.Error("failed to update custom TUI agent MCP strategy", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
// The agent's supports_mcp and tui_config both changed; refresh the agent
// list so open settings tabs show the MCP editor appearing or disappearing
// without a reload.
//
// ActionAgentSettingsUpdated, not ActionAgentUpdated: the latter is a
// runtime status ping shaped {agentId, status} feeding a different store
// slice, so sending an agent record on it inserts {id: undefined} into the
// runtime list instead of updating settings.
//
//ws:global agent settings are not workspace-scoped — the agents list is a
// global settings surface, so every connected client's copy is stale after
// this change. Matches the sibling agent.profile.* and agent.available.updated
// broadcasts in this package.
if h.hub != nil {
notification, _ := ws.NewNotification(ws.ActionAgentSettingsUpdated, gin.H{"agent": resp})
h.hub.Broadcast(notification)
}
c.JSON(http.StatusOK, resp)
}