Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 97 additions & 10 deletions src/features/ai/components/icons/provider-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ import type { SVGProps } from "react";
import { cn } from "@/utils/cn";

type IconProps = SVGProps<SVGSVGElement> & { size?: number };
export type ProviderIconKind =
| "openai"
| "v0"
| "anthropic"
| "gemini"
| "xai"
| "deepseek"
| "mistral"
| "ollama"
| "openrouter"
| "moonshot"
| "qwen"
| "custom";

const defaultProps = (size = 14, className?: string): SVGProps<SVGSVGElement> => ({
width: size,
Expand Down Expand Up @@ -139,31 +152,49 @@ export function CustomAPIIcon({ size, className, ...props }: IconProps) {

export function ProviderIcon({
providerId,
catalogIconUrl,
size = 14,
className,
}: {
providerId: string;
catalogIconUrl?: string | null;
size?: number;
className?: string;
}) {
const props = { size, className: cn("shrink-0", className) };
const resolvedCatalogIconUrl = resolveCatalogIconUrl(catalogIconUrl);

switch (providerId) {
if (resolvedCatalogIconUrl) {
return (
<span
aria-hidden="true"
className={cn("inline-block shrink-0 bg-current", className)}
style={{
width: size,
height: size,
maskImage: `url("${resolvedCatalogIconUrl}")`,
maskPosition: "center",
maskRepeat: "no-repeat",
maskSize: "contain",
WebkitMaskImage: `url("${resolvedCatalogIconUrl}")`,
WebkitMaskPosition: "center",
WebkitMaskRepeat: "no-repeat",
WebkitMaskSize: "contain",
}}
/>
);
}

switch (resolveProviderIconKind(providerId)) {
case "openai":
case "codex-cli":
return <OpenAIIcon {...props} />;
case "v0":
return <V0Icon {...props} />;
case "anthropic":
case "claude-code":
return <AnthropicIcon {...props} />;
case "gemini":
case "google":
case "gemini-cli":
return <GeminiIcon {...props} />;
case "grok":
case "xai":
case "x-ai":
return <XAIIcon {...props} />;
case "deepseek":
return <DeepSeekIcon {...props} />;
Expand All @@ -173,15 +204,71 @@ export function ProviderIcon({
return <OllamaIcon {...props} />;
case "openrouter":
return <OpenRouterIcon {...props} />;
case "kimi-cli":
case "moonshot":
return <MoonshotIcon {...props} />;
case "qwen":
case "qwen-code":
return <QwenIcon {...props} />;
case "opencode":
case "custom":
return <CustomAPIIcon {...props} />;
default:
return <CustomAPIIcon {...props} />;
}
}

export function resolveCatalogIconUrl(iconUrl?: string | null): string | null {
if (!iconUrl) return null;

const trimmed = iconUrl.trim();
if (!trimmed) return null;

try {
const url = new URL(trimmed);
return url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}

export function resolveProviderIconKind(providerId: string): ProviderIconKind {
const normalizedId = providerId.toLowerCase();

switch (normalizedId) {
case "openai":
case "codex-cli":
case "codex-acp":
return "openai";
case "v0":
return "v0";
case "anthropic":
case "claude-code":
case "claude-acp":
return "anthropic";
case "gemini":
case "google":
case "gemini-cli":
return "gemini";
case "grok":
case "xai":
case "x-ai":
return "xai";
case "deepseek":
return "deepseek";
case "mistral":
case "mistral-vibe":
return "mistral";
case "ollama":
return "ollama";
case "openrouter":
return "openrouter";
case "kimi":
case "kimi-cli":
return "moonshot";
case "qwen":
case "qwen-code":
return "qwen";
case "opencode":
case "custom":
default:
return "custom";
}
}
17 changes: 15 additions & 2 deletions src/features/ai/components/selectors/agent-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const ATHAS_AGENT_OPTION = {
name: "Athas Agent",
description: "Use Athas chat settings and provider configuration",
isAcp: false,
icon: null,
};

interface AgentSelectorProps {
Expand Down Expand Up @@ -97,6 +98,7 @@ export function AgentSelector({
isCurrent?: boolean;
canInstall?: boolean;
isInstalling?: boolean;
icon?: string | null;
}> = [];

const searchLower = search.toLowerCase();
Expand Down Expand Up @@ -124,6 +126,7 @@ export function AgentSelector({
isCurrent: agent.id === currentAgentId,
canInstall: agent.id === "custom" ? false : (agentConfig?.canInstall ?? true),
isInstalling: installingAgentId === agent.id,
icon: agentConfig?.icon ?? agent.icon,
});
}

Expand Down Expand Up @@ -284,7 +287,12 @@ export function AgentSelector({
compact
className="ui-font flex h-8 max-w-[min(220px,100%)] items-center gap-1.5 rounded-full border border-border bg-secondary-bg/80 px-3 text-xs transition-colors hover:bg-hover"
>
<ProviderIcon providerId={currentAgentId} size={11} className="text-text-lighter" />
<ProviderIcon
providerId={currentAgentId}
catalogIconUrl={currentAgent.icon}
size={11}
className="text-text-lighter"
/>
<span className="max-w-[140px] truncate text-text">{currentAgent?.name || "Agent"}</span>
<ChevronDown
className={cn("text-text-lighter transition-transform", isOpen && "rotate-180")}
Expand Down Expand Up @@ -349,7 +357,12 @@ export function AgentSelector({
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<ProviderIcon providerId={item.id} size={12} className="text-text-lighter" />
<ProviderIcon
providerId={item.id}
catalogIconUrl={item.icon}
size={12}
className="text-text-lighter"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-left text-text text-xs leading-4">
{item.name}
Expand Down
35 changes: 35 additions & 0 deletions src/features/ai/tests/provider-icons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vite-plus/test";
import { resolveCatalogIconUrl, resolveProviderIconKind } from "../components/icons/provider-icons";

describe("resolveProviderIconKind", () => {
it("recognizes legacy and registry ACP provider ids", () => {
expect(resolveProviderIconKind("codex-cli")).toBe("openai");
expect(resolveProviderIconKind("codex-acp")).toBe("openai");
expect(resolveProviderIconKind("claude-code")).toBe("anthropic");
expect(resolveProviderIconKind("claude-acp")).toBe("anthropic");
expect(resolveProviderIconKind("gemini-cli")).toBe("gemini");
expect(resolveProviderIconKind("gemini")).toBe("gemini");
expect(resolveProviderIconKind("kimi-cli")).toBe("moonshot");
expect(resolveProviderIconKind("kimi")).toBe("moonshot");
expect(resolveProviderIconKind("qwen-code")).toBe("qwen");
expect(resolveProviderIconKind("mistral-vibe")).toBe("mistral");
});

it("falls back to the custom terminal glyph for unknown agent ids", () => {
expect(resolveProviderIconKind("local-agent")).toBe("custom");
});
});

describe("resolveCatalogIconUrl", () => {
it("accepts secure catalog icon urls", () => {
expect(
resolveCatalogIconUrl("https://cdn.agentclientprotocol.com/registry/v1/latest/amp-acp.svg"),
).toBe("https://cdn.agentclientprotocol.com/registry/v1/latest/amp-acp.svg");
});

it("rejects empty and non-https catalog icon urls", () => {
expect(resolveCatalogIconUrl("")).toBeNull();
expect(resolveCatalogIconUrl("http://example.com/icon.svg")).toBeNull();
expect(resolveCatalogIconUrl("javascript:alert(1)")).toBeNull();
});
});
12 changes: 12 additions & 0 deletions src/features/settings/components/tabs/extensions-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
resetSkillLocalOverride,
updateSkillFromMarketplace,
} from "@/features/ai/lib/skill-library";
import { ProviderIcon } from "@/features/ai/components/icons/provider-icons";
import type { AgentConfig } from "@/features/ai/types/acp";
import type { AIChatSkill, MarketplaceSkill } from "@/features/ai/types/skills";
import { extensionManager } from "@/features/editor/extensions/manager";
Expand All @@ -52,6 +53,7 @@ interface UnifiedExtension {
skill?: AIChatSkill;
marketplaceSkill?: MarketplaceSkill;
agentId?: string;
icon?: string | null;
canInstall?: boolean;
packageSize?: number;
contributionSummary?: string[];
Expand Down Expand Up @@ -149,6 +151,14 @@ const ExtensionRow = ({
<div className="flex items-center justify-between gap-4 border-b border-border px-1 py-3 transition-colors hover:bg-hover">
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
{extension.category === "agent" ? (
<ProviderIcon
providerId={extension.agentId ?? extension.id}
catalogIconUrl={extension.icon}
size={14}
className="text-text-lighter"
/>
) : null}
<span className="ui-font ui-text-md text-text">{extension.name}</span>
<Badge variant="default" size="compact" shape="pill">
{getCategoryLabel(extension.category)}
Expand Down Expand Up @@ -302,6 +312,7 @@ export const ExtensionsSettings = () => {
isBundled: false,
runtimeIssues: ext.runtimeIssues,
agentId: contribution.id,
icon: agent?.icon ?? contribution.icon,
canInstall: agent?.canInstall ?? Boolean(contribution.install),
contributionSummary: [
`agent:${contribution.id}`,
Expand Down Expand Up @@ -483,6 +494,7 @@ export const ExtensionsSettings = () => {
publisher: "Marketplace",
isMarketplace: true,
agentId: agent.id,
icon: agent.icon,
canInstall: agent.canInstall,
contributionSummary: [`agent:${agent.id}`, agent.binaryName],
});
Expand Down
Loading