Compare commits
12 commits
main
...
studio-set
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf9a9a3682 | ||
|
|
8e48894fa8 | ||
|
|
3f436eb609 | ||
|
|
d3817021ff | ||
|
|
0f70c81776 | ||
|
|
8f0c0608b6 | ||
|
|
146cf98f23 | ||
|
|
29131cc85e | ||
|
|
ecb80670e5 | ||
|
|
937919b17f | ||
|
|
03082cdb74 | ||
|
|
dfed6a6921 |
19 changed files with 428 additions and 410 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -451,7 +451,7 @@ export const ar = {
|
|||
codingAgents: "وكلاء البرمجة",
|
||||
codingAgentsHint:
|
||||
"شغّل وكيل برمجة مقابل هذا الخادم. يستخدم النموذج المُحمَّل؛ الخادم المحلي يُنشئ مفتاح API تلقائيًا، والخادم البعيد يُضمّنه في الأمر.",
|
||||
codingAgentsSwap: "استبدل claude بـ codex أو openclaw أو opencode أو hermes أو pi.",
|
||||
codingAgentsSwap: "استبدل claude بـ codex أو openclaw أو opencode أو hermes.",
|
||||
codingAgentDetected: "مثبّت على هذا الجهاز",
|
||||
codingAgentsDetectedHint: "تم اكتشافه على هذا الجهاز: {agents}.",
|
||||
relativeNever: "أبدًا",
|
||||
|
|
|
|||
|
|
@ -469,7 +469,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",
|
||||
|
|
|
|||
|
|
@ -589,9 +589,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",
|
||||
|
|
@ -660,7 +660,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.",
|
||||
},
|
||||
|
|
@ -843,8 +843,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",
|
||||
|
|
|
|||
|
|
@ -466,7 +466,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",
|
||||
|
|
|
|||
|
|
@ -464,7 +464,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",
|
||||
|
|
|
|||
|
|
@ -449,7 +449,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: "कभी नहीं",
|
||||
|
|
|
|||
|
|
@ -511,7 +511,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: "なし",
|
||||
|
|
|
|||
|
|
@ -450,7 +450,7 @@ export const ko = {
|
|||
codingAgents: "코딩 에이전트",
|
||||
codingAgentsHint:
|
||||
"이 서버를 대상으로 코딩 에이전트를 실행합니다. 로드된 모델을 사용하며, 로컬 서버는 API 키를 자동으로 발급하고 원격 서버는 명령에 포함합니다.",
|
||||
codingAgentsSwap: "claude를 codex, openclaw, opencode, hermes 또는 pi로 바꾸세요.",
|
||||
codingAgentsSwap: "claude를 codex, openclaw, opencode 또는 hermes로 바꾸세요.",
|
||||
codingAgentDetected: "이 컴퓨터에 설치됨",
|
||||
codingAgentsDetectedHint: "이 컴퓨터에서 감지됨: {agents}.",
|
||||
relativeNever: "없음",
|
||||
|
|
|
|||
|
|
@ -552,7 +552,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",
|
||||
|
|
|
|||
|
|
@ -449,7 +449,7 @@ export const ru = {
|
|||
codingAgents: "Кодинг-агенты",
|
||||
codingAgentsHint:
|
||||
"Запустите кодинг-агента к этому серверу. Он использует загруженную модель; локальный сервер выпускает ключ API автоматически, удалённый включает его в команду.",
|
||||
codingAgentsSwap: "Замените claude на codex, openclaw, opencode, hermes или pi.",
|
||||
codingAgentsSwap: "Замените claude на codex, openclaw, opencode или hermes.",
|
||||
codingAgentDetected: "Установлен на этой машине",
|
||||
codingAgentsDetectedHint: "Обнаружены на этой машине: {agents}.",
|
||||
relativeNever: "никогда",
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ export const zhCN = {
|
|||
codingAgents: "编程智能体",
|
||||
codingAgentsHint:
|
||||
"针对此服务器启动编程智能体。它会使用已加载的模型;本地服务器会自动生成 API 密钥,远程服务器则会将其包含在命令中。",
|
||||
codingAgentsSwap: "可将 claude 替换为 codex、openclaw、opencode、hermes 或 pi。",
|
||||
codingAgentsSwap: "可将 claude 替换为 codex、openclaw、opencode 或 hermes。",
|
||||
codingAgentDetected: "已安装在本机",
|
||||
codingAgentsDetectedHint: "本机检测到:{agents}。",
|
||||
relativeNever: "从未",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue