diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index 048d95d73f..d318edc737 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -26,7 +26,8 @@ func provideLifecycleManager( eventBus bus.EventBus, dockerClient *docker.Client, agentSettingsRepo settingsstore.Repository, -) (*lifecycle.Manager, *registry.Registry, error) { + agentRegistry *registry.Registry, +) (*lifecycle.Manager, error) { log.Info("Initializing Agent Manager...") // Create runtime registry to manage multiple runtimes @@ -70,11 +71,6 @@ func provideLifecycleManager( runtimeRegistry.Register(remoteDockerRuntime) log.Info("Remote Docker runtime registered") - agentRegistry, _, err := registry.Provide(log) - if err != nil { - return nil, nil, err - } - credsMgr := credentials.NewManager(log) credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_")) credsMgr.AddProvider(credentials.NewAugmentSessionProvider()) @@ -101,11 +97,11 @@ func provideLifecycleManager( // via lifecycleMgr.SetMCPHandler(gateway.Dispatcher) if err := lifecycleMgr.Start(ctx); err != nil { - return nil, nil, err + return nil, err } log.Info("Agent Manager initialized", zap.Int("runtimes", len(runtimeRegistry.List())), zap.Int("agent_types", len(agentRegistry.List()))) - return lifecycleMgr, agentRegistry, nil + return lifecycleMgr, nil } diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 5d0452a147..dff445e80b 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -192,7 +192,13 @@ func startServices( //nolint:cyclop addCleanup(c) } - services, agentSettingsController, err := provideServices(cfg, log, repos, eventBus) + agentRegistry, _, err := registry.Provide(log) + if err != nil { + log.Error("Failed to initialize agent registry", zap.Error(err)) + return false + } + + services, agentSettingsController, err := provideServices(cfg, log, repos, eventBus, agentRegistry) if err != nil { log.Error("Failed to initialize services", zap.Error(err)) return false @@ -225,7 +231,7 @@ func startServices( //nolint:cyclop }() } - return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, dockerClient, dbPool, repos, services, agentSettingsController, runCleanups) + return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, dockerClient, dbPool, repos, services, agentSettingsController, agentRegistry, runCleanups) } // startAgentInfrastructure initializes the agent lifecycle manager, worktree, orchestrator, @@ -241,12 +247,13 @@ func startAgentInfrastructure( repos *Repositories, services *Services, agentSettingsController *agentsettingscontroller.Controller, + agentRegistry *registry.Registry, runCleanups func(), ) bool { // ============================================ // AGENT MANAGER // ============================================ - lifecycleMgr, agentRegistry, err := provideLifecycleManager(ctx, cfg, log, eventBus, dockerClient, repos.AgentSettings) + lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, dockerClient, repos.AgentSettings, agentRegistry) if err != nil { log.Error("Failed to initialize agent manager", zap.Error(err)) return false diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 5a474d80c7..48e8289c02 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -3,6 +3,8 @@ package main import ( "context" + "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/registry" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" @@ -18,11 +20,10 @@ import ( workflowservice "github.com/kandev/kandev/internal/workflow/service" ) -func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, eventBus bus.EventBus) (*Services, *agentsettingscontroller.Controller, error) { - agentRegistry, _, err := registry.Provide(log) - if err != nil { - return nil, nil, err - } +func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, eventBus bus.EventBus, agentRegistry *registry.Registry) (*Services, *agentsettingscontroller.Controller, error) { + // Load custom TUI agents from DB into registry before discovery + loadCustomTUIAgents(context.Background(), repos, agentRegistry, log) + discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log) if err != nil { return nil, nil, err @@ -66,6 +67,28 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories }, agentSettingsController, nil } +// loadCustomTUIAgents loads user-defined TUI agents from the database into the registry. +// Non-fatal: logs warnings but continues if any individual agent fails. +func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry *registry.Registry, log *logger.Logger) { + tuiAgents, err := repos.AgentSettings.ListTUIAgents(ctx) + if err != nil { + log.Warn("failed to load custom TUI agents from database", zap.Error(err)) + return + } + for _, agent := range tuiAgents { + if agent.TUIConfig == nil { + continue + } + cfg := agent.TUIConfig + if regErr := agentRegistry.RegisterCustomTUIAgent( + agent.Name, cfg.DisplayName, cfg.Command, cfg.Description, cfg.Model, cfg.CommandArgs, + ); regErr != nil { + log.Warn("failed to register custom TUI agent", + zap.String("name", agent.Name), zap.Error(regErr)) + } + } +} + // workflowStepGetterAdapter adapts workflow service to task service's WorkflowStepGetter interface. // Since task service now uses wfmodels.WorkflowStep directly, the adapter simply delegates to the service. type workflowStepGetterAdapter struct { diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go index 02eeb7d8cd..c98fd50117 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go @@ -91,6 +91,10 @@ func (m *MockRepository) ListAgentProfiles(ctx context.Context, agentID string) return []*models.AgentProfile{}, nil } +func (m *MockRepository) ListTUIAgents(ctx context.Context) ([]*models.Agent, error) { + return []*models.Agent{}, nil +} + func (m *MockRepository) Close() error { return nil } diff --git a/apps/backend/internal/agent/registry/custom_tui.go b/apps/backend/internal/agent/registry/custom_tui.go new file mode 100644 index 0000000000..732054b9dc --- /dev/null +++ b/apps/backend/internal/agent/registry/custom_tui.go @@ -0,0 +1,42 @@ +package registry + +import ( + "fmt" + "strings" + + "github.com/kandev/kandev/internal/agent/agents" +) + +// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and registers it. +// The command string is split into binary + args. Any {{model}} placeholder in the command +// is replaced with the model value. +func (r *Registry) RegisterCustomTUIAgent(slug, displayName, command, description, model string, commandArgs []string) error { + // Replace {{model}} template in the command string + resolvedCommand := command + if model != "" { + resolvedCommand = strings.ReplaceAll(command, "{{model}}", model) + } + + // Split command into binary + args + parts := strings.Fields(resolvedCommand) + if len(parts) == 0 { + return fmt.Errorf("command is empty") + } + binary := parts[0] + args := parts[1:] + if len(commandArgs) > 0 { + args = append(args, commandArgs...) + } + + cfg := agents.TUIAgentConfig{ + AgentID: slug, + AgentName: slug, + Command: binary, + Desc: description, + Display: displayName, + WaitForTerm: true, + CommandArgs: args, + } + tuiAgent := agents.NewTUIAgent(cfg) + return r.Register(tuiAgent) +} diff --git a/apps/backend/internal/agent/registry/custom_tui_test.go b/apps/backend/internal/agent/registry/custom_tui_test.go new file mode 100644 index 0000000000..4584aec39c --- /dev/null +++ b/apps/backend/internal/agent/registry/custom_tui_test.go @@ -0,0 +1,125 @@ +package registry + +import ( + "testing" +) + +func TestRegisterCustomTUIAgent_Success(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + err := reg.RegisterCustomTUIAgent("my-agent", "My Agent", "my-agent --verbose", "A test agent", "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ag, ok := reg.Get("my-agent") + if !ok { + t.Fatal("expected agent to be registered") + } + if ag.ID() != "my-agent" { + t.Errorf("expected ID %q, got %q", "my-agent", ag.ID()) + } + if ag.DisplayName() != "My Agent" { + t.Errorf("expected display name %q, got %q", "My Agent", ag.DisplayName()) + } + if ag.Description() != "A test agent" { + t.Errorf("expected description %q, got %q", "A test agent", ag.Description()) + } + + rt := ag.Runtime() + if rt == nil { + t.Fatal("expected non-nil runtime") + } + cmd := rt.Cmd.Args() + if len(cmd) < 2 || cmd[0] != "my-agent" || cmd[1] != "--verbose" { + t.Errorf("expected command [my-agent --verbose], got %v", cmd) + } +} + +func TestRegisterCustomTUIAgent_ModelTemplate(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + err := reg.RegisterCustomTUIAgent("tmpl-agent", "Template", "my-cli --model {{model}}", "", "best", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ag, ok := reg.Get("tmpl-agent") + if !ok { + t.Fatal("expected agent to be registered") + } + cmd := ag.Runtime().Cmd.Args() + found := false + for _, arg := range cmd { + if arg == "best" { + found = true + } + if arg == "{{model}}" { + t.Error("{{model}} should have been replaced") + } + } + if !found { + t.Errorf("expected 'best' in command args, got %v", cmd) + } +} + +func TestRegisterCustomTUIAgent_ModelTemplateNotReplacedWhenEmpty(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + err := reg.RegisterCustomTUIAgent("no-model", "No Model", "cli --model {{model}}", "", "", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ag, _ := reg.Get("no-model") + cmd := ag.Runtime().Cmd.Args() + found := false + for _, arg := range cmd { + if arg == "{{model}}" { + found = true + } + } + if !found { + t.Errorf("expected {{model}} to remain when model is empty, got %v", cmd) + } +} + +func TestRegisterCustomTUIAgent_CommandArgs(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + err := reg.RegisterCustomTUIAgent("extra-args", "Extra", "my-cli", "", "", []string{"--extra", "--flag"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ag, _ := reg.Get("extra-args") + cmd := ag.Runtime().Cmd.Args() + if len(cmd) < 3 || cmd[len(cmd)-2] != "--extra" || cmd[len(cmd)-1] != "--flag" { + t.Errorf("expected command args to include --extra --flag, got %v", cmd) + } +} + +func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + err := reg.RegisterCustomTUIAgent("empty-cmd", "Empty", "", "", "", nil) + if err == nil { + t.Error("expected error for empty command") + } +} + +func TestRegisterCustomTUIAgent_DuplicateID(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + + _ = reg.RegisterCustomTUIAgent("dup-agent", "First", "first-cli", "", "", nil) + err := reg.RegisterCustomTUIAgent("dup-agent", "Second", "second-cli", "", "", nil) + if err == nil { + t.Error("expected error for duplicate registration") + } +} diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go index 7e4bc12b81..cafd853d6b 100644 --- a/apps/backend/internal/agent/settings/controller/agent_config.go +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -191,7 +191,6 @@ func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && req.CLIPassthrough { cmd = ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ Model: req.Model, - Prompt: "{prompt}", PermissionValues: req.PermissionSettings, }) } else { diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index e189f0fb57..0c055743a7 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -166,6 +166,18 @@ func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (* } func (c *Controller) DeleteAgent(ctx context.Context, id string) error { + // If the agent has a tui_config, unregister from the in-memory registry + agent, err := c.repo.GetAgent(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrAgentNotFound + } + return err + } + if agent.TUIConfig != nil { + _ = c.agentRegistry.Unregister(agent.Name) + } + if err := c.repo.DeleteAgent(ctx, id); err != nil { if strings.Contains(err.Error(), "agent not found") { return ErrAgentNotFound diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 8ad56e9f7a..ca49766608 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -30,11 +30,14 @@ func buildCommandString(cmd []string) string { var ( ErrAgentNotFound = errors.New("agent not found") + ErrAgentAlreadyExists = errors.New("agent already exists") ErrAgentProfileNotFound = errors.New("agent profile not found") ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session") ErrAgentMcpUnsupported = errors.New("mcp not supported by agent") ErrModelRequired = errors.New("model is required for agent profiles") ErrLogoNotAvailable = errors.New("logo not available for agent") + ErrInvalidSlug = errors.New("display name must produce a valid slug") + ErrCommandRequired = errors.New("command is required") ) type Controller struct { diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index c05515dd30..344e7384fa 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -240,17 +240,13 @@ func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) { t.Errorf("PreviewAgentCommand() missing permission flag, got %v", result.Command) } - // Verify prompt placeholder is present - hasPrompt := false + // Verify prompt placeholder is NOT present in preview (prompt is injected at runtime, not in preview) for _, part := range result.Command { if part == "--prompt" || part == "{prompt}" { - hasPrompt = true + t.Errorf("PreviewAgentCommand() should not include prompt placeholder in passthrough preview, got %v", result.Command) break } } - if !hasPrompt { - t.Errorf("PreviewAgentCommand() missing prompt placeholder, got %v", result.Command) - } } func TestController_PreviewAgentCommand_AgentNotFound(t *testing.T) { diff --git a/apps/backend/internal/agent/settings/controller/custom_tui.go b/apps/backend/internal/agent/settings/controller/custom_tui.go new file mode 100644 index 0000000000..b0a80e0022 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/custom_tui.go @@ -0,0 +1,101 @@ +package controller + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/models" +) + +var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`) + +// slugify converts a display name into a URL-friendly slug. +func slugify(displayName string) string { + s := strings.ToLower(strings.TrimSpace(displayName)) + s = nonAlphanumeric.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + return s +} + +// CreateCustomTUIAgentRequest is the request to create a custom TUI agent. +type CreateCustomTUIAgentRequest struct { + DisplayName string + Model string + Command string + Description string +} + +// CreateCustomTUIAgent registers a new custom TUI agent and persists it to the database. +func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomTUIAgentRequest) (*dto.AgentDTO, error) { + slug := slugify(req.DisplayName) + if slug == "" { + return nil, ErrInvalidSlug + } + if req.Command == "" { + return nil, ErrCommandRequired + } + + // Check for conflict with existing registry entry + if c.agentRegistry.Exists(slug) { + return nil, ErrAgentAlreadyExists + } + + // Check for conflict with existing DB entry + existing, err := c.repo.GetAgentByName(ctx, slug) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if existing != nil { + return nil, ErrAgentAlreadyExists + } + + // Register in the in-memory registry + if regErr := c.agentRegistry.RegisterCustomTUIAgent( + slug, req.DisplayName, req.Command, req.Description, req.Model, nil, + ); regErr != nil { + return nil, fmt.Errorf("failed to register agent: %w", regErr) + } + + // Persist to DB + tuiConfig := &models.TUIConfigJSON{ + Command: req.Command, + DisplayName: req.DisplayName, + Model: req.Model, + Description: req.Description, + WaitForTerminal: true, + } + agent := &models.Agent{ + Name: slug, + TUIConfig: tuiConfig, + } + if err := c.repo.CreateAgent(ctx, agent); err != nil { + // Rollback registry on DB failure + _ = c.agentRegistry.Unregister(slug) + return nil, err + } + + // Create default passthrough profile — use model as profile name for distinct dropdown labels + profileName := req.Model + if profileName == "" { + profileName = req.DisplayName + } + profile := &models.AgentProfile{ + AgentID: agent.ID, + Name: profileName, + AgentDisplayName: req.DisplayName, + Model: "passthrough", + CLIPassthrough: true, + } + if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { + return nil, err + } + + profiles := []*models.AgentProfile{profile} + result := toAgentDTO(agent, profiles) + return &result, nil +} diff --git a/apps/backend/internal/agent/settings/controller/custom_tui_test.go b/apps/backend/internal/agent/settings/controller/custom_tui_test.go new file mode 100644 index 0000000000..58ccb57bed --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/custom_tui_test.go @@ -0,0 +1,221 @@ +package controller + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/settings/modelfetcher" + "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestControllerWithRepo(t *testing.T) (*Controller, *registry.Registry) { + t.Helper() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + repo, _, err := store.Provide(db, db) + if err != nil { + t.Fatal(err) + } + + reg := registry.NewRegistry(log) + ctrl := &Controller{ + repo: repo, + agentRegistry: reg, + modelCache: modelfetcher.NewCache(), + logger: log, + } + return ctrl, reg +} + +func TestSlugify(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"My Agent", "my-agent"}, + {" hello world ", "hello-world"}, + {"UPPER CASE", "upper-case"}, + {"special!@#chars", "special-chars"}, + {"---leading-trailing---", "leading-trailing"}, + {"", ""}, + {" ", ""}, + {"already-slug", "already-slug"}, + {"múltiple àccénts", "m-ltiple-cc-nts"}, + {"a", "a"}, + {"123", "123"}, + {"a--b", "a-b"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := slugify(tt.input) + if got != tt.expected { + t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestCreateCustomTUIAgent_Success(t *testing.T) { + ctrl, reg := newTestControllerWithRepo(t) + ctx := context.Background() + + result, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "My Custom Agent", + Model: "best", + Command: "my-agent --yolo", + Description: "A custom agent", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Name != "my-custom-agent" { + t.Errorf("expected name %q, got %q", "my-custom-agent", result.Name) + } + if result.TUIConfig == nil { + t.Fatal("expected tui_config to be set") + } + if result.TUIConfig.Command != "my-agent --yolo" { + t.Errorf("expected command %q, got %q", "my-agent --yolo", result.TUIConfig.Command) + } + if result.TUIConfig.DisplayName != "My Custom Agent" { + t.Errorf("expected display name %q, got %q", "My Custom Agent", result.TUIConfig.DisplayName) + } + if len(result.Profiles) != 1 { + t.Fatalf("expected 1 profile, got %d", len(result.Profiles)) + } + if result.Profiles[0].Name != "best" { + t.Errorf("expected profile name %q, got %q", "best", result.Profiles[0].Name) + } + if result.Profiles[0].Model != "passthrough" { + t.Errorf("expected profile model %q, got %q", "passthrough", result.Profiles[0].Model) + } + + // Verify registered in registry + if !reg.Exists("my-custom-agent") { + t.Error("expected agent to be registered in registry") + } +} + +func TestCreateCustomTUIAgent_ProfileNameFallsBackToDisplayName(t *testing.T) { + ctrl, _ := newTestControllerWithRepo(t) + ctx := context.Background() + + result, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "No Model Agent", + Command: "no-model-cli", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result.Profiles) != 1 { + t.Fatalf("expected 1 profile, got %d", len(result.Profiles)) + } + if result.Profiles[0].Name != "No Model Agent" { + t.Errorf("expected profile name %q, got %q", "No Model Agent", result.Profiles[0].Name) + } +} + +func TestCreateCustomTUIAgent_InvalidSlug(t *testing.T) { + ctrl, _ := newTestControllerWithRepo(t) + ctx := context.Background() + + _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "!!!", + Command: "some-cli", + }) + if err != ErrInvalidSlug { + t.Errorf("expected ErrInvalidSlug, got %v", err) + } +} + +func TestCreateCustomTUIAgent_EmptyCommand(t *testing.T) { + ctrl, _ := newTestControllerWithRepo(t) + ctx := context.Background() + + _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "Valid Name", + Command: "", + }) + if err != ErrCommandRequired { + t.Errorf("expected ErrCommandRequired, got %v", err) + } +} + +func TestCreateCustomTUIAgent_RegistryConflict(t *testing.T) { + ctrl, reg := newTestControllerWithRepo(t) + ctx := context.Background() + + // Pre-register an agent in the registry + _ = reg.RegisterCustomTUIAgent("my-agent", "My Agent", "my-cli", "", "", nil) + + _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "My Agent", + Command: "other-cli", + }) + if err != ErrAgentAlreadyExists { + t.Errorf("expected ErrAgentAlreadyExists, got %v", err) + } +} + +func TestCreateCustomTUIAgent_DBConflict(t *testing.T) { + ctrl, _ := newTestControllerWithRepo(t) + ctx := context.Background() + + // Create first agent + _, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "Unique Agent", + Command: "unique-cli", + }) + if err != nil { + t.Fatalf("first create failed: %v", err) + } + + // Try to create with same name — should conflict + _, err = ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "Unique Agent", + Command: "other-cli", + }) + if err != ErrAgentAlreadyExists { + t.Errorf("expected ErrAgentAlreadyExists, got %v", err) + } +} + +func TestDeleteAgent_UnregistersFromRegistry(t *testing.T) { + ctrl, reg := newTestControllerWithRepo(t) + ctx := context.Background() + + // Create a custom TUI agent + result, err := ctrl.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{ + DisplayName: "Delete Me", + Command: "delete-cli", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + + if !reg.Exists("delete-me") { + t.Fatal("expected agent to exist in registry before delete") + } + + // Delete it + if err := ctrl.DeleteAgent(ctx, result.ID); err != nil { + t.Fatalf("delete failed: %v", err) + } + + // Verify unregistered + if reg.Exists("delete-me") { + t.Error("expected agent to be unregistered from registry after delete") + } +} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 379197a4a4..3311619236 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -131,7 +131,7 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD for _, profile := range profiles { profileDTOs = append(profileDTOs, toProfileDTO(profile)) } - return dto.AgentDTO{ + result := dto.AgentDTO{ ID: agent.ID, Name: agent.Name, WorkspaceID: agent.WorkspaceID, @@ -141,6 +141,17 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD CreatedAt: agent.CreatedAt, UpdatedAt: agent.UpdatedAt, } + if agent.TUIConfig != nil { + result.TUIConfig = &dto.TUIConfigDTO{ + Command: agent.TUIConfig.Command, + DisplayName: agent.TUIConfig.DisplayName, + Model: agent.TUIConfig.Model, + Description: agent.TUIConfig.Description, + CommandArgs: agent.TUIConfig.CommandArgs, + WaitForTerminal: agent.TUIConfig.WaitForTerminal, + } + } + return result } func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 3d687e0ec9..7d347892f9 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -21,12 +21,22 @@ type AgentProfileDTO struct { UpdatedAt time.Time `json:"updated_at"` } +type TUIConfigDTO struct { + Command string `json:"command"` + DisplayName string `json:"display_name"` + Model string `json:"model,omitempty"` + Description string `json:"description,omitempty"` + CommandArgs []string `json:"command_args,omitempty"` + WaitForTerminal bool `json:"wait_for_terminal"` +} + type AgentDTO struct { ID string `json:"id"` Name string `json:"name"` WorkspaceID *string `json:"workspace_id,omitempty"` SupportsMCP bool `json:"supports_mcp"` MCPConfigPath string `json:"mcp_config_path,omitempty"` + TUIConfig *TUIConfigDTO `json:"tui_config,omitempty"` Profiles []AgentProfileDTO `json:"profiles"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go new file mode 100644 index 0000000000..1a803fd432 --- /dev/null +++ b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "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"` +} + +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(), controller.CreateCustomTUIAgentRequest{ + DisplayName: body.DisplayName, + Model: body.Model, + Command: body.Command, + Description: body.Description, + }) + if err != nil { + switch err { + case controller.ErrInvalidSlug, controller.ErrCommandRequired: + 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 + 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) + } + } + + c.JSON(http.StatusOK, resp) +} diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 9f262626d8..5a45f05f8a 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -43,6 +43,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.GET("/agents/available", h.httpListAvailableAgents) api.GET("/agents", h.httpListAgents) api.POST("/agents", h.httpCreateAgent) + api.POST("/agents/tui", h.httpCreateCustomTUIAgent) api.GET("/agents/:id", h.httpGetAgent) api.PATCH("/agents/:id", h.httpUpdateAgent) api.DELETE("/agents/:id", h.httpDeleteAgent) @@ -200,6 +201,20 @@ func (h *Handlers) httpDeleteAgent(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent"}) return } + + // Broadcast updated available agents list (agent may have been unregistered) + if h.hub != nil { + availableResp, err := h.controller.ListAvailableAgents(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list available agents after delete", zap.Error(err)) + } else { + notification, _ := ws.NewNotification(ws.ActionAgentAvailableUpdated, gin.H{ + "agents": availableResp.Agents, + }) + h.hub.Broadcast(notification) + } + } + c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 2c39d92549..1591074466 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -3,13 +3,24 @@ package models import "time" type Agent struct { - ID string `json:"id"` - Name string `json:"name"` - WorkspaceID *string `json:"workspace_id,omitempty"` - SupportsMCP bool `json:"supports_mcp"` - MCPConfigPath string `json:"mcp_config_path,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + WorkspaceID *string `json:"workspace_id,omitempty"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath string `json:"mcp_config_path,omitempty"` + TUIConfig *TUIConfigJSON `json:"tui_config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TUIConfigJSON is the JSON schema stored in the tui_config column for custom TUI agents. +type TUIConfigJSON struct { + Command string `json:"command"` + DisplayName string `json:"display_name"` + Model string `json:"model,omitempty"` + Description string `json:"description,omitempty"` + CommandArgs []string `json:"command_args,omitempty"` + WaitForTerminal bool `json:"wait_for_terminal"` } type AgentProfile struct { diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index b90cbfe8d2..e1a092a641 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -88,6 +88,9 @@ func (r *sqliteRepository) initSchema() error { return err } + // Migration: add tui_config column (idempotent — ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE agents ADD COLUMN tui_config TEXT DEFAULT NULL`) + return nil } @@ -105,16 +108,25 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent) now := time.Now().UTC() agent.CreatedAt = now agent.UpdatedAt = now + var tuiConfigJSON *string + if agent.TUIConfig != nil { + data, err := json.Marshal(agent.TUIConfig) + if err != nil { + return fmt.Errorf("failed to marshal tui_config: %w", err) + } + s := string(data) + tuiConfigJSON = &s + } _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt) + INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.CreatedAt, agent.UpdatedAt) return err } func (r *sqliteRepository) GetAgent(ctx context.Context, id string) (*models.Agent, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at + SELECT id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at FROM agents WHERE id = ? `), id) return scanAgent(row) @@ -122,7 +134,7 @@ func (r *sqliteRepository) GetAgent(ctx context.Context, id string) (*models.Age func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.Agent, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at + SELECT id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at FROM agents WHERE name = ? `), name) return scanAgent(row) @@ -157,26 +169,7 @@ func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error { } func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.Agent, error) { - rows, err := r.ro.QueryContext(ctx, ` - SELECT id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at - FROM agents ORDER BY created_at DESC - `) - if err != nil { - return nil, err - } - defer func() { - _ = rows.Close() - }() - - var result []*models.Agent - for rows.Next() { - agent, err := scanAgent(rows) - if err != nil { - return nil, err - } - result = append(result, agent) - } - return result, rows.Err() + return r.listAgentsWhere(ctx, "1=1") } func (r *sqliteRepository) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error) { @@ -319,18 +312,47 @@ func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string return result, rows.Err() } +func (r *sqliteRepository) ListTUIAgents(ctx context.Context) ([]*models.Agent, error) { + return r.listAgentsWhere(ctx, "tui_config IS NOT NULL") +} + +func (r *sqliteRepository) listAgentsWhere(ctx context.Context, where string) ([]*models.Agent, error) { + rows, err := r.ro.QueryContext(ctx, + `SELECT id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at + FROM agents WHERE `+where+` ORDER BY created_at DESC`, + ) + if err != nil { + return nil, err + } + defer func() { + _ = rows.Close() + }() + + var result []*models.Agent + for rows.Next() { + agent, scanErr := scanAgent(rows) + if scanErr != nil { + return nil, scanErr + } + result = append(result, agent) + } + return result, rows.Err() +} + func scanAgent(scanner interface { - Scan(dest ...interface{}) error + Scan(dest ...any) error }) (*models.Agent, error) { agent := &models.Agent{} var supportsMCP int var workspaceID sql.NullString + var tuiConfigRaw sql.NullString if err := scanner.Scan( &agent.ID, &agent.Name, &workspaceID, &supportsMCP, &agent.MCPConfigPath, + &tuiConfigRaw, &agent.CreatedAt, &agent.UpdatedAt, ); err != nil { @@ -340,11 +362,18 @@ func scanAgent(scanner interface { agent.WorkspaceID = &workspaceID.String } agent.SupportsMCP = supportsMCP == 1 + if tuiConfigRaw.Valid { + var cfg models.TUIConfigJSON + if err := json.Unmarshal([]byte(tuiConfigRaw.String), &cfg); err != nil { + return nil, fmt.Errorf("failed to parse tui_config: %w", err) + } + agent.TUIConfig = &cfg + } return agent, nil } func scanAgentProfile(scanner interface { - Scan(dest ...interface{}) error + Scan(dest ...any) error }) (*models.AgentProfile, error) { profile := &models.AgentProfile{} var autoApprove int diff --git a/apps/backend/internal/agent/settings/store/sqlite_tui_test.go b/apps/backend/internal/agent/settings/store/sqlite_tui_test.go new file mode 100644 index 0000000000..f188d45df2 --- /dev/null +++ b/apps/backend/internal/agent/settings/store/sqlite_tui_test.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +func newTestRepo(t *testing.T) Repository { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + repo, err := newSQLiteRepository(db, db, false) + if err != nil { + t.Fatal(err) + } + return repo +} + +func TestListTUIAgents_ReturnsOnlyTUIAgents(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + // Create a regular agent (no tui_config) + regular := &models.Agent{Name: "regular-agent"} + if err := repo.CreateAgent(ctx, regular); err != nil { + t.Fatal(err) + } + + // Create a TUI agent (with tui_config) + tui := &models.Agent{ + Name: "tui-agent", + TUIConfig: &models.TUIConfigJSON{ + Command: "my-cli", + DisplayName: "TUI Agent", + }, + } + if err := repo.CreateAgent(ctx, tui); err != nil { + t.Fatal(err) + } + + // ListTUIAgents should only return the TUI agent + tuiAgents, err := repo.ListTUIAgents(ctx) + if err != nil { + t.Fatal(err) + } + if len(tuiAgents) != 1 { + t.Fatalf("expected 1 TUI agent, got %d", len(tuiAgents)) + } + if tuiAgents[0].Name != "tui-agent" { + t.Errorf("expected name %q, got %q", "tui-agent", tuiAgents[0].Name) + } + if tuiAgents[0].TUIConfig == nil { + t.Fatal("expected tui_config to be set") + } + if tuiAgents[0].TUIConfig.Command != "my-cli" { + t.Errorf("expected command %q, got %q", "my-cli", tuiAgents[0].TUIConfig.Command) + } + + // ListAgents should return both + allAgents, err := repo.ListAgents(ctx) + if err != nil { + t.Fatal(err) + } + if len(allAgents) != 2 { + t.Fatalf("expected 2 agents, got %d", len(allAgents)) + } +} + +func TestListTUIAgents_EmptyWhenNone(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + regular := &models.Agent{Name: "only-regular"} + if err := repo.CreateAgent(ctx, regular); err != nil { + t.Fatal(err) + } + + tuiAgents, err := repo.ListTUIAgents(ctx) + if err != nil { + t.Fatal(err) + } + if len(tuiAgents) != 0 { + t.Errorf("expected 0 TUI agents, got %d", len(tuiAgents)) + } +} + +func TestCreateAgent_TUIConfigRoundTrip(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + agent := &models.Agent{ + Name: "roundtrip-agent", + TUIConfig: &models.TUIConfigJSON{ + Command: "my-cli --flag", + DisplayName: "Round Trip", + Model: "best", + Description: "A round trip test", + CommandArgs: []string{"--extra"}, + WaitForTerminal: true, + }, + } + if err := repo.CreateAgent(ctx, agent); err != nil { + t.Fatal(err) + } + + got, err := repo.GetAgent(ctx, agent.ID) + if err != nil { + t.Fatal(err) + } + if got.TUIConfig == nil { + t.Fatal("expected tui_config to be set after round-trip") + } + cfg := got.TUIConfig + if cfg.Command != "my-cli --flag" { + t.Errorf("command = %q, want %q", cfg.Command, "my-cli --flag") + } + if cfg.DisplayName != "Round Trip" { + t.Errorf("display_name = %q, want %q", cfg.DisplayName, "Round Trip") + } + if cfg.Model != "best" { + t.Errorf("model = %q, want %q", cfg.Model, "best") + } + if cfg.Description != "A round trip test" { + t.Errorf("description = %q, want %q", cfg.Description, "A round trip test") + } + if len(cfg.CommandArgs) != 1 || cfg.CommandArgs[0] != "--extra" { + t.Errorf("command_args = %v, want [--extra]", cfg.CommandArgs) + } + if !cfg.WaitForTerminal { + t.Error("wait_for_terminal should be true") + } +} + +func TestGetAgentByName_TUIConfig(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + agent := &models.Agent{ + Name: "by-name-tui", + TUIConfig: &models.TUIConfigJSON{ + Command: "test-cli", + DisplayName: "By Name", + }, + } + if err := repo.CreateAgent(ctx, agent); err != nil { + t.Fatal(err) + } + + got, err := repo.GetAgentByName(ctx, "by-name-tui") + if err != nil { + t.Fatal(err) + } + if got.TUIConfig == nil { + t.Fatal("expected tui_config via GetAgentByName") + } + if got.TUIConfig.Command != "test-cli" { + t.Errorf("command = %q, want %q", got.TUIConfig.Command, "test-cli") + } +} diff --git a/apps/backend/internal/agent/settings/store/store.go b/apps/backend/internal/agent/settings/store/store.go index e9791aaef3..52e091042b 100644 --- a/apps/backend/internal/agent/settings/store/store.go +++ b/apps/backend/internal/agent/settings/store/store.go @@ -13,6 +13,7 @@ type Repository interface { UpdateAgent(ctx context.Context, agent *models.Agent) error DeleteAgent(ctx context.Context, id string) error ListAgents(ctx context.Context) ([]*models.Agent, error) + ListTUIAgents(ctx context.Context) ([]*models.Agent, error) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error) UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error diff --git a/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go index 03de479076..81019d2a17 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go +++ b/apps/backend/internal/agentctl/server/process/interactive_lifecycle.go @@ -152,6 +152,22 @@ func (r *InteractiveRunner) Start(ctx context.Context, req InteractiveStartReque func (r *InteractiveRunner) immediateStartProcess(req InteractiveStartRequest, proc *interactiveProcess, id string) error { cols := req.DefaultCols rows := req.DefaultRows + + // Prefer last known session dimensions from previous resize events. + // This ensures restarted processes use the correct terminal size + // instead of the 120x40 defaults. + r.sessionWsMu.RLock() + sessWs, exists := r.sessionWs[req.SessionID] + r.sessionWsMu.RUnlock() + if exists && sessWs != nil { + sessWs.mu.RLock() + if sessWs.lastCols > 0 && sessWs.lastRows > 0 { + cols = int(sessWs.lastCols) + rows = int(sessWs.lastRows) + } + sessWs.mu.RUnlock() + } + if cols <= 0 { cols = 120 } diff --git a/apps/backend/internal/agentctl/server/process/interactive_resize.go b/apps/backend/internal/agentctl/server/process/interactive_resize.go index 1f2b07f794..c496210aab 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_resize.go +++ b/apps/backend/internal/agentctl/server/process/interactive_resize.go @@ -38,6 +38,19 @@ func (r *InteractiveRunner) lazyStartAndResize(proc *interactiveProcess, cols, r statusTracker.Resize(int(cols), int(rows)) } + // Store dimensions at session level so restarted processes use the correct size + if !proc.isUserShell { + r.sessionWsMu.RLock() + sessWs, exists := r.sessionWs[proc.info.SessionID] + r.sessionWsMu.RUnlock() + if exists && sessWs != nil { + sessWs.mu.Lock() + sessWs.lastCols = cols + sessWs.lastRows = rows + sessWs.mu.Unlock() + } + } + return nil } diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner.go b/apps/backend/internal/agentctl/server/process/interactive_runner.go index 88eeeae9a3..b9d7ed9b29 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_runner.go +++ b/apps/backend/internal/agentctl/server/process/interactive_runner.go @@ -73,8 +73,10 @@ type AgentStateCallback func(sessionID string, state AgentState) // sessionWebSocket tracks a WebSocket connection at the session level. // This allows the WebSocket to survive process restarts. type sessionWebSocket struct { - writer DirectOutputWriter - mu sync.RWMutex + writer DirectOutputWriter + lastCols uint16 + lastRows uint16 + mu sync.RWMutex } // userShellEntry tracks a user shell with its metadata. diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 00d38813f2..415bc8cc42 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -3,7 +3,18 @@ import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { IconPlus } from '@tabler/icons-react'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@kandev/ui/alert-dialog'; import { Button } from '@kandev/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; import { Separator } from '@kandev/ui/separator'; @@ -15,6 +26,7 @@ import { buildDefaultPermissions } from '@/lib/agent-permissions'; import { generateUUID } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; +import { deleteAgentAction } from '@/app/actions/agents'; import { ProfileMcpConfigCard } from './profile-mcp-config-card'; import { saveNewAgent, saveExistingAgent, isProfileDirty } from './agent-save-helpers'; import type { DraftProfile, DraftAgent } from './agent-save-helpers'; @@ -109,6 +121,7 @@ function ProfileCardItem({ agentName={draftAgent.name} onRemove={() => onRemoveProfile(profile.id)} canRemove={draftAgent.profiles.length > 1} + lockPassthrough={Boolean(draftAgent.tui_config)} /> void; }; -function AgentHeader({ displayName, matchedPath, isCreateMode }: AgentHeaderProps) { +function AgentHeader({ displayName, matchedPath, isCreateMode, savedAgent, onDelete }: AgentHeaderProps) { return (
@@ -183,6 +198,28 @@ function AgentHeader({ displayName, matchedPath, isCreateMode }: AgentHeaderProp {isCreateMode ? 'Create a new profile for this agent.' : 'Configure profiles and defaults for this agent.'}

+ {savedAgent?.tui_config && onDelete && ( + + + + + + + Delete {displayName}? + + This will permanently remove the agent and all its profiles. This action cannot be undone. + + + + Cancel + Delete + + + + )}
); } @@ -287,11 +324,21 @@ function AgentSetupForm({ initialAgent, savedAgent, discoveryAgent, onToastError } catch (error) { setSaveStatus('error'); onToastError(error); } }; + const handleDeleteAgent = async () => { + if (!savedAgent) return; + try { + await deleteAgentAction(savedAgent.id); + router.replace('/settings/agents'); + } catch (err) { + onToastError(err); + } + }; + const displayName = draftAgent.profiles[0]?.agent_display_name ?? draftAgent.name; return (
- + diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx index abdb73abd7..a12a86859e 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card.tsx @@ -41,9 +41,10 @@ function CommandPreviewEmpty() { type CommandPreviewContentProps = { preview: CommandPreviewResponse; + cliPassthrough: boolean; }; -function CommandPreviewContent({ preview }: CommandPreviewContentProps) { +function CommandPreviewContent({ preview, cliPassthrough }: CommandPreviewContentProps) { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -86,9 +87,11 @@ function CommandPreviewContent({ preview }: CommandPreviewContentProps) {
-

- {'{prompt}'} will be replaced with your task description or follow-up message. -

+ {!cliPassthrough && ( +

+ {'{prompt}'} will be replaced with your task description or follow-up message. +

+ )} ); } @@ -148,7 +151,7 @@ export function CommandPreviewCard({ {loading && } {error && } - {!loading && !error && preview && } + {!loading && !error && preview && } {!loading && !error && !preview && } diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 6a7dd74f5a..6c2b008a9f 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -2,15 +2,16 @@ import { useMemo, useState } from 'react'; import Link from 'next/link'; -import { IconAlertTriangle, IconSettings } from '@tabler/icons-react'; +import { IconAlertTriangle, IconPlus, IconSettings } from '@tabler/icons-react'; import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; import { Card, CardContent } from '@kandev/ui/card'; import { Separator } from '@kandev/ui/separator'; import { useAppStore } from '@/components/state-provider'; -import { listAgentDiscovery } from '@/lib/api'; +import { createCustomTUIAgent, listAgentDiscovery, listAgents, listAvailableAgents } from '@/lib/api'; import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; import { AgentLogo } from '@/components/agent-logo'; +import { AddTUIAgentDialog } from '@/components/settings/add-tui-agent-dialog'; import type { AgentDiscovery, Agent, AvailableAgent, AgentProfile } from '@/lib/types/http'; type AgentCardProps = { @@ -72,23 +73,24 @@ function ProfileListItem({ agent, profile }: ProfileListItemProps) { ); } -export default function AgentsSettingsPage() { +function useAgentPageState() { const discoveryAgents = useAppStore((state) => state.agentDiscovery.items); const savedAgents = useAppStore((state) => state.settingsAgents.items); const setAgentDiscovery = useAppStore((state) => state.setAgentDiscovery); + const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); + const setAvailableAgents = useAppStore((state) => state.setAvailableAgents); const availableAgents = useAvailableAgents().items; const [rescanning, setRescanning] = useState(false); + const [tuiDialogOpen, setTuiDialogOpen] = useState(false); const installedAgents = useMemo( () => discoveryAgents.filter((agent: AgentDiscovery) => agent.available), [discoveryAgents] ); - const savedAgentsByName = useMemo( () => new Map(savedAgents.map((agent: Agent) => [agent.name, agent])), [savedAgents] ); - const resolveDisplayName = (name: string) => availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? name; @@ -103,6 +105,32 @@ export default function AgentsSettingsPage() { } }; + const handleCreateCustomTUI = async (data: { display_name: string; model?: string; command: string }) => { + await createCustomTUIAgent(data); + const [discoveryResp, agentsResp, availableResp] = await Promise.all([ + listAgentDiscovery({ cache: 'no-store' }), + listAgents({ cache: 'no-store' }), + listAvailableAgents({ cache: 'no-store' }), + ]); + setAgentDiscovery(discoveryResp.agents); + setSettingsAgents(agentsResp.agents); + setAvailableAgents(availableResp.agents); + }; + + return { + savedAgents, installedAgents, savedAgentsByName, + rescanning, tuiDialogOpen, setTuiDialogOpen, + resolveDisplayName, handleRescan, handleCreateCustomTUI, + }; +} + +export default function AgentsSettingsPage() { + const { + savedAgents, installedAgents, savedAgentsByName, + rescanning, tuiDialogOpen, setTuiDialogOpen, + resolveDisplayName, handleRescan, handleCreateCustomTUI, + } = useAgentPageState(); + return (
@@ -122,9 +150,15 @@ export default function AgentsSettingsPage() { Agents detected on this machine are ready to configure.

- +
+ + +
{installedAgents.length === 0 && ( @@ -167,6 +201,8 @@ export default function AgentsSettingsPage() { )} + + ); } diff --git a/apps/web/components/agent-logo.tsx b/apps/web/components/agent-logo.tsx index 016b2ab21c..3444ddc6bc 100644 --- a/apps/web/components/agent-logo.tsx +++ b/apps/web/components/agent-logo.tsx @@ -61,7 +61,23 @@ function AgentLogoImage({ agentName, size = 16, className }: AgentLogoProps) { const src = cachedSrc ?? fetchedSrc; if (!mounted || error || !src) { - return ; + return ( + + + + + ); } return ( diff --git a/apps/web/components/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index 30e891057a..5d16a3c0e5 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -123,8 +123,8 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) { if (!open) return; Promise.all([listAvailableAgents({ cache: 'no-store' }), listAgentsAction()]) .then(([availRes, savedRes]) => { setAvailableAgents(availRes.agents ?? []); setAgentSettings(buildAgentSettings(availRes.agents ?? [], savedRes.agents ?? [])); }) - .catch(() => {}).finally(() => setLoadingAgents(false)); - listWorkflowTemplates().then((res) => setTemplates(res.templates ?? [])).catch(() => {}).finally(() => setLoadingTemplates(false)); + .catch(() => { }).finally(() => setLoadingAgents(false)); + listWorkflowTemplates().then((res) => setTemplates(res.templates ?? [])).catch(() => { }).finally(() => setLoadingTemplates(false)); }, [open]); const saveAgentSettings = useCallback(async () => { @@ -242,6 +242,7 @@ function StepAgents({

Expand an agent to configure its model and permissions. Changes are saved when you proceed. + You can also add custom TUI agents later in Settings > Agents.

); diff --git a/apps/web/components/settings/add-tui-agent-dialog.tsx b/apps/web/components/settings/add-tui-agent-dialog.tsx new file mode 100644 index 0000000000..f4c06528d1 --- /dev/null +++ b/apps/web/components/settings/add-tui-agent-dialog.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@kandev/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@kandev/ui/dialog'; +import { Input } from '@kandev/ui/input'; +import { Label } from '@kandev/ui/label'; + +type TUIAgentFormData = { display_name: string; model?: string; command: string }; + +type AddTUIAgentDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: TUIAgentFormData) => Promise; +}; + +export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentDialogProps) { + const [displayName, setDisplayName] = useState(''); + const [model, setModel] = useState(''); + const [command, setCommand] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setDisplayName(''); setModel(''); setCommand(''); + setError(null); setLoading(false); + }; + + const handleSubmit = async () => { + if (!displayName.trim()) { setError('Display name is required'); return; } + if (!command.trim()) { setError('Command is required'); return; } + setError(null); + setLoading(true); + try { + await onSubmit({ + display_name: displayName.trim(), + model: model.trim() || undefined, + command: command.trim(), + }); + reset(); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create agent'); + } finally { + setLoading(false); + } + }; + + return ( + { if (!next) reset(); onOpenChange(next); }}> + + + Add TUI Agent + + Register a CLI tool as a TUI agent. It will appear in the agent list with a default passthrough profile. + + +
+
+ + setDisplayName(e.target.value)} /> +
+
+ + setModel(e.target.value)} /> +

Profile label shown in the agent selector dropdown

+
+
+ + setCommand(e.target.value)} /> +

+ Binary name looked up on PATH. Use {'{{model}}'} to insert the model value. +

+
+ {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 989bc5143d..3f95806693 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -138,6 +138,7 @@ function ProfileSettingsCard({ permissionSettings={permissionSettings} passthroughConfig={passthroughConfig} agentName={agent.name} + lockPassthrough={Boolean(agent.tui_config)} /> diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx index 57d6eb964a..70c6567d22 100644 --- a/apps/web/components/settings/profile-form-fields.tsx +++ b/apps/web/components/settings/profile-form-fields.tsx @@ -25,6 +25,7 @@ export type ProfileFormFieldsProps = { canRemove?: boolean; variant?: 'default' | 'compact'; hideNameField?: boolean; + lockPassthrough?: boolean; }; type PermissionToggleProps = { @@ -33,6 +34,7 @@ type PermissionToggleProps = { permissionSettings: Record; passthroughConfig: PassthroughConfig | null; variant: 'default' | 'compact'; + lockPassthrough?: boolean; }; function PermissionToggles({ @@ -41,6 +43,7 @@ function PermissionToggles({ permissionSettings, passthroughConfig, variant, + lockPassthrough, }: PermissionToggleProps) { const isCompact = variant === 'compact'; const switchSize = isCompact ? ('sm' as const) : ('default' as const); @@ -79,6 +82,7 @@ function PermissionToggles({ size={switchSize} checked={profile.cli_passthrough} onCheckedChange={(checked) => onChange({ cli_passthrough: checked })} + disabled={lockPassthrough} /> )} @@ -117,6 +121,7 @@ function PermissionToggles({ onChange({ cli_passthrough: checked })} + disabled={lockPassthrough} /> )} @@ -135,6 +140,7 @@ export function ProfileFormFields({ canRemove = false, variant = 'default', hideNameField = false, + lockPassthrough = false, }: ProfileFormFieldsProps) { const isCompact = variant === 'compact'; @@ -181,6 +187,7 @@ export function ProfileFormFields({ permissionSettings={permissionSettings} passthroughConfig={passthroughConfig} variant={variant} + lockPassthrough={lockPassthrough} /> ); diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 57e8d8e523..9a8b0a5c7d 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -1,5 +1,6 @@ import { fetchJson, type ApiRequestOptions } from '../client'; import type { + Agent, ListAgentsResponse, ListAgentDiscoveryResponse, ListAvailableAgentsResponse, @@ -101,6 +102,16 @@ export async function previewAgentCommand( }); } +export async function createCustomTUIAgent( + payload: { display_name: string; model?: string; command: string; description?: string }, + options?: ApiRequestOptions +): Promise { + return fetchJson('/api/v1/agents/tui', { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + export async function fetchDynamicModels( agentName: string, options?: ApiRequestOptions & { refresh?: boolean } diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 940ae0fdcb..9e0b91bdbb 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -14,12 +14,22 @@ export type AgentProfile = { updated_at: string; }; +export type TUIConfig = { + command: string; + display_name: string; + model?: string; + description?: string; + command_args?: string[]; + wait_for_terminal: boolean; +}; + export type Agent = { id: string; name: string; workspace_id?: string | null; supports_mcp: boolean; mcp_config_path?: string | null; + tui_config?: TUIConfig | null; profiles: AgentProfile[]; created_at: string; updated_at: string;