This commit is contained in:
Michael Han 2026-07-29 09:35:34 +00:00 committed by GitHub
commit 91c1314fe4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 428 additions and 410 deletions

View file

@ -84,9 +84,8 @@ Replace `claude` with any supported agent:
| Hermes Agent | `unsloth start hermes` |
| OpenClaw | `unsloth start openclaw` |
| OpenCode | `unsloth start opencode` |
| Pi Coding Agent | `unsloth start pi` |
Claude Code, Codex, OpenCode and Pi can keep their current model and use Unsloth as a local
Claude Code, Codex and OpenCode can keep their current model and use Unsloth as a local
subagent:
```bash
@ -204,7 +203,7 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad
## 🦥 Unsloth News
- **AMD training**: Train, run RL, chat and deploy on AMD GPUs across Windows, WSL and Linux. [Guide](https://unsloth.ai/docs/basics/amd)
- **GGUF hardware controls**: Choose GPU/layer placement, offload MoE experts and use multi-GPU or Tensor Parallelism. [#6414](https://github.com/unslothai/unsloth/pull/6414)
- **Local models for any agent**: Use `unsloth start` with Claude Code, Codex, Hermes, OpenCode, OpenClaw, Pi and more through Unsloth's OpenAI- and Anthropic-compatible APIs. [Guide](https://unsloth.ai/docs/basics/api)
- **Local models for any agent**: Use `unsloth start` with Claude Code, Codex, Hermes, OpenCode, OpenClaw and more through Unsloth's OpenAI- and Anthropic-compatible APIs. [Guide](https://unsloth.ai/docs/basics/api)
- **MCP control endpoint**: Let compatible clients manage models, training, recipes, checkpoints and exports. [#7191](https://github.com/unslothai/unsloth/pull/7191)
- **Local inference reliability**: Resume long chats faster, recover stalled downloads and reuse existing GGUF files. [#7204](https://github.com/unslothai/unsloth/pull/7204) • [#6858](https://github.com/unslothai/unsloth/pull/6858) • [#7209](https://github.com/unslothai/unsloth/pull/7209)
- **New models**: [Qwen-AgentWorld](https://huggingface.co/unsloth/Qwen-AgentWorld-35B-A3B-GGUF), [Ornith](https://huggingface.co/unsloth/models?search=ornith), [Kimi K2.7 Code](https://unsloth.ai/docs/models/kimi-k2.7-code) and [MiniMax M3](https://unsloth.ai/docs/models/minimax-m3)

View file

@ -1,21 +0,0 @@
<!-- Source: https://pi.dev/favicon.svg (official Pi press-kit badge) -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<rect width="800" height="800" rx="120" fill="#09090b"/>
<path fill="#fff" fill-rule="evenodd" d="
M165.29 165.29
H517.36
V400
H400
V517.36
H282.65
V634.72
H165.29
Z
M282.65 282.65
V400
H400
V282.65
Z
"/>
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

View file

@ -1454,7 +1454,7 @@ export function ChatProvidersSettings({
</div>
<section className="flex max-w-[760px] flex-col gap-2">
<div className="overflow-hidden rounded-[10px] border border-border/70 bg-muted/[0.12]">
<div className="overflow-hidden rounded-[14px] border border-border/70 bg-muted/[0.12]">
<button
type="button"
onClick={openAddProvider}

View file

@ -16,6 +16,10 @@ type ApiCodingAgentsInfo = {
detected: string[];
};
// Agents the CLI still supports but Studio does not list. Filtered here so
// every consumer of this endpoint sees the same set.
const HIDDEN_AGENTS = new Set(["pi"]);
// Which CLIs are on PATH is environment state, not a persisted setting -- it
// can change any time the user installs something new, so this only
// de-duplicates concurrent in-flight calls (e.g. React strict-mode's double
@ -24,7 +28,8 @@ type ApiCodingAgentsInfo = {
let inFlightInfo: Promise<CodingAgentsInfo> | null = null;
function fromApi(info: ApiCodingAgentsInfo): CodingAgentsInfo {
return { agents: info.agents, detected: info.detected };
const visible = (ids: string[]) => ids.filter((id) => !HIDDEN_AGENTS.has(id));
return { agents: visible(info.agents), detected: visible(info.detected) };
}
async function fetchCodingAgents(): Promise<CodingAgentsInfo> {

View file

@ -106,16 +106,15 @@ const DOC_LINKS = [
{ label: "Hermes Agent", href: "https://unsloth.ai/docs/integrations/hermes-agent" },
];
// Falls back to this list until the backend's installed-CLI check resolves;
// kept in sync with the `unsloth start <agent>` subcommands and with
// CODING_AGENTS in studio/backend/utils/coding_agents.py.
// Fallback until the backend's installed-CLI check resolves. Mirrors
// CODING_AGENTS in studio/backend/utils/coding_agents.py, minus HIDDEN_AGENTS
// (see ../api/coding-agents.ts).
const DEFAULT_AGENTS = [
"claude",
"codex",
"openclaw",
"opencode",
"hermes",
"pi",
];
// The agent selection resets to this whenever an auto-pick is no longer
// trustworthy (leaving loopback, or the only compatible detected agent
@ -127,7 +126,6 @@ const AGENT_LABELS: Record<string, string> = {
openclaw: "OpenClaw",
opencode: "OpenCode",
hermes: "Hermes",
pi: "Pi",
};
const j = (s: string): string => JSON.stringify(s);

View file

@ -41,11 +41,7 @@ import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { Tick02Icon } from "@/lib/tick-icon";
import { cn } from "@/lib/utils";
import {
ArrowUpRight01Icon,
Book03Icon,
Copy01Icon,
} from "@hugeicons/core-free-icons";
import { ArrowUpRight01Icon, Copy01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ApiProviderLogo } from "../../chat/api-provider-logo";
@ -66,7 +62,7 @@ const STATUS_POLL_MS = 5000;
const HUGGING_FACE_REPO_PATTERN = /^[^/\\:\s]+\/[^/\\:\s]+$/;
const SEARCH_TOKEN_PATTERN = /\s+/;
const SAFE_SHELL_ARG_PATTERN = /^[A-Za-z0-9_./:@%+=,-]+$/;
const SUBAGENT_AGENT_IDS = new Set(["claude", "codex", "opencode", "pi"]);
const SUBAGENT_AGENT_IDS = new Set(["claude", "codex", "opencode"]);
function isLoopbackBase(base: string): boolean {
try {
@ -165,12 +161,6 @@ const SUPPORTED_AGENTS: AgentDetails[] = [
icon: "opencode-light.svg",
darkIcon: "opencode-dark.svg",
},
{
id: "pi",
name: "Pi Coding Agent",
docsUrl: DOCS_URL,
icon: "pi.svg",
},
];
const FALLBACK_AGENT = SUPPORTED_AGENTS[0];
@ -382,8 +372,8 @@ function AgentIcon({
}) {
if (logo) {
return (
<span className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md">
<ApiProviderLogo providerType={logo} className="size-7 rounded-md" />
<span className="flex size-5 shrink-0 items-center justify-center overflow-hidden rounded">
<ApiProviderLogo providerType={logo} className="size-5 rounded" />
</span>
);
}
@ -393,13 +383,13 @@ function AgentIcon({
? `${import.meta.env.BASE_URL}agent-logos/${darkIcon}`
: null;
return (
<span className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md">
<span className="flex size-5 shrink-0 items-center justify-center overflow-hidden rounded">
<img
src={iconSrc}
alt=""
aria-hidden={true}
className={cn(
"size-7 object-contain",
"size-5 object-contain",
darkIconSrc && "dark:hidden",
invertIconInDark && "dark:invert",
)}
@ -409,7 +399,7 @@ function AgentIcon({
src={darkIconSrc}
alt=""
aria-hidden={true}
className="hidden size-7 object-contain dark:block"
className="hidden size-5 object-contain dark:block"
/>
) : null}
</span>
@ -419,7 +409,7 @@ function AgentIcon({
<span
aria-hidden={true}
style={{ backgroundColor: color }}
className="flex size-7 shrink-0 items-center justify-center rounded-md font-heading text-ui-11 font-semibold text-white"
className="flex size-5 shrink-0 items-center justify-center rounded font-heading text-ui-10 font-semibold text-white"
>
{mark}
</span>
@ -473,13 +463,59 @@ const PASSTHROUGH_EXAMPLES = [
const DRY_RUN_FLAGS = "--no-launch";
/** Code box with the copy control inside it, top-right. Presentational: the
* copy state stays with the caller so existing resets still apply. */
function CopyableCode({
value,
copyLabel,
copied,
onCopy,
breakAll = true,
}: {
value: string;
copyLabel: string;
copied: boolean;
onCopy: () => void;
breakAll?: boolean;
}) {
const t = useT();
return (
<div className="relative min-w-0">
<code
className={cn(
"block min-w-0 whitespace-pre-wrap rounded-lg border border-border bg-background/70 py-2.5 pr-9 pl-4 font-mono text-ui-11 leading-relaxed text-foreground dark:border-transparent dark:bg-white/[0.05]",
breakAll ? "break-all" : "break-words",
)}
>
{value}
</code>
<button
type="button"
onClick={onCopy}
aria-label={copyLabel}
className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className={cn("size-3.5", copied && "text-control-accent")}
strokeWidth={2}
/>
</button>
<output className="sr-only" aria-live="polite">
{copied ? t("settings.agents.copied") : ""}
</output>
</div>
);
}
function CommandBlock({ command }: { command: string }) {
const t = useT();
const { copied, copy } = useCopyButton(command);
return (
<div className="group relative">
<pre className="hover-scrollbar overflow-x-auto rounded-lg border border-border bg-muted/40 py-3 pl-3.5 pr-11 text-xs leading-relaxed text-foreground dark:bg-white/[0.04]">
<div className="group relative overflow-hidden rounded-xl border border-border bg-muted/40 dark:border-transparent dark:bg-white/[0.04]">
<pre className="hover-scrollbar overflow-x-auto py-3 pr-11 pl-4 text-xs leading-relaxed text-foreground">
<code className="font-mono whitespace-pre">{command}</code>
</pre>
<button
@ -535,7 +571,7 @@ function SubagentSection({
}
return (
<div className="flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-muted/10 p-3">
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-1">
<span
data-settings-label={t("settings.agents.subagent.title")}
@ -548,60 +584,29 @@ function SubagentSection({
</p>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-ui-11 font-medium text-foreground">
{t("settings.agents.subagent.setupCommand")}
</span>
<button
type="button"
onClick={commandCopy.copy}
aria-label={t("settings.agents.subagent.copySetupCommand")}
className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-ui-11 font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={commandCopy.copied ? Tick02Icon : Copy01Icon}
className={cn(
"size-3.5",
commandCopy.copied && "text-control-accent",
)}
/>
{commandCopy.copied
? t("settings.agents.copied")
: t("settings.agents.copy")}
</button>
</div>
<code className="block min-w-0 whitespace-pre-wrap break-all rounded-md border border-border bg-background/70 px-2.5 py-2 font-mono text-ui-11 leading-relaxed text-foreground">
{command}
</code>
<div className="flex min-w-0 flex-col gap-2">
<span className="text-ui-11 font-medium text-foreground">
{t("settings.agents.subagent.setupCommand")}
</span>
<CopyableCode
value={command}
copyLabel={t("settings.agents.subagent.copySetupCommand")}
copied={commandCopy.copied}
onCopy={commandCopy.copy}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-ui-11 font-medium text-foreground">
{t("settings.agents.subagent.usagePrompt", { agent: agent.name })}
</span>
<button
type="button"
onClick={promptCopy.copy}
aria-label={t("settings.agents.subagent.copyUsagePrompt")}
className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-ui-11 font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={promptCopy.copied ? Tick02Icon : Copy01Icon}
className={cn(
"size-3.5",
promptCopy.copied && "text-control-accent",
)}
/>
{promptCopy.copied
? t("settings.agents.copied")
: t("settings.agents.copy")}
</button>
</div>
<code className="block min-w-0 whitespace-pre-wrap break-words rounded-md border border-border bg-background/70 px-2.5 py-2 font-mono text-ui-11 leading-relaxed text-foreground">
{prompt}
</code>
<div className="flex min-w-0 flex-col gap-2">
<span className="text-ui-11 font-medium text-foreground">
{t("settings.agents.subagent.usagePrompt", { agent: agent.name })}
</span>
<CopyableCode
value={prompt}
copyLabel={t("settings.agents.subagent.copyUsagePrompt")}
copied={promptCopy.copied}
onCopy={promptCopy.copy}
breakAll={false}
/>
</div>
</div>
);
@ -665,7 +670,6 @@ export function AgentsTab() {
const [modelSearch, setModelSearch] = useState("");
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [variants, setVariants] = useState<GgufVariantDetail[]>([]);
const [defaultVariant, setDefaultVariant] = useState<string | null>(null);
const [selectedVariant, setSelectedVariant] = useState<string | null>(
EXAMPLE_MODEL_VARIANT,
);
@ -986,7 +990,6 @@ export function AgentsTab() {
return;
}
setVariants([]);
setDefaultVariant(null);
setSelectedVariant(standaloneFile ? null : preferredVariant);
setVariantsFailed(false);
setVariantsLoading(false);
@ -999,7 +1002,6 @@ export function AgentsTab() {
// A programmatic model change reaches here too, so clear the previous model's
// quants up front rather than leaving them selectable until this resolves.
setVariants([]);
setDefaultVariant(null);
setVariantsLoading(true);
// Offer the quants from the same place the command loads from, not remote-only ones.
listGgufVariants(selectedModel, hfToken || undefined, {
@ -1023,7 +1025,6 @@ export function AgentsTab() {
).values(),
);
setVariants(uniqueVariants);
setDefaultVariant(info.default_variant);
const available = new Set(
uniqueVariants.map((variant) => variant.quant),
);
@ -1045,7 +1046,6 @@ export function AgentsTab() {
}
setVariantsFailed(true);
setVariants([]);
setDefaultVariant(null);
setSelectedVariant(preferredVariant);
if (preferredVariant) {
setVariants([
@ -1073,7 +1073,7 @@ export function AgentsTab() {
// picker only ever offers GGUF models.
return (
<div className="flex min-w-0 max-w-full flex-col gap-6">
<div className="flex min-w-0 max-w-full flex-col gap-8">
{/* data-settings-label lets indexed settings search scroll to these. */}
<header className="flex min-w-0 flex-col gap-1">
<h1
@ -1094,249 +1094,265 @@ export function AgentsTab() {
data-settings-label={t("settings.agents.intro")}
className="text-sm text-muted-foreground leading-relaxed"
>
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em] text-foreground dark:bg-white/[0.08]">
{/* The chip is the docs entry point, so no separate link is needed.
No aria-label: it would replace the visible "unsloth start" as the
accessible name, leaving voice control unable to target it. */}
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
title={t("settings.agents.readDocs")}
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em] text-foreground underline decoration-border decoration-dotted underline-offset-2 transition-colors hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:bg-white/[0.08]"
>
unsloth start
</code>{" "}
</a>{" "}
{t("settings.agents.intro")}
</p>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-fit items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={Book03Icon} className="size-3.5" />
{t("settings.agents.readDocs")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
<section
aria-label={t("settings.agents.commandBuilder")}
className="flex w-full flex-col gap-4"
className="flex w-full flex-col gap-6"
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span
data-settings-label={t("settings.agents.agent")}
className="text-xs font-medium text-foreground"
>
{t("settings.agents.agent")}
</span>
<a
href={selectedAgentDetails.docsUrl}
target="_blank"
rel="noreferrer"
aria-label={t("settings.agents.agentDocs", {
agent: selectedAgentDetails.name,
})}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-ui-11 font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{t("settings.agents.docs")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</div>
<Select
value={selectedAgent}
onValueChange={(agent) => {
agentSelectionChanged.current = true;
setSelectedAgent(agent);
resetCopied();
}}
>
<SelectTrigger
aria-label={t("settings.agents.agent")}
className="w-full rounded-lg"
>
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<AgentIcon
logo={selectedAgentDetails.logo}
icon={selectedAgentDetails.icon}
darkIcon={selectedAgentDetails.darkIcon}
invertIconInDark={selectedAgentDetails.invertIconInDark}
color={selectedAgentDetails.color}
mark={selectedAgentDetails.mark}
/>
<span className="truncate">{selectedAgentDetails.name}</span>
{/* Keyed to this pane, not the viewport. The dialog leaves the tab
about 440px at a 768px window, where three columns crush the agent
and model controls; 34rem is the point all three stay usable. */}
<div className="@container">
<div className="grid grid-cols-1 items-start gap-3 @[34rem]:grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_minmax(9rem,0.5fr)]">
<div className="flex min-w-0 flex-col gap-1.5">
{/* Fixed height on every column header, so the padded docs link
here cannot push this control below the other two. */}
<div className="flex h-5 items-center justify-between gap-3">
<span
data-settings-label={t("settings.agents.agent")}
className="text-xs font-medium text-foreground"
>
{t("settings.agents.agent")}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{agents.map((agentId) => {
const agent = detailsFor(agentId);
return (
<SelectItem key={agent.id} value={agent.id}>
<a
href={selectedAgentDetails.docsUrl}
target="_blank"
rel="noreferrer"
aria-label={t("settings.agents.agentDocs", {
agent: selectedAgentDetails.name,
})}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-ui-11 font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{t("settings.agents.docs")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
</div>
<Select
value={selectedAgent}
onValueChange={(agent) => {
agentSelectionChanged.current = true;
setSelectedAgent(agent);
resetCopied();
}}
>
<SelectTrigger
aria-label={t("settings.agents.agent")}
className="w-full rounded-lg"
>
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<AgentIcon
logo={agent.logo}
icon={agent.icon}
darkIcon={agent.darkIcon}
invertIconInDark={agent.invertIconInDark}
color={agent.color}
mark={agent.mark}
logo={selectedAgentDetails.logo}
icon={selectedAgentDetails.icon}
darkIcon={selectedAgentDetails.darkIcon}
invertIconInDark={selectedAgentDetails.invertIconInDark}
color={selectedAgentDetails.color}
mark={selectedAgentDetails.mark}
/>
<span className="truncate">{agent.name}</span>
{localDetection &&
loaded &&
detectedAgents.has(agent.id) ? (
<span className="shrink-0 rounded-full bg-control-accent/10 px-2 py-1 text-ui-10 leading-none font-semibold text-control-accent">
{t("settings.agents.quickstart.installed")}
</span>
) : null}
<span className="truncate">
{selectedAgentDetails.name}
</span>
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(10rem,0.4fr)] items-start gap-3 max-md:grid-cols-1">
<div className="flex min-w-0 flex-col gap-1.5">
<span
data-settings-label={t("settings.agents.model")}
className="text-xs font-medium text-foreground"
>
{t("settings.agents.model")}
</span>
<Popover
open={modelPickerOpen}
onOpenChange={(open) => {
setModelPickerOpen(open);
if (!open) {
setModelSearch("");
}
}}
>
<PopoverTrigger asChild={true}>
<button
type="button"
aria-label={t("settings.agents.model")}
aria-expanded={modelPickerOpen}
title={selectedModel}
className="flex h-9 w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-3 text-left transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:border-transparent dark:bg-white/[0.06] dark:hover:bg-white/10"
>
<span className="min-w-0 truncate font-mono text-xs">
{labelFor(selectedModel)}
</span>
<HugeiconsIcon
icon={ChevronDownStandardIcon}
strokeWidth={2}
className="size-4 shrink-0 text-muted-foreground"
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={4}
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] gap-0 rounded-lg p-1"
>
<Command
shouldFilter={false}
className="rounded-none bg-transparent p-0"
>
<CommandInput
value={modelSearch}
onValueChange={setModelSearch}
aria-label={t("settings.agents.searchModels")}
placeholder={t("settings.agents.searchModels")}
className="font-mono text-xs"
/>
<CommandList>
<CommandEmpty>{t("settings.agents.noModels")}</CommandEmpty>
{visibleModels.map((model) => (
<CommandItem
key={model}
value={model}
data-checked={model === selectedModel}
onSelect={() => {
modelSelectionChanged.current = true;
setSelectedModel(model);
setSelectedVariant(knownVariants[model] ?? null);
setVariants([]);
setDefaultVariant(null);
setVariantsFailed(false);
setVariantsLoading(isHuggingFaceRepo(model));
setModelSearch("");
setModelPickerOpen(false);
resetCopied();
}}
className="cursor-pointer font-mono text-xs"
>
<span className="min-w-0 truncate" title={model}>
{labelFor(model)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{agents.map((agentId) => {
const agent = detailsFor(agentId);
return (
<SelectItem key={agent.id} value={agent.id}>
<span className="flex min-w-0 items-center gap-2">
<AgentIcon
logo={agent.logo}
icon={agent.icon}
darkIcon={agent.darkIcon}
invertIconInDark={agent.invertIconInDark}
color={agent.color}
mark={agent.mark}
/>
<span className="truncate">{agent.name}</span>
{localDetection &&
loaded &&
detectedAgents.has(agent.id) ? (
<span className="shrink-0 rounded-full bg-control-accent/10 px-2 py-1 text-ui-10 leading-none font-semibold text-control-accent">
{t("settings.agents.quickstart.installed")}
</span>
) : null}
</span>
</CommandItem>
))}
</CommandList>
{matchingModels.length > visibleModels.length ? (
<p className="border-t border-border/60 px-3 py-2 text-ui-11 text-muted-foreground">
{t("settings.agents.showingModels", {
shown: visibleModels.length,
total: matchingModels.length,
})}
</p>
) : null}
</Command>
</PopoverContent>
</Popover>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<span
data-settings-label={t("settings.agents.quantization")}
className="text-xs font-medium text-foreground"
>
{t("settings.agents.quantization")}
</span>
<Select
value={selectedVariant ?? undefined}
onValueChange={(variant) => {
chosenVariant.current = { model: selectedModel, variant };
setSelectedVariant(variant);
resetCopied();
}}
disabled={variantsLoading || variants.length === 0}
>
<SelectTrigger
aria-label={t("settings.agents.quantization")}
className="w-full rounded-lg font-mono text-xs"
>
<SelectValue
placeholder={
variantsLoading
? t("settings.agents.loadingQuantizations")
: t("settings.agents.noQuantizations")
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex h-5 items-center">
<span
data-settings-label={t("settings.agents.model")}
className="text-xs font-medium text-foreground"
>
{t("settings.agents.model")}
</span>
</div>
<Popover
open={modelPickerOpen}
onOpenChange={(open) => {
setModelPickerOpen(open);
if (!open) {
setModelSearch("");
}
}}
>
<PopoverTrigger asChild={true}>
<button
type="button"
aria-label={t("settings.agents.model")}
aria-expanded={modelPickerOpen}
title={selectedModel}
className="flex h-9 w-full items-center justify-between gap-2 rounded-lg border border-border bg-background px-3 text-left transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:border-transparent dark:bg-white/[0.06] dark:hover:bg-white/10"
>
<span className="min-w-0 truncate font-mono text-xs">
{labelFor(selectedModel)}
</span>
<HugeiconsIcon
icon={ChevronDownStandardIcon}
strokeWidth={2}
className="size-4 shrink-0 text-muted-foreground"
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={4}
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] gap-0 rounded-lg p-1"
>
{selectedVariant}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{variants.map((variant) => {
const metadata = [
variant.quant === defaultVariant
? t("settings.agents.recommended")
: null,
variant.downloaded ? t("settings.agents.downloaded") : null,
formatBytes(
<Command
shouldFilter={false}
className="rounded-none bg-transparent p-0"
>
<CommandInput
value={modelSearch}
onValueChange={setModelSearch}
aria-label={t("settings.agents.searchModels")}
placeholder={t("settings.agents.searchModels")}
className="font-mono text-xs"
/>
<CommandList>
<CommandEmpty>
{t("settings.agents.noModels")}
</CommandEmpty>
{visibleModels.map((model) => (
<CommandItem
key={model}
value={model}
data-checked={model === selectedModel}
onSelect={() => {
modelSelectionChanged.current = true;
setSelectedModel(model);
setSelectedVariant(knownVariants[model] ?? null);
setVariants([]);
setVariantsFailed(false);
setVariantsLoading(isHuggingFaceRepo(model));
setModelSearch("");
setModelPickerOpen(false);
resetCopied();
}}
className="cursor-pointer font-mono text-xs"
>
<span className="min-w-0 truncate" title={model}>
{labelFor(model)}
</span>
</CommandItem>
))}
</CommandList>
{matchingModels.length > visibleModels.length ? (
<p className="border-t border-border/60 px-3 py-2 text-ui-11 text-muted-foreground">
{t("settings.agents.showingModels", {
shown: visibleModels.length,
total: matchingModels.length,
})}
</p>
) : null}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex h-5 items-center">
<span
data-settings-label={t("settings.agents.quantization")}
className="text-xs font-medium text-foreground"
>
{t("settings.agents.quantization")}
</span>
</div>
<Select
value={selectedVariant ?? undefined}
onValueChange={(variant) => {
chosenVariant.current = { model: selectedModel, variant };
setSelectedVariant(variant);
resetCopied();
}}
disabled={variantsLoading || variants.length === 0}
>
<SelectTrigger
aria-label={t("settings.agents.quantization")}
className="w-full rounded-lg font-mono text-xs"
>
<SelectValue
placeholder={
variantsLoading
? t("settings.agents.loadingQuantizations")
: t("settings.agents.noQuantizations")
}
>
{selectedVariant}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" className="min-w-[16rem]">
{variants.map((variant) => {
// Size only: the recommended/downloaded tags wrapped every
// row onto two lines and made the list hard to scan.
const size = formatBytes(
variant.download_size_bytes ?? variant.size_bytes,
),
].filter(Boolean);
return (
<SelectItem key={variant.quant} value={variant.quant}>
<span className="font-mono text-xs">{variant.quant}</span>
{metadata.length > 0 ? (
<span className="text-ui-10 text-muted-foreground">
{metadata.join(" · ")}
);
return (
<SelectItem
key={variant.quant}
value={variant.quant}
// Stretch the item text so the size can sit flush right,
// giving the list a clean two-column read.
className="[&>span:last-child]:w-full [&>span:last-child]:justify-between"
>
<span className="font-mono text-xs whitespace-nowrap">
{variant.quant}
</span>
) : null}
</SelectItem>
);
})}
</SelectContent>
</Select>
{size ? (
<span className="text-ui-10 whitespace-nowrap text-muted-foreground">
{size}
</span>
) : null}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
</div>
@ -1346,27 +1362,16 @@ export function AgentsTab() {
</p>
) : null}
<div className="flex min-w-0 flex-col gap-2 rounded-lg border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium text-foreground">
{t("settings.agents.generatedCommand")}
</span>
<button
type="button"
onClick={handleCopy}
aria-label={t("settings.agents.copyGeneratedCommand")}
className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-ui-11 font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className={cn("size-3.5", copied && "text-control-accent")}
/>
{copied ? t("settings.agents.copied") : t("settings.agents.copy")}
</button>
</div>
<code className="block min-w-0 whitespace-pre-wrap break-all rounded-md border border-border bg-background/70 px-2.5 py-2 font-mono text-ui-11 leading-relaxed text-foreground">
{command}
</code>
<div className="flex min-w-0 flex-col gap-2.5">
<span className="text-xs font-medium text-foreground">
{t("settings.agents.generatedCommand")}
</span>
<CopyableCode
value={command}
copyLabel={t("settings.agents.copyGeneratedCommand")}
copied={copied}
onCopy={handleCopy}
/>
</div>
<SubagentSection
@ -1406,7 +1411,7 @@ export function AgentsTab() {
title={t("settings.agents.remote.title")}
description={t("settings.agents.remote.description")}
>
<div className="pt-2">
<div className="pt-3">
<CommandBlock command={remoteCommand} />
</div>
</SettingsSection>
@ -1415,7 +1420,7 @@ export function AgentsTab() {
title={t("settings.agents.passthrough.title")}
description={t("settings.agents.passthrough.description")}
>
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col gap-3 pt-3">
{PASSTHROUGH_EXAMPLES.map(({ agent, flags }) => (
<CommandBlock key={flags} command={example(agent, flags)} />
))}
@ -1426,7 +1431,7 @@ export function AgentsTab() {
title={t("settings.agents.dryRun.title")}
description={t("settings.agents.dryRun.description")}
>
<div className="pt-2">
<div className="pt-3">
<CommandBlock command={example("claude", DRY_RUN_FLAGS)} />
</div>
</SettingsSection>

View file

@ -72,7 +72,10 @@ function formatBytes(value: number | null): string | null {
function formatGiB(value: number | null | undefined): string {
const safe = isFiniteNumber(value) ? Math.max(0, value) : 0;
const digits = safe >= 10 ? 1 : 2;
return `${safe.toFixed(digits)} GiB`;
// digits is never 0, so toFixed always leaves a decimal point and trimming
// trailing zeros cannot reach an integer digit. "64.0" reads as "64".
const text = safe.toFixed(digits).replace(/\.?0+$/, "");
return `${text} GiB`;
}
function formatMb(value: number | null | undefined): string {
@ -116,7 +119,7 @@ function MetricTile({
const percentKnown = isFiniteNumber(percent);
const safePercent = clampPercent(percent);
return (
<div className="flex min-w-0 flex-col gap-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4 dark:border-transparent dark:bg-white/[0.06]">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-ui-11 font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{label}
@ -124,7 +127,9 @@ function MetricTile({
<span
className={cn(
"shrink-0 font-mono text-xs tabular-nums",
percentKnown ? usageTextClass(safePercent) : "text-muted-foreground",
percentKnown
? usageTextClass(safePercent)
: "text-muted-foreground",
)}
>
{percentKnown ? formatPercent(safePercent) : "--"}
@ -141,7 +146,7 @@ function MetricTile({
<Progress
value={percentKnown ? safePercent : 0}
aria-label={label}
className="h-1.5 rounded-full bg-muted"
className="h-1.5 rounded-full bg-muted dark:bg-black/40"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
@ -327,7 +332,9 @@ export function ResourcesTab() {
const hasGpu =
(displayedGpu?.available ?? false) && metrics.devices.length > 0;
const backendLabel = (
displayedGpu?.backend ?? systemInfo.device_backend ?? "cpu"
displayedGpu?.backend ??
systemInfo.device_backend ??
"cpu"
).toUpperCase();
const modelsFolderPath = hfCache
? hfCache.cacheHome
@ -483,54 +490,66 @@ export function ResourcesTab() {
? formatPercent(safePercent)
: unknownLabel;
return (
// Name over backend on the left, figures over the meter on the
// right. The meter tracks the figures' width rather than the
// pane's, so it reads as one device's usage, not a rule.
<div
key={`${device.index ?? index}-${device.name ?? "gpu"}`}
className="flex min-w-0 flex-col gap-2 py-3"
className="flex min-w-0 items-center justify-between gap-x-4 gap-y-2 py-3 max-[992px]:flex-col max-[992px]:items-stretch"
>
<div className="flex min-w-0 items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{device.name ??
t("settings.resources.gpu.unknownDevice")}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{device.name ?? t("settings.resources.gpu.unknownDevice")}
</div>
<div className="mt-1 flex min-w-0 items-center gap-2">
<span className="truncate text-xs text-muted-foreground">
{ordinal === undefined
? backendLabel
: `${t("settings.resources.gpu.deviceWithIndex", {
index: ordinal,
})}, ${backendLabel}`}
</div>
</div>
<div className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
<span>
</span>
{/* Same accent pill as the New tags, which stays legible
on the light background. */}
<span className="shrink-0 rounded-full bg-control-accent/10 px-2 py-1 text-ui-10 leading-none font-semibold tabular-nums text-control-accent">
{percentText}{" "}
{t("settings.resources.gpu.vramUtilization")}
</span>
</div>
</div>
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-3 sm:gap-2">
<span className="min-w-0 truncate font-mono tabular-nums">
{t("settings.resources.gpu.used", {
value: usedText,
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-center">
{t("settings.resources.gpu.free", {
value: freeText,
})}
</span>
<span className="min-w-0 truncate font-mono tabular-nums sm:text-right">
{t("settings.resources.gpu.total", {
value: totalText,
})}
</span>
{/* Meter under the figures, so it spans their width instead of
being squeezed into the gap beside them. Same 392px as the
Model downloads control, so both blocks start on one edge.
Below 992px the dialog stops filling its 960px cap and the
device name would truncate, so the row stacks instead. */}
<div className="flex w-[392px] shrink-0 flex-col items-stretch gap-2.5 max-[992px]:w-full">
{/* Ruled between the three readings: run together they are
easy to misread as one number. */}
{/* min-w-0 on each reading, or truncate cannot fire: a flex
item defaults to min-width:auto and the longest locales
would push past the block instead of ellipsizing. */}
<div className="flex items-center justify-between gap-3 font-mono text-ui-11 tabular-nums text-muted-foreground">
<span className="min-w-0 truncate">
{t("settings.resources.gpu.used", { value: usedText })}
</span>
<span aria-hidden className="h-3 w-px shrink-0 bg-border" />
<span className="min-w-0 truncate">
{t("settings.resources.gpu.free", { value: freeText })}
</span>
<span aria-hidden className="h-3 w-px shrink-0 bg-border" />
<span className="min-w-0 truncate">
{t("settings.resources.gpu.total", {
value: totalText,
})}
</span>
</div>
<Progress
value={safePercent}
aria-label={device.name ?? "GPU"}
className="h-1.5 w-full rounded-full bg-muted dark:bg-black/40"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
<Progress
value={safePercent}
aria-label={device.name ?? "GPU"}
className="h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
);
})

View file

@ -461,7 +461,7 @@ export const ar = {
codingAgents: "وكلاء البرمجة",
codingAgentsHint:
"شغّل وكيل برمجة مقابل هذا الخادم. يستخدم النموذج المُحمَّل؛ الخادم المحلي يُنشئ مفتاح API تلقائيًا، والخادم البعيد يُضمّنه في الأمر.",
codingAgentsSwap: "استبدل claude بـ codex أو openclaw أو opencode أو hermes أو pi.",
codingAgentsSwap: "استبدل claude بـ codex أو openclaw أو opencode أو hermes.",
codingAgentDetected: "مثبّت على هذا الجهاز",
codingAgentsDetectedHint: "تم اكتشافه على هذا الجهاز: {agents}.",
relativeNever: "أبدًا",

View file

@ -491,7 +491,7 @@ export const de = {
codingAgentsHint:
"Starten Sie einen Coding-Agent gegen diesen Server. Er verwendet das geladene Modell; ein lokaler Server erstellt automatisch einen API-Schlüssel, ein entfernter fügt ihn dem Befehl hinzu.",
codingAgentsSwap:
"Ersetzen Sie claude durch codex, openclaw, opencode, hermes oder pi.",
"Ersetzen Sie claude durch codex, openclaw, opencode oder hermes.",
codingAgentDetected: "Auf diesem Gerät installiert",
codingAgentsDetectedHint: "Auf diesem Gerät erkannt: {agents}.",
relativeNever: "nie",

View file

@ -601,9 +601,9 @@ export const en = {
agents: {
title: "Agents",
description:
"Connect coding agents like Claude Code and Codex to a model running locally in Unsloth with unsloth start.",
"Connect coding agents like Claude Code and Codex to a local model with unsloth start.",
intro:
"connects Claude Code, Codex, Hermes, OpenClaw, OpenCode, Pi and other agents to a model served locally by Unsloth, fully offline on your own hardware. It runs an OpenAI-compatible server for the agent and never touches your agent's config files.",
"connects Claude Code, Codex, Hermes, OpenClaw, OpenCode and other agents to a model served locally by Unsloth, fully offline. It runs an OpenAI-compatible server and never touches your agent's config files.",
readDocs: "Read the docs",
copy: "Copy",
copied: "Copied",
@ -672,7 +672,7 @@ export const en = {
launch: "Launch the agent, or just print the command and environment.",
persist: "Keep Unsloth-managed agent storage between runs.",
asSubagent:
"Keep the parent on its current model and register Unsloth as a local subagent (Claude Code, Codex, OpenCode, and Pi).",
"Keep the parent on its current model and register Unsloth as a local subagent (Claude Code, Codex, and OpenCode).",
apiKey: "Provide your Unsloth API key (or set UNSLOTH_API_KEY).",
yolo: "Skip approval prompts. Use only in trusted environments.",
},
@ -855,8 +855,7 @@ export const en = {
codingAgents: "Coding agents",
codingAgentsHint:
"Launch a coding agent against this server. It uses the loaded model; a local server mints an API key automatically, a remote one includes it in the command.",
codingAgentsSwap:
"Swap claude for codex, openclaw, opencode, hermes, or pi.",
codingAgentsSwap: "Swap claude for codex, openclaw, opencode, or hermes.",
codingAgentDetected: "Installed on this machine",
codingAgentsDetectedHint: "Detected on this machine: {agents}.",
relativeNever: "never",

View file

@ -476,7 +476,7 @@ export const es = {
codingAgentsHint:
"Inicia un agente de programación contra este servidor. Usa el modelo cargado; un servidor local genera una clave de API automáticamente y uno remoto la incluye en el comando.",
codingAgentsSwap:
"Reemplaza claude por codex, openclaw, opencode, hermes o pi.",
"Reemplaza claude por codex, openclaw, opencode o hermes.",
codingAgentDetected: "Instalado en esta máquina",
codingAgentsDetectedHint: "Detectados en esta máquina: {agents}.",
relativeNever: "nunca",

View file

@ -474,7 +474,7 @@ export const fr = {
codingAgentsHint:
"Lancez un agent de codage sur ce serveur. Il utilise le modèle chargé ; un serveur local génère automatiquement une clé API, un serveur distant l'inclut dans la commande.",
codingAgentsSwap:
"Remplacez claude par codex, openclaw, opencode, hermes ou pi.",
"Remplacez claude par codex, openclaw, opencode ou hermes.",
codingAgentDetected: "Installé sur cette machine",
codingAgentsDetectedHint: "Détecté sur cette machine : {agents}.",
relativeNever: "jamais",

View file

@ -459,7 +459,7 @@ export const hi = {
codingAgents: "कोडिंग एजेंट",
codingAgentsHint:
"इस सर्वर के विरुद्ध एक कोडिंग एजेंट लॉन्च करें। यह लोड किए गए मॉडल का उपयोग करता है; एक स्थानीय सर्वर स्वचालित रूप से API key बनाता है, एक रिमोट सर्वर इसे कमांड में शामिल करता है।",
codingAgentsSwap: "claude को codex, openclaw, opencode, hermes, या pi से बदलें।",
codingAgentsSwap: "claude को codex, openclaw, opencode, या hermes से बदलें।",
codingAgentDetected: "इस मशीन पर इंस्टॉल है",
codingAgentsDetectedHint: "इस मशीन पर पाया गया: {agents}।",
relativeNever: "कभी नहीं",

View file

@ -521,7 +521,7 @@ export const ja = {
setupDocs: "セットアップドキュメント:",
codingAgents: "コーディングエージェント",
codingAgentsHint: "このサーバーに対してコーディングエージェントを起動します。読み込み済みのモデルを使用します。ローカルサーバーでは API キーが自動的に発行され、リモートサーバーではコマンドに含まれます。",
codingAgentsSwap: "claude を codex、openclaw、opencode、hermes、pi に置き換えられます。",
codingAgentsSwap: "claude を codex、openclaw、opencode、hermes に置き換えられます。",
codingAgentDetected: "このマシンにインストール済み",
codingAgentsDetectedHint: "このマシンで検出されました: {agents}。",
relativeNever: "なし",

View file

@ -460,7 +460,7 @@ export const ko = {
codingAgents: "코딩 에이전트",
codingAgentsHint:
"이 서버를 대상으로 코딩 에이전트를 실행합니다. 로드된 모델을 사용하며, 로컬 서버는 API 키를 자동으로 발급하고 원격 서버는 명령에 포함합니다.",
codingAgentsSwap: "claude를 codex, openclaw, opencode, hermes 또는 pi로 바꾸세요.",
codingAgentsSwap: "claude를 codex, openclaw, opencode 또는 hermes로 바꾸세요.",
codingAgentDetected: "이 컴퓨터에 설치됨",
codingAgentsDetectedHint: "이 컴퓨터에서 감지됨: {agents}.",
relativeNever: "없음",

View file

@ -562,7 +562,7 @@ export const ptBR = {
codingAgents: "Agentes de código",
codingAgentsHint:
"Inicie um agente de código conectado a este servidor. Ele usa o modelo carregado; um servidor local gera uma chave de API automaticamente, um remoto a inclui no comando.",
codingAgentsSwap: "Troque claude por codex, openclaw, opencode, hermes ou pi.",
codingAgentsSwap: "Troque claude por codex, openclaw, opencode ou hermes.",
codingAgentDetected: "Instalado nesta máquina",
codingAgentsDetectedHint: "Detectado nesta máquina: {agents}.",
relativeNever: "nunca",

View file

@ -459,7 +459,7 @@ export const ru = {
codingAgents: "Кодинг-агенты",
codingAgentsHint:
"Запустите кодинг-агента к этому серверу. Он использует загруженную модель; локальный сервер выпускает ключ API автоматически, удалённый включает его в команду.",
codingAgentsSwap: "Замените claude на codex, openclaw, opencode, hermes или pi.",
codingAgentsSwap: "Замените claude на codex, openclaw, opencode или hermes.",
codingAgentDetected: "Установлен на этой машине",
codingAgentsDetectedHint: "Обнаружены на этой машине: {agents}.",
relativeNever: "никогда",

View file

@ -549,7 +549,7 @@ export const zhCN = {
codingAgents: "编程智能体",
codingAgentsHint:
"针对此服务器启动编程智能体。它会使用已加载的模型;本地服务器会自动生成 API 密钥,远程服务器则会将其包含在命令中。",
codingAgentsSwap: "可将 claude 替换为 codex、openclaw、opencode、hermes 或 pi。",
codingAgentsSwap: "可将 claude 替换为 codex、openclaw、opencode 或 hermes。",
codingAgentDetected: "已安装在本机",
codingAgentsDetectedHint: "本机检测到:{agents}。",
relativeNever: "从未",

View file

@ -664,6 +664,20 @@ html[data-chat-font] .aui-root {
in the same style pass that swaps the color tokens. Driving it from
React state paints one frame late while the whole page recalculates,
which briefly leaves the ring on the previous card. */
/* Dark mode drops the resting border. Same specificity as, and before, the
hover/selected rules below so those still paint a visible border. */
.dark .palette-card {
border-color: transparent;
}
/* The card removes its outline, so the border is the only focus indicator.
This rule is unlayered like the one above, which would otherwise beat
Tailwind's layered focus-visible:border-ring and leave nothing visible. */
.dark .palette-card:focus-visible {
border-color: var(--ring);
}
.palette-card:hover {
border-color: var(--ring-soft);
}