feat: suggest agent install commands and fix TUI agent startup (#398)
This commit is contained in:
@@ -257,9 +257,12 @@ func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentE
|
||||
return nil, fmt.Errorf("interactive runner not available for passthrough mode")
|
||||
}
|
||||
|
||||
// Some agents (like Codex) require the terminal to be connected first because
|
||||
// they query the terminal for cursor position on startup.
|
||||
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, !pt.WaitForTerminal)
|
||||
// Always start immediately with default dimensions (120×40). The first resize
|
||||
// from the terminal WebSocket will correct the size. Without immediate start,
|
||||
// WaitForTerminal agents deadlock: the frontend won't connect the terminal
|
||||
// until the session leaves STARTING, but the process never starts without a resize.
|
||||
// This matches ResumePassthroughSession and restartPassthroughProcess.
|
||||
startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, true)
|
||||
|
||||
processInfo, err := interactiveRunner.Start(ctx, startReq)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/agent/discovery"
|
||||
"github.com/kandev/kandev/internal/agent/settings/dto"
|
||||
@@ -53,7 +56,8 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl
|
||||
}
|
||||
payload = append(payload, c.buildAvailableAgentDTO(ctx, ag, availability, now))
|
||||
}
|
||||
return &dto.ListAvailableAgentsResponse{Agents: payload, Total: len(payload)}, nil
|
||||
tools := c.detectTools()
|
||||
return &dto.ListAvailableAgentsResponse{Agents: payload, Tools: tools, Total: len(payload)}, nil
|
||||
}
|
||||
|
||||
// HasAvailableAgents returns true if at least one agent is detected as installed.
|
||||
@@ -119,6 +123,8 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent
|
||||
return dto.AvailableAgentDTO{
|
||||
Name: ag.ID(),
|
||||
DisplayName: displayName,
|
||||
Description: ag.Description(),
|
||||
InstallScript: ag.InstallScript(),
|
||||
SupportsMCP: availability.SupportsMCP,
|
||||
MCPConfigPath: availability.MCPConfigPath,
|
||||
InstallationPaths: availability.InstallationPaths,
|
||||
@@ -371,6 +377,30 @@ func (c *Controller) upsertAgent(ctx context.Context, result discovery.Availabil
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// detectTools checks for recommended CLI tools (e.g. gh) and returns their status.
|
||||
func (c *Controller) detectTools() []dto.ToolStatusDTO {
|
||||
ghTool := dto.ToolStatusDTO{
|
||||
Name: "gh",
|
||||
DisplayName: "GitHub CLI",
|
||||
Description: "Required for GitHub integration features (PRs, reviews, webhooks).",
|
||||
InfoURL: "https://cli.github.com",
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
ghTool.InstallScript = "brew install gh"
|
||||
case "windows":
|
||||
ghTool.InstallScript = "winget install --id GitHub.cli"
|
||||
default:
|
||||
// No single command works across all Linux distros; rely on info_url.
|
||||
ghTool.InstallScript = ""
|
||||
}
|
||||
if path, err := exec.LookPath("gh"); err == nil {
|
||||
ghTool.Available = true
|
||||
ghTool.MatchedPath = path
|
||||
}
|
||||
return []dto.ToolStatusDTO{ghTool}
|
||||
}
|
||||
|
||||
// detectAgents runs discovery and forces mock-agent available when enabled.
|
||||
func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) {
|
||||
results, err := c.discovery.Detect(ctx)
|
||||
|
||||
@@ -98,9 +98,21 @@ type PassthroughConfigDTO struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ToolStatusDTO struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Available bool `json:"available"`
|
||||
MatchedPath string `json:"matched_path,omitempty"`
|
||||
InstallScript string `json:"install_script,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InfoURL string `json:"info_url,omitempty"`
|
||||
}
|
||||
|
||||
type AvailableAgentDTO struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InstallScript string `json:"install_script,omitempty"`
|
||||
SupportsMCP bool `json:"supports_mcp"`
|
||||
MCPConfigPath string `json:"mcp_config_path,omitempty"`
|
||||
InstallationPaths []string `json:"installation_paths,omitempty"`
|
||||
@@ -115,6 +127,7 @@ type AvailableAgentDTO struct {
|
||||
|
||||
type ListAvailableAgentsResponse struct {
|
||||
Agents []AvailableAgentDTO `json:"agents"`
|
||||
Tools []ToolStatusDTO `json:"tools,omitempty"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast updated available agents list
|
||||
// Broadcast updated available agents list and profile created event
|
||||
if h.hub != nil {
|
||||
availableResp, availErr := h.controller.ListAvailableAgents(c.Request.Context())
|
||||
if availErr != nil {
|
||||
@@ -62,6 +62,13 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { IconAlertTriangle, IconPlus, IconSettings } from "@tabler/icons-react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconClipboard,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
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";
|
||||
@@ -18,7 +26,63 @@ import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"
|
||||
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";
|
||||
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
import type {
|
||||
AgentDiscovery,
|
||||
Agent,
|
||||
AvailableAgent,
|
||||
AgentProfile,
|
||||
ToolStatus,
|
||||
} from "@/lib/types/http";
|
||||
|
||||
function useCopyCommand() {
|
||||
const [copiedValue, setCopiedValue] = useState<string | null>(null);
|
||||
const copy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopiedValue(text);
|
||||
setTimeout(() => setCopiedValue(null), 2000);
|
||||
}, []);
|
||||
return { copiedValue, copy };
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
text,
|
||||
copiedValue,
|
||||
onCopy,
|
||||
}: {
|
||||
text: string;
|
||||
copiedValue: string | null;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
const isCopied = copiedValue === text;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 cursor-pointer shrink-0"
|
||||
aria-label={isCopied ? "Copied" : "Copy install command"}
|
||||
onClick={() => onCopy(text)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<IconClipboard className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type AgentCardProps = {
|
||||
agent: AgentDiscovery;
|
||||
@@ -60,6 +124,81 @@ function AgentCard({ agent, savedAgent, displayName }: AgentCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function InstallCard({
|
||||
agent,
|
||||
copiedValue,
|
||||
onCopy,
|
||||
}: {
|
||||
agent: AvailableAgent;
|
||||
copiedValue: string | null;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentLogo agentName={agent.name} size={20} className="shrink-0" />
|
||||
<h4 className="font-medium">{agent.display_name}</h4>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{agent.description}</p>
|
||||
)}
|
||||
{agent.install_script && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
<code className="flex-1 truncate">{agent.install_script}</code>
|
||||
<CopyButton text={agent.install_script} copiedValue={copiedValue} onCopy={onCopy} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolInstallCard({
|
||||
tool,
|
||||
copiedValue,
|
||||
onCopy,
|
||||
}: {
|
||||
tool: ToolStatus;
|
||||
copiedValue: string | null;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconDownload className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<h4 className="font-medium">{tool.display_name}</h4>
|
||||
{tool.available && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && <p className="text-xs text-muted-foreground">{tool.description}</p>}
|
||||
{!tool.available && tool.install_script && (
|
||||
<div className="flex items-center gap-1 rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
<code className="flex-1 truncate">{tool.install_script}</code>
|
||||
<CopyButton text={tool.install_script} copiedValue={copiedValue} onCopy={onCopy} />
|
||||
</div>
|
||||
)}
|
||||
{tool.info_url && (
|
||||
<a
|
||||
href={tool.info_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<IconExternalLink className="h-3 w-3" />
|
||||
{tool.info_url}
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileListItemProps = {
|
||||
agent: Agent;
|
||||
profile: AgentProfile;
|
||||
@@ -85,7 +224,7 @@ function ProfileListItem({ agent, profile }: ProfileListItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type SupportedAgentsSectionProps = {
|
||||
type InstalledAgentsSectionProps = {
|
||||
installedAgents: AgentDiscovery[];
|
||||
discoveryLoading: boolean;
|
||||
rescanning: boolean;
|
||||
@@ -95,7 +234,7 @@ type SupportedAgentsSectionProps = {
|
||||
handleRescan: () => Promise<void>;
|
||||
};
|
||||
|
||||
function SupportedAgentsSection({
|
||||
function InstalledAgentsSection({
|
||||
installedAgents,
|
||||
discoveryLoading,
|
||||
rescanning,
|
||||
@@ -103,12 +242,12 @@ function SupportedAgentsSection({
|
||||
resolveDisplayName,
|
||||
setTuiDialogOpen,
|
||||
handleRescan,
|
||||
}: SupportedAgentsSectionProps) {
|
||||
}: InstalledAgentsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Supported agents found</h3>
|
||||
<h3 className="text-lg font-semibold">Installed Agents</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agents detected on this machine are ready to configure.
|
||||
</p>
|
||||
@@ -144,7 +283,7 @@ function SupportedAgentsSection({
|
||||
) : (
|
||||
<>
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
No installed agents were detected yet.
|
||||
No installed agents were detected. Install one below, then click Rescan.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -166,6 +305,42 @@ function SupportedAgentsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestInstallSection({
|
||||
notInstalledAgents,
|
||||
tools,
|
||||
copiedValue,
|
||||
onCopy,
|
||||
}: {
|
||||
notInstalledAgents: AvailableAgent[];
|
||||
tools: ToolStatus[];
|
||||
copiedValue: string | null;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
const notInstalledTools = tools.filter((t) => !t.available);
|
||||
if (notInstalledAgents.length === 0 && notInstalledTools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Available to Install</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Install an agent CLI globally, then log in with the agent and click Rescan above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{notInstalledAgents.map((agent) => (
|
||||
<InstallCard key={agent.name} agent={agent} copiedValue={copiedValue} onCopy={onCopy} />
|
||||
))}
|
||||
{notInstalledTools.map((tool) => (
|
||||
<ToolInstallCard key={tool.name} tool={tool} copiedValue={copiedValue} onCopy={onCopy} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AgentProfilesSectionProps = {
|
||||
savedAgents: Agent[];
|
||||
};
|
||||
@@ -200,7 +375,8 @@ function useAgentPageState() {
|
||||
const setAgentDiscovery = useAppStore((state) => state.setAgentDiscovery);
|
||||
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
|
||||
const setAvailableAgents = useAppStore((state) => state.setAvailableAgents);
|
||||
const availableAgents = useAvailableAgents().items;
|
||||
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
|
||||
const { items: availableAgents, tools } = useAvailableAgents();
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
const [tuiDialogOpen, setTuiDialogOpen] = useState(false);
|
||||
|
||||
@@ -208,6 +384,10 @@ function useAgentPageState() {
|
||||
() => discoveryAgents.filter((agent: AgentDiscovery) => agent.available),
|
||||
[discoveryAgents],
|
||||
);
|
||||
const notInstalledAgents = useMemo(
|
||||
() => availableAgents.filter((a: AvailableAgent) => !a.available && a.install_script),
|
||||
[availableAgents],
|
||||
);
|
||||
const savedAgentsByName = useMemo(
|
||||
() => new Map(savedAgents.map((agent: Agent) => [agent.name, agent])),
|
||||
[savedAgents],
|
||||
@@ -219,8 +399,12 @@ function useAgentPageState() {
|
||||
if (rescanning) return;
|
||||
setRescanning(true);
|
||||
try {
|
||||
const response = await listAgentDiscovery({ cache: "no-store" });
|
||||
setAgentDiscovery(response.agents);
|
||||
const [discoveryResp, availableResp] = await Promise.all([
|
||||
listAgentDiscovery({ cache: "no-store" }),
|
||||
listAvailableAgents({ cache: "no-store" }),
|
||||
]);
|
||||
setAgentDiscovery(discoveryResp.agents);
|
||||
setAvailableAgents(availableResp.agents, availableResp.tools ?? []);
|
||||
} finally {
|
||||
setRescanning(false);
|
||||
}
|
||||
@@ -239,12 +423,19 @@ function useAgentPageState() {
|
||||
]);
|
||||
setAgentDiscovery(discoveryResp.agents);
|
||||
setSettingsAgents(agentsResp.agents);
|
||||
setAvailableAgents(availableResp.agents);
|
||||
setAgentProfiles(
|
||||
agentsResp.agents.flatMap((agent) =>
|
||||
agent.profiles.map((profile) => toAgentProfileOption(agent, profile)),
|
||||
),
|
||||
);
|
||||
setAvailableAgents(availableResp.agents, availableResp.tools ?? []);
|
||||
};
|
||||
|
||||
return {
|
||||
savedAgents,
|
||||
installedAgents,
|
||||
notInstalledAgents,
|
||||
tools,
|
||||
savedAgentsByName,
|
||||
discoveryLoading,
|
||||
rescanning,
|
||||
@@ -260,6 +451,8 @@ export default function AgentsSettingsPage() {
|
||||
const {
|
||||
savedAgents,
|
||||
installedAgents,
|
||||
notInstalledAgents,
|
||||
tools,
|
||||
savedAgentsByName,
|
||||
discoveryLoading,
|
||||
rescanning,
|
||||
@@ -269,19 +462,20 @@ export default function AgentsSettingsPage() {
|
||||
handleRescan,
|
||||
handleCreateCustomTUI,
|
||||
} = useAgentPageState();
|
||||
const { copiedValue, copy } = useCopyCommand();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Agents</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Discover installed agents and continue setup from their configuration page.
|
||||
Discover installed agents, install new ones, and manage their profiles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SupportedAgentsSection
|
||||
<InstalledAgentsSection
|
||||
installedAgents={installedAgents}
|
||||
discoveryLoading={discoveryLoading}
|
||||
rescanning={rescanning}
|
||||
@@ -291,6 +485,13 @@ export default function AgentsSettingsPage() {
|
||||
handleRescan={handleRescan}
|
||||
/>
|
||||
|
||||
<SuggestInstallSection
|
||||
notInstalledAgents={notInstalledAgents}
|
||||
tools={tools}
|
||||
copiedValue={copiedValue}
|
||||
onCopy={copy}
|
||||
/>
|
||||
|
||||
<AgentProfilesSection savedAgents={savedAgents} />
|
||||
|
||||
<AddTUIAgentDialog
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
IconBrandDocker,
|
||||
IconX,
|
||||
IconLoader2,
|
||||
IconChevronDown,
|
||||
IconCommand,
|
||||
IconSearch,
|
||||
IconHome,
|
||||
@@ -28,26 +27,19 @@ import {
|
||||
IconArrowDown,
|
||||
IconCloud,
|
||||
} from "@tabler/icons-react";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible";
|
||||
import { Kbd } from "@kandev/ui/kbd";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
import { type ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
import { profileToPermissionsMap, permissionsToProfilePatch } from "@/lib/agent-permissions";
|
||||
import { listAvailableAgents, listWorkflowTemplates } from "@/lib/api";
|
||||
import { listAgentsAction, updateAgentProfileAction } from "@/app/actions/agents";
|
||||
import type { AvailableAgent, WorkflowTemplate, AgentProfile } from "@/lib/types/http";
|
||||
import { StepAgents, type AgentSetting } from "@/components/onboarding/step-agents";
|
||||
import type { AvailableAgent, ToolStatus, WorkflowTemplate, AgentProfile } from "@/lib/types/http";
|
||||
|
||||
interface OnboardingDialogProps {
|
||||
open: boolean;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
type AgentSetting = {
|
||||
profileId: string;
|
||||
formData: ProfileFormData;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
|
||||
const RUNTIMES = [
|
||||
@@ -76,7 +68,7 @@ const RUNTIMES = [
|
||||
|
||||
const STEP_TITLES = ["AI Agents", "Executors", "Agentic Workflows", "Command Panel"];
|
||||
const STEP_DESCRIPTIONS = [
|
||||
"These AI coding agents were discovered on your system.",
|
||||
"Manage discovered agents and install new ones.",
|
||||
"Agents can run in different executor environments — local, containerized, or remote.",
|
||||
"Workflows define the steps and automation for your tasks.",
|
||||
"Quick access to actions from anywhere with a keyboard shortcut.",
|
||||
@@ -143,6 +135,7 @@ function OnboardingStepDots({ step }: { step: number }) {
|
||||
|
||||
function useOnboardingResources(open: boolean) {
|
||||
const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([]);
|
||||
const [tools, setTools] = useState<ToolStatus[]>([]);
|
||||
const [agentSettings, setAgentSettings] = useState<Record<string, AgentSetting>>({});
|
||||
const [templates, setTemplates] = useState<WorkflowTemplate[]>([]);
|
||||
const [loadingAgents, setLoadingAgents] = useState(true);
|
||||
@@ -161,6 +154,7 @@ function useOnboardingResources(open: boolean) {
|
||||
Promise.all([listAvailableAgents({ cache: "no-store" }), listAgentsAction()])
|
||||
.then(([availRes, savedRes]) => {
|
||||
setAvailableAgents(availRes.agents ?? []);
|
||||
setTools(availRes.tools ?? []);
|
||||
setAgentSettings(buildAgentSettings(availRes.agents ?? [], savedRes.agents ?? []));
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -173,6 +167,7 @@ function useOnboardingResources(open: boolean) {
|
||||
|
||||
return {
|
||||
availableAgents,
|
||||
tools,
|
||||
agentSettings,
|
||||
setAgentSettings,
|
||||
templates,
|
||||
@@ -217,6 +212,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const {
|
||||
availableAgents,
|
||||
tools,
|
||||
agentSettings,
|
||||
setAgentSettings,
|
||||
templates,
|
||||
@@ -276,6 +272,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
{step === 0 && (
|
||||
<StepAgents
|
||||
availableAgents={availableAgents}
|
||||
tools={tools}
|
||||
agentSettings={agentSettings}
|
||||
loading={loadingAgents}
|
||||
onUpdateSetting={updateSetting}
|
||||
@@ -298,97 +295,6 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function StepAgents({
|
||||
availableAgents,
|
||||
agentSettings,
|
||||
loading,
|
||||
onUpdateSetting,
|
||||
}: {
|
||||
availableAgents: AvailableAgent[];
|
||||
agentSettings: Record<string, AgentSetting>;
|
||||
loading: boolean;
|
||||
onUpdateSetting: (agentName: string, formPatch: Partial<ProfileFormData>) => void;
|
||||
}) {
|
||||
const [openAgent, setOpenAgent] = useState<string | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-sm text-muted-foreground">
|
||||
<IconLoader2 className="h-6 w-6 animate-spin" />
|
||||
Discovering agents...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agents = availableAgents.filter((a) => a.available);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{agents.map((agent) => {
|
||||
const settings = agentSettings[agent.name];
|
||||
const currentModel = settings?.formData.model || agent.model_config.default_model;
|
||||
const modelName =
|
||||
agent.model_config.available_models.find((m) => m.id === currentModel)?.name ??
|
||||
currentModel;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={agent.name}
|
||||
open={openAgent === agent.name}
|
||||
onOpenChange={(isOpen) => setOpenAgent(isOpen ? agent.name : null)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-lg border p-2 text-left cursor-pointer hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<AgentLogo agentName={agent.name} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{agent.display_name}</p>
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium truncate max-w-[120px]">
|
||||
{modelName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
Installed
|
||||
</span>
|
||||
<IconChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border border-t-0 rounded-b-lg px-3 pb-3 pt-2">
|
||||
{settings && (
|
||||
<ProfileFormFields
|
||||
variant="compact"
|
||||
hideNameField
|
||||
profile={settings.formData}
|
||||
onChange={(patch) => onUpdateSetting(agent.name, patch)}
|
||||
modelConfig={agent.model_config}
|
||||
permissionSettings={agent.permission_settings ?? {}}
|
||||
passthroughConfig={agent.passthrough_config ?? null}
|
||||
agentName={agent.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-yellow-500 font-medium">Careful:</span> The default Agent Profiles run
|
||||
with with Auto Approve enabled (YOLO mode).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepEnvironments() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
IconCheck,
|
||||
IconClipboard,
|
||||
IconChevronDown,
|
||||
IconLoader2,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
} from "@tabler/icons-react";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
|
||||
|
||||
export type AgentSetting = {
|
||||
profileId: string;
|
||||
formData: ProfileFormData;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="p-0.5 cursor-pointer hover:text-foreground text-muted-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<IconClipboard className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledAgentRow({
|
||||
agent,
|
||||
settings,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onUpdateSetting,
|
||||
}: {
|
||||
agent: AvailableAgent;
|
||||
settings: AgentSetting | undefined;
|
||||
isOpen: boolean;
|
||||
onToggle: (open: boolean) => void;
|
||||
onUpdateSetting: (agentName: string, formPatch: Partial<ProfileFormData>) => void;
|
||||
}) {
|
||||
const currentModel = settings?.formData.model || agent.model_config.default_model;
|
||||
const modelName =
|
||||
agent.model_config.available_models.find((m) => m.id === currentModel)?.name ?? currentModel;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={onToggle}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-lg border p-2 text-left cursor-pointer hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<AgentLogo agentName={agent.name} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{agent.display_name}</p>
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium truncate max-w-[120px]">
|
||||
{modelName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
Installed
|
||||
</span>
|
||||
<IconChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border border-t-0 rounded-b-lg px-3 pb-3 pt-2">
|
||||
{settings && (
|
||||
<ProfileFormFields
|
||||
variant="compact"
|
||||
hideNameField
|
||||
profile={settings.formData}
|
||||
onChange={(patch) => onUpdateSetting(agent.name, patch)}
|
||||
modelConfig={agent.model_config}
|
||||
permissionSettings={agent.permission_settings ?? {}}
|
||||
passthroughConfig={agent.passthrough_config ?? null}
|
||||
agentName={agent.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function NotInstalledItems({
|
||||
agents,
|
||||
tools,
|
||||
showLabel,
|
||||
}: {
|
||||
agents: AvailableAgent[];
|
||||
tools: ToolStatus[];
|
||||
showLabel: boolean;
|
||||
}) {
|
||||
if (agents.length === 0 && tools.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{showLabel && <p className="text-xs text-muted-foreground mt-1">Not yet installed</p>}
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.name}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-dashed p-2"
|
||||
>
|
||||
<AgentLogo agentName={agent.name} size={28} className="opacity-60" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-muted-foreground">{agent.display_name}</p>
|
||||
{agent.install_script && (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<code className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{agent.install_script}
|
||||
</code>
|
||||
<CopyButton text={agent.install_script} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IconDownload className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
))}
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-dashed p-2"
|
||||
>
|
||||
<IconDownload className="h-7 w-7 p-1 text-muted-foreground opacity-60 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-muted-foreground">{tool.display_name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{tool.description}</p>
|
||||
{tool.install_script && (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<code className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{tool.install_script}
|
||||
</code>
|
||||
<CopyButton text={tool.install_script} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tool.info_url && (
|
||||
<a
|
||||
href={tool.info_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepAgents({
|
||||
availableAgents,
|
||||
tools,
|
||||
agentSettings,
|
||||
loading,
|
||||
onUpdateSetting,
|
||||
}: {
|
||||
availableAgents: AvailableAgent[];
|
||||
tools: ToolStatus[];
|
||||
agentSettings: Record<string, AgentSetting>;
|
||||
loading: boolean;
|
||||
onUpdateSetting: (agentName: string, formPatch: Partial<ProfileFormData>) => void;
|
||||
}) {
|
||||
const [openAgent, setOpenAgent] = useState<string | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-sm text-muted-foreground">
|
||||
<IconLoader2 className="h-6 w-6 animate-spin" />
|
||||
Discovering agents...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const installedAgents = availableAgents.filter((a) => a.available);
|
||||
const notInstalledAgents = availableAgents.filter((a) => !a.available && a.install_script);
|
||||
const notInstalledTools = tools.filter((t) => !t.available);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{installedAgents.map((agent) => (
|
||||
<InstalledAgentRow
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
settings={agentSettings[agent.name]}
|
||||
isOpen={openAgent === agent.name}
|
||||
onToggle={(isOpen) => setOpenAgent(isOpen ? agent.name : null)}
|
||||
onUpdateSetting={onUpdateSetting}
|
||||
/>
|
||||
))}
|
||||
<NotInstalledItems
|
||||
agents={notInstalledAgents}
|
||||
tools={notInstalledTools}
|
||||
showLabel={installedAgents.length > 0}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="text-yellow-500 font-medium">Careful:</span> The default Agent Profiles run
|
||||
with Auto Approve enabled (YOLO mode).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function useAvailableAgents(enabled = true) {
|
||||
setAvailableAgentsLoading(true);
|
||||
listAvailableAgents({ cache: "no-store" })
|
||||
.then((response) => {
|
||||
setAvailableAgents(response.agents);
|
||||
setAvailableAgents(response.agents, response.tools ?? []);
|
||||
})
|
||||
.catch(() => setAvailableAgents([]));
|
||||
}, [
|
||||
|
||||
@@ -5,7 +5,7 @@ export const defaultSettingsState: SettingsSliceState = {
|
||||
executors: { items: [] },
|
||||
settingsAgents: { items: [] },
|
||||
agentDiscovery: { items: [], loading: false, loaded: false },
|
||||
availableAgents: { items: [], loading: false, loaded: false },
|
||||
availableAgents: { items: [], tools: [], loading: false, loaded: false },
|
||||
agentProfiles: { items: [], version: 0 },
|
||||
editors: { items: [], loaded: false, loading: false },
|
||||
prompts: { items: [], loaded: false, loading: false },
|
||||
@@ -84,9 +84,10 @@ function createCoreActions(
|
||||
set((draft) => {
|
||||
draft.agentDiscovery.loading = loading;
|
||||
}),
|
||||
setAvailableAgents: (agents) =>
|
||||
setAvailableAgents: (agents, tools) =>
|
||||
set((draft) => {
|
||||
draft.availableAgents.items = agents;
|
||||
if (tools) draft.availableAgents.tools = tools;
|
||||
draft.availableAgents.loading = false;
|
||||
draft.availableAgents.loaded = true;
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Executor,
|
||||
NotificationProvider,
|
||||
SavedLayout,
|
||||
ToolStatus,
|
||||
} from "@/lib/types/http";
|
||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||
import type { SpritesStatus, SpritesInstance } from "@/lib/types/http-sprites";
|
||||
@@ -28,6 +29,7 @@ export type AgentDiscoveryState = {
|
||||
|
||||
export type AvailableAgentsState = {
|
||||
items: AvailableAgent[];
|
||||
tools: ToolStatus[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
};
|
||||
@@ -139,7 +141,10 @@ export type SettingsSliceActions = {
|
||||
setSettingsAgents: (agents: SettingsAgentsState["items"]) => void;
|
||||
setAgentDiscovery: (agents: AgentDiscoveryState["items"]) => void;
|
||||
setAgentDiscoveryLoading: (loading: boolean) => void;
|
||||
setAvailableAgents: (agents: AvailableAgentsState["items"]) => void;
|
||||
setAvailableAgents: (
|
||||
agents: AvailableAgentsState["items"],
|
||||
tools?: AvailableAgentsState["tools"],
|
||||
) => void;
|
||||
setAvailableAgentsLoading: (loading: boolean) => void;
|
||||
setAgentProfiles: (profiles: AgentProfilesState["items"]) => void;
|
||||
setEditors: (editors: EditorsState["items"]) => void;
|
||||
|
||||
@@ -229,7 +229,10 @@ export type AppState = {
|
||||
setSettingsAgents: (agents: SettingsAgentsState["items"]) => void;
|
||||
setAgentDiscovery: (agents: AgentDiscoveryState["items"]) => void;
|
||||
setAgentDiscoveryLoading: (loading: boolean) => void;
|
||||
setAvailableAgents: (agents: AvailableAgentsState["items"]) => void;
|
||||
setAvailableAgents: (
|
||||
agents: AvailableAgentsState["items"],
|
||||
tools?: AvailableAgentsState["tools"],
|
||||
) => void;
|
||||
setAvailableAgentsLoading: (loading: boolean) => void;
|
||||
setAgentProfiles: (profiles: AgentProfilesState["items"]) => void;
|
||||
setRepositories: (workspaceId: string, repositories: Repository[]) => void;
|
||||
|
||||
@@ -66,7 +66,13 @@ export type BackendMessage<T extends BackendMessageType, P> = {
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
import type { AvailableAgent, SavedLayout, StepEvents, TaskState } from "@/lib/types/http";
|
||||
import type {
|
||||
AvailableAgent,
|
||||
SavedLayout,
|
||||
StepEvents,
|
||||
TaskState,
|
||||
ToolStatus,
|
||||
} from "@/lib/types/http";
|
||||
import type { SecretListItem } from "@/lib/types/http-secrets";
|
||||
import type { GitEventPayload } from "@/lib/types/git-events";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
@@ -120,6 +126,7 @@ export type AgentUpdatePayload = {
|
||||
|
||||
export type AgentAvailableUpdatedPayload = {
|
||||
agents: AvailableAgent[];
|
||||
tools?: ToolStatus[];
|
||||
};
|
||||
|
||||
export type TerminalOutputPayload = {
|
||||
|
||||
@@ -111,9 +111,21 @@ export type PassthroughConfig = {
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ToolStatus = {
|
||||
name: string;
|
||||
display_name: string;
|
||||
available: boolean;
|
||||
matched_path?: string;
|
||||
install_script?: string;
|
||||
description?: string;
|
||||
info_url?: string;
|
||||
};
|
||||
|
||||
export type AvailableAgent = {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
install_script?: string;
|
||||
supports_mcp: boolean;
|
||||
mcp_config_path?: string | null;
|
||||
installation_paths: string[];
|
||||
@@ -138,6 +150,7 @@ export type ListAgentDiscoveryResponse = {
|
||||
|
||||
export type ListAvailableAgentsResponse = {
|
||||
agents: AvailableAgent[];
|
||||
tools?: ToolStatus[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -86,7 +86,12 @@ export function registerAgentsHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
"agent.available.updated": (message) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
availableAgents: { items: message.payload.agents ?? [], loaded: true, loading: false },
|
||||
availableAgents: {
|
||||
items: message.payload.agents ?? [],
|
||||
tools: message.payload.tools ?? state.availableAgents.tools,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
"agent.updated": (message) => {
|
||||
|
||||
Reference in New Issue
Block a user