Compare commits

...
Sign in to create a new pull request.

12 commits

Author SHA1 Message Date
Unsloth
cf9a9a3682 Studio: let the GPU readings truncate instead of overflowing
The three figures carry truncate but sit in a flex row, where the
default min-width:auto stops an item shrinking below its text. The
longest locales would push past the block rather than ellipsize.
2026-07-29 02:35:30 -07:00
Unsloth
8e48894fa8 Studio: widen the GPU figures to the Model downloads edge
Match the 392px of the Model downloads control so both blocks start on
the same edge, and open up the gap between the figures and the meter.
Below 992px the dialog no longer fills its width cap, so stack the row
rather than squeezing the device name.
2026-07-29 01:54:35 -07:00
Unsloth
3f436eb609 Studio: rule the GPU figures apart and drop trailing zeros
Used, free and total ran together with only a gap between them, so the
three readings were easy to misread as one. Rule them apart, size the
numbers down a step, and widen the block so it starts further left.
Whole values now read as 64 GiB rather than 64.0 GiB.
2026-07-29 01:45:08 -07:00
Unsloth
d3817021ff Studio: stack the GPU meter under its figures and tint the VRAM pill
The meter sat beside the used/free/total figures with only a fixed 24
width, so it read as squeezed. Move it under them where it spans their
width. The pill was muted grey and washed out in light mode; give it the
same accent fill the New tags use.
2026-07-29 01:39:28 -07:00
Unsloth
0f70c81776 Studio: align the three Agents column headers
The Coding agent header carries a padded docs link, so it measured
taller than the plain labels beside it and pushed its select down.
Pin every column header to the same height.
2026-07-29 01:34:28 -07:00
Unsloth
8f0c0608b6 Studio: rework the GPU device row
Restores the earlier shape, name over backend on the left, but lifts the
memory figures and the meter up beside them instead of stacking them
underneath. The meter is short rather than full width, which was the
original complaint: at full width it read as a rule across the pane
rather than a reading for one device.

VRAM utilization becomes a rounded pill next to the backend label. The
row wraps rather than squeezing the device name when the pane is narrow.
2026-07-29 01:22:12 -07:00
Unsloth
146cf98f23 Studio: align the localized agent swap hints with the picker
The English hint was updated to match the agents the picker offers, but
the ten translated copies were not, so every non-English user was told to
pick an option the UI does not show.
2026-07-29 01:07:45 -07:00
Unsloth
29131cc85e Studio: size Agents and System to their pane, restore palette focus
- The GPU row's breakpoint could never fire. The settings pane is about
  664px at any window size, and the name, three figures, meter and
  percentage need more than that, so the one-row layout was dead code.
  Each device is now two compact lines: name with the percentage, then
  the figures with a fixed-width meter. The name stays readable, the
  meter no longer runs the width of the pane, and there is no breakpoint
  that cannot be reached.
- The agent, model and quantization grid switched on the viewport, so
  between 768px and 1024px it went to three columns inside a 440px pane
  and crushed every control. It now keys on the pane and stacks until
  the columns genuinely fit.
- Dark mode dropped the palette card's resting border, but that border
  is the only focus indicator since the card removes its outline, and an
  unlayered rule beats Tailwind's layered focus-visible utility. Keyboard
  focus paints the ring colour again.
2026-07-29 00:47:41 -07:00
Unsloth
ecb80670e5 Studio: size the GPU row to its pane and fix the docs link name
- The device row keyed its layout off the viewport, but the Settings
  dialog caps the tab at roughly 650px no matter how wide the window is,
  so on every desktop the row stayed horizontal and squeezed the device
  name down to a fragment. It now uses a container query against the
  pane and only goes to one row when there is genuinely space.
- The docs link carried an aria-label that replaced its visible
  "unsloth start" text as the accessible name, so it could not be
  targeted by the label users can see. The title keeps the description.
- Dropped a stale agent from the swap hint so it matches the picker.
2026-07-29 00:12:37 -07:00
Unsloth
937919b17f Studio: let the GPU meter shrink beside its percentage
The stacked layout gave the meter the full row width while keeping it
shrink-0, so each device row ran wider than its container once the
figures moved onto their own line. It now fills whatever the percentage
label leaves, and keeps the fixed width at lg and above.
2026-07-28 23:47:47 -07:00
Unsloth
03082cdb74 Studio: keep per-GPU memory figures on narrow screens
The one-row device layout hid used, free and total below 1024px, so
tablets and small laptops were left with only the utilization
percentage. Those widths now stack the row instead of dropping the
figures, and the bar goes full width under them.
2026-07-28 23:23:03 -07:00
Unsloth
dfed6a6921 Studio: tidy up the Agents, System and Connections settings
Agents tab
- Coding agent, model and quantization now sit on one row of three
  columns instead of stacking.
- Smaller agent logos so the picker reads as a list, not a row of tiles.
- Command boxes lose their outer border and carry the copy control
  inside the box, top right.
- The `unsloth start` chip is now the link to the docs.
- Quantization dropdown drops the extra tags that wrapped every row onto
  two lines, and the size sits flush right so the list scans cleanly.
- More breathing room between sections.

System tab
- Rounder metric cards with more padding, and no resting border in dark
  mode.
- Darker meter track in dark mode so the bars stand out again.
- GPU devices collapse to one row per device; a full width bar under a
  single GPU read like a page-wide rule.

Connections
- The add-provider box matches the corner radius used elsewhere.

Appearance
- Colour palette cards drop the resting border in dark mode, matching
  the rest of Settings. Hover and selected states are unchanged.
2026-07-28 22:56:16 -07:00
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">
<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>
<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",
)}
<CopyableCode
value={command}
copyLabel={t("settings.agents.subagent.copySetupCommand")}
copied={commandCopy.copied}
onCopy={commandCopy.copy}
/>
{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>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<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>
<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",
)}
<CopyableCode
value={prompt}
copyLabel={t("settings.agents.subagent.copyUsagePrompt")}
copied={promptCopy.copied}
onCopy={promptCopy.copy}
breakAll={false}
/>
{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>
</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,29 +1094,34 @@ 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]">
unsloth start
</code>{" "}
{t("settings.agents.intro")}
</p>
{/* 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"
className="inline-flex w-fit items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
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]"
>
<HugeiconsIcon icon={Book03Icon} className="size-3.5" />
{t("settings.agents.readDocs")}
<HugeiconsIcon icon={ArrowUpRight01Icon} className="size-3" />
</a>
unsloth start
</a>{" "}
{t("settings.agents.intro")}
</p>
<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">
{/* 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"
@ -1158,7 +1163,9 @@ export function AgentsTab() {
color={selectedAgentDetails.color}
mark={selectedAgentDetails.mark}
/>
<span className="truncate">{selectedAgentDetails.name}</span>
<span className="truncate">
{selectedAgentDetails.name}
</span>
</span>
</SelectValue>
</SelectTrigger>
@ -1192,14 +1199,15 @@ export function AgentsTab() {
</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">
<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) => {
@ -1244,7 +1252,9 @@ export function AgentsTab() {
className="font-mono text-xs"
/>
<CommandList>
<CommandEmpty>{t("settings.agents.noModels")}</CommandEmpty>
<CommandEmpty>
{t("settings.agents.noModels")}
</CommandEmpty>
{visibleModels.map((model) => (
<CommandItem
key={model}
@ -1255,7 +1265,6 @@ export function AgentsTab() {
setSelectedModel(model);
setSelectedVariant(knownVariants[model] ?? null);
setVariants([]);
setDefaultVariant(null);
setVariantsFailed(false);
setVariantsLoading(isHuggingFaceRepo(model));
setModelSearch("");
@ -1284,12 +1293,14 @@ export function AgentsTab() {
</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) => {
@ -1313,23 +1324,27 @@ export function AgentsTab() {
{selectedVariant}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectContent align="start" className="min-w-[16rem]">
{variants.map((variant) => {
const metadata = [
variant.quant === defaultVariant
? t("settings.agents.recommended")
: null,
variant.downloaded ? t("settings.agents.downloaded") : null,
formatBytes(
// 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(" · ")}
<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>
{size ? (
<span className="text-ui-10 whitespace-nowrap text-muted-foreground">
{size}
</span>
) : null}
</SelectItem>
@ -1339,6 +1354,7 @@ export function AgentsTab() {
</Select>
</div>
</div>
</div>
{variantsFailed ? (
<p className="text-ui-11 leading-relaxed text-amber-700 dark:text-amber-400">
@ -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">
<div className="flex min-w-0 flex-col gap-2.5">
<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")}
<CopyableCode
value={command}
copyLabel={t("settings.agents.copyGeneratedCommand")}
copied={copied}
onCopy={handleCopy}
/>
{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>
<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,43 +490,54 @@ 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")}
{device.name ?? t("settings.resources.gpu.unknownDevice")}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
<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,
})}
{/* 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 className="min-w-0 truncate font-mono tabular-nums sm:text-center">
{t("settings.resources.gpu.free", {
value: freeText,
})}
<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 className="min-w-0 truncate font-mono tabular-nums sm:text-right">
<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,
})}
@ -528,10 +546,11 @@ export function ResourcesTab() {
<Progress
value={safePercent}
aria-label={device.name ?? "GPU"}
className="h-1.5 rounded-full bg-muted"
className="h-1.5 w-full rounded-full bg-muted dark:bg-black/40"
indicatorClassName={usageIndicatorClass(safePercent)}
/>
</div>
</div>
);
})
) : (

View file

@ -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: "أبدًا",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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: "कभी नहीं",

View file

@ -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: "なし",

View file

@ -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: "없음",

View file

@ -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",

View file

@ -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: "никогда",

View file

@ -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: "从未",

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);
}