feat: suggest agent install commands and fix TUI agent startup (#398)

This commit is contained in:
Carlos Florêncio
2026-03-09 21:54:30 +00:00
committed by GitHub
parent d6275c3e51
commit 3820feb819
14 changed files with 562 additions and 129 deletions
@@ -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)
+215 -14
View File
@@ -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
+9 -103
View File
@@ -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 &gt; 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 &gt; 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;
}),
+6 -1
View File
@@ -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;
+4 -1
View File
@@ -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;
+8 -1
View File
@@ -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 = {
+13
View File
@@ -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;
};
+6 -1
View File
@@ -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) => {