feat: detect installed coding agent CLIs in Studio settings (#6909)
* feat: detect installed coding agent CLIs in Studio settings
The API-keys panel only ever showed the "claude" flavor of the
`unsloth start` command, so anyone using Codex, OpenCode, OpenClaw,
Hermes, or Pi had to manually rewrite the copied command by hand.
Add a backend check that looks for each agent's CLI binary on PATH
(shutil.which, mirroring the pattern already used elsewhere in
studio/backend/utils) and expose it as GET /api/settings/coding-agents.
The API-keys panel now renders a picker for all six supported agents,
marks the ones it finds installed, and defaults to one of those instead
of always falling back to claude.
Includes unit tests for the detection helper.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* address review feedback on coding-agent detection
Three fixes from PR review:
- detect_installed_coding_agents now treats a PATH lookup failure as
"not installed" instead of letting it bubble up and break the
settings endpoint; added a regression test for it.
- CodingAgentsResponse.agents is now typed as an immutable tuple
instead of a list built from one, matching CODING_AGENTS itself.
- Fixed a race in the API-keys panel: picking an agent while the
installed-CLI check is still in flight could get silently overwritten
once that check resolved. A ref now tracks whether the user has made
a manual choice, so the auto-detected default only applies before
that happens.
* Address Codex feedback: GGUF gating and remote-detection scope
- codex refuses to launch against a non-GGUF (transformers-backed) model
(unsloth_cli's _require_gguf_for_codex), so auto-defaulting to it produced
a copy-pasteable command that fails immediately whenever the loaded model
isn't GGUF. Add useActiveModelIsGguf() (looks up the active checkpoint in
the chat runtime store) and a correction effect that steers the auto-pick
away from codex unless the loaded model qualifies, without ever touching a
choice the user made by hand.
- Detection runs via shutil.which on the Studio backend host, which isn't
the same machine as the browser in a tunnel/remote session. Reword the
'installed'/'detected' copy to say so explicitly when the tunnel URL is
in use, instead of implying the check ran on the viewer's own device.
* Rework auto-default per review: loopback gating + inline GGUF check
Replaces the previous approach with the exact shape discussed on the PR:
- Export isLoopbackHost/normalizeHost from agent-command.ts. The detection
endpoint runs shutil.which on the Studio backend, which only describes the
browser's own machine when the base this panel targets resolves to
loopback. For a LAN or tunnel/remote base, gate the whole thing off --
don't mark anything as "detected" and don't let it drive the default --
instead of just relabeling the copy.
- Drop the separate GGUF-correction effect and useActiveModelIsGguf hook.
Read useChatRuntimeStore.getState().activeGgufVariant inline inside the
existing detection effect's .then() (so it doesn't need to sit in the
effect's deps), and pick the first detected agent that isn't codex unless
the loaded model is GGUF, leaving the existing default untouched when no
compatible agent is detected.
Verified both branches (loopback vs LAN/tunnel base, gguf vs non-gguf,
manual pick preserved, no-compatible-agent fallback) with a standalone
port of the .then() logic.
* Address latest Codex findings: stale detection, model swap, cache
- Clear detectedAgents (and skip the network call entirely) when the panel
leaves a loopback base, instead of leaving a previous loopback detection
result marked 'installed' for a command that now targets a LAN/tunnel/
remote host.
- Add a separate, network-free correction effect keyed on the live
activeGgufVariant: if codex was auto-picked while a GGUF model was loaded
and the user then switches to a transformers-backed model while this panel
stays mounted, steer away from codex instead of leaving a command that
unsloth_cli's _require_gguf_for_codex will now reject. Never touches a
manual pick.
- Drop coding-agents.ts's module-lifetime cache. Installed-CLI detection is
environment state, not a persisted setting, so a stale positive/negative
from before the user installed something (or reopened the tab) is worse
than one extra cheap local API call per mount; keep only the in-flight
de-dupe for concurrent callers.
Verified the correction-effect logic (gguf->non-gguf swap with/without a
fallback, still-gguf no-op, manual pick never overridden) with a standalone
port of the effect.
* Make the codex/GGUF auto-pick symmetric in both directions
The correction effect only steered away from codex when the model stopped
being GGUF; it never steered back toward codex if the model became GGUF
*after* a non-GGUF-gated fallback had already picked something else (e.g.
codex is the only detected CLI, a transformers model is loaded so the
selection correctly falls back to the claude default, then the user loads a
GGUF model while the panel stays mounted -- codex never gets reconsidered).
Consolidate into one effect that re-derives the preferred detected agent
from scratch whenever detectedAgents or activeGgufVariant changes, in either
direction, instead of only reacting to the codex-specific downgrade case.
The fetch effect now only populates detectedAgents/availableAgents; this
effect is the single source of truth for what gets auto-picked from that
list. Never overrides a manual choice.
Verified both transition directions plus the manual-pick-survives and
initial-detection cases with a standalone port of the derivation logic.
* Reset the auto-pick to the default when it stops being trustworthy
Two more real gaps from the latest Codex pass on d988f52:
- The unified derivation effect only handled the case where a *different*
detected agent could take over. If codex was the only detected agent and
auto-picked while a GGUF model was loaded, then the model stopped being
GGUF, 'preferred' came back undefined and the effect silently left the
selection on codex -- exactly the command unsloth_cli's
_require_gguf_for_codex now rejects. Fall back to DEFAULT_AGENT in that
case instead of leaving it untouched.
- Leaving a loopback base cleared detectedAgents (so the 'installed' badges
correctly disappear) but left whatever agent had been auto-picked from
that now-stale, server-side-only detection still selected. Reset to
DEFAULT_AGENT there too, unless the user picked by hand.
Introduces a shared DEFAULT_AGENT constant instead of repeating the "claude"
literal at each reset site. Verified all five cases (both new resets, both
manual-pick-survives variants, and the existing multi-detected-agent
fallback still preferring another compatible agent over resetting) with a
standalone port of the effects.
* Derive GGUF-ness from the actual loaded state, not just the variant string
activeGgufVariant only covers an HF-repo GGUF pick (a specific quant
variant string). A direct local .gguf file -- custom folder, LM
Studio, or drag-drop -- is just as much a GGUF the codex preflight
(unsloth_cli's _require_gguf_for_codex) would accept, but it never has
a "variant" to report, so it read as non-GGUF here even though
/api/inference/status correctly reports is_gguf: true for it. That
mismatch could leave a Codex-only install not auto-selected, or reset
an auto-picked Codex, for a model that actually supports it.
Combined activeGgufVariant with activeNativePathToken (covers the
drag-drop/picked-file case) and ggufContextLength (only ever populated
when the backend last reported is_gguf: true for the active model, see
applyActiveModelStatusToStore) so all three paths a model can be GGUF
through are covered, matching the same is_gguf-or-equivalent check
hasGgufSource already applies to a staged pick elsewhere in this
codebase.
* Clear stale native-path token on a non-GGUF status refresh
When a native (drag-dropped or picked) GGUF was loaded and the backend later
switches to a transformers model outside the UI load path, refresh() adopts the
new /api/inference/status via setCheckpoint and applyActiveModelStatusToStore.
Those reset activeGgufVariant and ggufContextLength but never clear
activeNativePathToken, so the isGguf OR stays true after the switch and a
Codex-only detection auto-selects unsloth start codex for a non-GGUF model its
preflight rejects.
Drop activeNativePathToken in applyActiveModelStatusToStore whenever the status
is non-GGUF. A real GGUF load reports is_gguf: true, so its token is preserved
(the load path owns it); only a non-GGUF status clears it.
* Add the AGPL-3.0 header to the new studio contract test
* Fix/adjust agent detection for PR #6909
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com>
This commit is contained in:
parent
6ef0936180
commit
e86b7874d4
10 changed files with 361 additions and 17 deletions
|
|
@ -3,16 +3,19 @@
|
|||
|
||||
import { getInferenceStatus } from "../api/chat-api";
|
||||
import { mergeBackendRecommendedInference } from "../presets/preset-policy";
|
||||
import { clampReasoningEffortToLevels } from "../provider-capabilities";
|
||||
import {
|
||||
CHAT_REASONING_ENABLED_KEY,
|
||||
loadOptionalBool,
|
||||
type ReasoningEffort,
|
||||
type ReasoningStyle,
|
||||
loadOptionalBool,
|
||||
resolveToolsEnabledOnLoad,
|
||||
useChatRuntimeStore,
|
||||
} from "../stores/chat-runtime-store";
|
||||
import { isMultimodalResponse, type InferenceStatusResponse } from "../types/api";
|
||||
import { clampReasoningEffortToLevels } from "../provider-capabilities";
|
||||
import {
|
||||
type InferenceStatusResponse,
|
||||
isMultimodalResponse,
|
||||
} from "../types/api";
|
||||
import type { ChatModelSummary } from "../types/runtime";
|
||||
|
||||
type LocalReasoningEffort = Extract<ReasoningEffort, "low" | "medium" | "high">;
|
||||
|
|
@ -31,7 +34,10 @@ export function normalizeSpeculativeType(
|
|||
return "ngram";
|
||||
}
|
||||
if (s === "mtp+ngram") return "mtp+ngram";
|
||||
const parts = s.split(",").map((p) => p.trim()).filter(Boolean);
|
||||
const parts = s
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const hasMtp = parts.some((p) => p === "mtp" || p === "draft-mtp");
|
||||
const hasNgram = parts.some(
|
||||
(p) => p === "ngram" || p === "ngram-mod" || p === "ngram-simple",
|
||||
|
|
@ -197,6 +203,12 @@ export function applyActiveModelStatusToStore(
|
|||
ggufContextLength: currentGgufContextLength,
|
||||
ggufMaxContextLength,
|
||||
ggufNativeContextLength,
|
||||
// A non-GGUF status must also drop a stale native-path token: without this the
|
||||
// isGguf OR (activeGgufVariant || activeNativePathToken || ggufContextLength)
|
||||
// stays true after switching from a native GGUF to a transformers model, so a
|
||||
// Codex-only detection would auto-select for a model its preflight rejects. A real
|
||||
// GGUF load reports is_gguf: true, so its token is preserved (the load path owns it).
|
||||
...(status.is_gguf ? {} : { activeNativePathToken: null }),
|
||||
modelRequiresTrustRemoteCode: status.requires_trust_remote_code ?? false,
|
||||
defaultChatTemplate: nextDefaultChatTemplate,
|
||||
loadedIsMultimodal: isMultimodalResponse(status),
|
||||
|
|
@ -245,7 +257,7 @@ export function applyActiveModelStatusToStore(
|
|||
const mid = checkpointId.toLowerCase();
|
||||
if (mid.includes("qwen3.5") || mid.includes("qwen3.6")) {
|
||||
const sizeMatch = mid.match(/(\d+\.?\d*)\s*b/);
|
||||
if (sizeMatch && parseFloat(sizeMatch[1]) < 9) {
|
||||
if (sizeMatch && Number.parseFloat(sizeMatch[1]) < 9) {
|
||||
reasoningDefault = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -281,8 +293,7 @@ export async function tryAdoptServerActiveModel(): Promise<boolean> {
|
|||
}
|
||||
|
||||
// Re-check after the await: keep a checkpoint the user picked meanwhile.
|
||||
const previousCheckpoint =
|
||||
useChatRuntimeStore.getState().params.checkpoint;
|
||||
const previousCheckpoint = useChatRuntimeStore.getState().params.checkpoint;
|
||||
if (previousCheckpoint) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
45
studio/frontend/src/features/settings/api/coding-agents.ts
Normal file
45
studio/frontend/src/features/settings/api/coding-agents.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
export type CodingAgentsInfo = {
|
||||
// Every agent `unsloth start` supports, in the CLI's declared order.
|
||||
agents: string[];
|
||||
// Subset of `agents` whose CLI binary was found on PATH by the backend.
|
||||
detected: string[];
|
||||
};
|
||||
|
||||
type ApiCodingAgentsInfo = {
|
||||
agents: string[];
|
||||
detected: string[];
|
||||
};
|
||||
|
||||
// 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
|
||||
// mount) rather than caching the result across the module's lifetime. Every
|
||||
// fresh call (each time a settings panel mounts) re-checks PATH for real.
|
||||
let inFlightInfo: Promise<CodingAgentsInfo> | null = null;
|
||||
|
||||
function fromApi(info: ApiCodingAgentsInfo): CodingAgentsInfo {
|
||||
return { agents: info.agents, detected: info.detected };
|
||||
}
|
||||
|
||||
async function fetchCodingAgents(): Promise<CodingAgentsInfo> {
|
||||
const res = await authFetch("/api/settings/coding-agents");
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
await readFastApiError(res, "Failed to load installed coding agents"),
|
||||
);
|
||||
}
|
||||
return fromApi(await res.json());
|
||||
}
|
||||
|
||||
export async function loadCodingAgents(): Promise<CodingAgentsInfo> {
|
||||
inFlightInfo ??= fetchCodingAgents().finally(() => {
|
||||
inFlightInfo = null;
|
||||
});
|
||||
return inFlightInfo;
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ const DEFAULT_AGENT = "claude";
|
|||
|
||||
// URL.hostname brackets IPv6 literals (`new URL("http://[::1]:8888").hostname` is
|
||||
// "[::1]"), so strip the brackets before matching the bare "::1" loopback rules below.
|
||||
function normalizeHost(host: string): string {
|
||||
export function normalizeHost(host: string): string {
|
||||
const lower = host.toLowerCase();
|
||||
return lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ function isDefaultLocalHost(host: string): boolean {
|
|||
}
|
||||
|
||||
// Match the CLI auto-mint rule (is_loopback_url): localhost, ::1, and all of 127.0.0.0/8.
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
if (host === "localhost" || host === "::1") return true;
|
||||
const octets = host.split(".");
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { fetchDeviceType, usePlatformStore } from "@/config/env";
|
|||
import { useChatRuntimeStore } from "@/features/chat";
|
||||
import { useT } from "@/i18n";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { Tick02Icon } from "@/lib/tick-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -25,14 +26,15 @@ import {
|
|||
InformationCircleIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { loadCodingAgents } from "../api/coding-agents";
|
||||
import {
|
||||
type OpenAIAutoSwitchSettings,
|
||||
loadOpenAIAutoSwitchSettings,
|
||||
updateOpenAIAutoSwitchSettings,
|
||||
} from "../api/openai-auto-switch";
|
||||
import { buildAgentCommand } from "./agent-command";
|
||||
import { buildAgentCommand, isLoopbackHost, normalizeHost } from "./agent-command";
|
||||
|
||||
type ExampleType =
|
||||
| "curl"
|
||||
|
|
@ -114,6 +116,30 @@ 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.
|
||||
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
|
||||
// stops being compatible) rather than lingering on a stale choice.
|
||||
const DEFAULT_AGENT = "claude";
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
openclaw: "OpenClaw",
|
||||
opencode: "OpenCode",
|
||||
hermes: "Hermes",
|
||||
pi: "Pi",
|
||||
};
|
||||
|
||||
const j = (s: string): string => JSON.stringify(s);
|
||||
const shSingle = (s: string): string => s.replace(/'/g, "'\\''");
|
||||
const psSingle = (s: string): string => s.replace(/'/g, "''");
|
||||
|
|
@ -399,6 +425,17 @@ function useLoadedModelName(): string {
|
|||
}, [checkpoint, ggufVariant]);
|
||||
}
|
||||
|
||||
// Backend PATH detection is only safe in the desktop app, where the UI owns
|
||||
// the local backend. A browser loopback URL may be an SSH/local port forward.
|
||||
function canUseLocalAgentDetection(base: string): boolean {
|
||||
if (!isTauri) return false;
|
||||
try {
|
||||
return isLoopbackHost(normalizeHost(new URL(base).hostname));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SHIKI_THEMES = [unslothLightTheme, unslothDarkTheme] as [
|
||||
typeof unslothLightTheme,
|
||||
typeof unslothDarkTheme,
|
||||
|
|
@ -443,7 +480,18 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
|
|||
const [copied, setCopied] = useState(false);
|
||||
const [copiedUrl, setCopiedUrl] = useState(false);
|
||||
const [copiedAgent, setCopiedAgent] = useState(false);
|
||||
const [agent, setAgent] = useState<string>(DEFAULT_AGENT);
|
||||
const [availableAgents, setAvailableAgents] =
|
||||
useState<string[]>(DEFAULT_AGENTS);
|
||||
const [detectedAgents, setDetectedAgents] = useState<string[]>([]);
|
||||
// True once the user has picked an agent themselves; guards the detection
|
||||
// effect below from clobbering that choice if it resolves afterward.
|
||||
const agentPickedByUserRef = useRef(false);
|
||||
const [useTunnel, setUseTunnel] = useState<boolean>(readUseTunnelPref);
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const base =
|
||||
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
|
||||
const localAgentDetection = canUseLocalAgentDetection(base);
|
||||
// null while loading; the same setting the General tab exposes (shared cache).
|
||||
const [autoSwitch, setAutoSwitch] = useState<OpenAIAutoSwitchSettings | null>(
|
||||
null,
|
||||
|
|
@ -454,6 +502,78 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
|
|||
void fetchDeviceType({ force: true });
|
||||
}, []);
|
||||
|
||||
// Fetching is the only job of this effect: populate availableAgents/
|
||||
// detectedAgents (or clear them). Which agent gets auto-picked from that
|
||||
// list is derived separately below, so it can react to the loaded model
|
||||
// changing too, not just a fresh fetch.
|
||||
useEffect(() => {
|
||||
// Browser loopback URLs can be SSH/local forwards, so only the desktop app
|
||||
// may use backend PATH checks to mark or auto-pick local agents.
|
||||
if (!localAgentDetection) {
|
||||
setDetectedAgents([]);
|
||||
// A previously auto-picked agent was only ever verified against the
|
||||
// Studio backend's PATH, which is meaningless now that this panel no
|
||||
// longer targets a loopback base -- don't leave it selected, but
|
||||
// never touch a choice the user made by hand.
|
||||
if (!agentPickedByUserRef.current) {
|
||||
setAgent(DEFAULT_AGENT);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void loadCodingAgents()
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
setAvailableAgents(info.agents);
|
||||
setDetectedAgents(info.detected);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: keep the default agent list and let the user pick manually.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localAgentDetection]);
|
||||
|
||||
// Single source of truth for the auto-picked agent, re-derived whenever
|
||||
// the detected list or the loaded model's GGUF-ness changes -- in either
|
||||
// direction. `codex` needs a GGUF model (unsloth_cli's
|
||||
// _require_gguf_for_codex exits otherwise), so it's only preferred once
|
||||
// the loaded model actually qualifies; loading a GGUF model *after* a
|
||||
// non-GGUF-gated fallback picked something else re-steers back to codex
|
||||
// just as loading a non-GGUF model steers away from it. Never overrides a
|
||||
// choice the user made by hand.
|
||||
// activeGgufVariant alone only covers an HF-repo GGUF pick (a specific
|
||||
// quant variant string) -- a direct local .gguf file (custom folder /
|
||||
// LM Studio / drag-drop) is just as much a GGUF the codex preflight would
|
||||
// accept, but never has a "variant" to report, and would otherwise read as
|
||||
// non-GGUF here. activeNativePathToken covers the drag-drop/picked-file
|
||||
// case; ggufContextLength is only ever populated when the backend's
|
||||
// /api/inference/status last reported is_gguf: true for the active model
|
||||
// (see applyActiveModelStatusToStore), so together these three cover every
|
||||
// path a model can be GGUF through, matching the same is_gguf-or-equivalent
|
||||
// check hasGgufSource applies to a staged pick.
|
||||
const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant);
|
||||
const activeNativePathToken = useChatRuntimeStore((s) => s.activeNativePathToken);
|
||||
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
|
||||
useEffect(() => {
|
||||
if (agentPickedByUserRef.current) return;
|
||||
if (detectedAgents.length === 0) return;
|
||||
const isGguf =
|
||||
activeGgufVariant != null || activeNativePathToken != null || ggufContextLength != null;
|
||||
const preferred = detectedAgents.find((a) => a !== "codex" || isGguf);
|
||||
if (preferred) {
|
||||
setAgent(preferred);
|
||||
} else if (agent === "codex" && !isGguf) {
|
||||
// codex was auto-picked while a GGUF model was active and it's the
|
||||
// only detected agent; now that the model isn't GGUF anymore, nothing
|
||||
// detected is actually runnable, so fall back to the default instead
|
||||
// of leaving a codex command unsloth_cli will reject.
|
||||
setAgent(DEFAULT_AGENT);
|
||||
}
|
||||
}, [agent, detectedAgents, activeGgufVariant, activeNativePathToken, ggufContextLength]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadOpenAIAutoSwitchSettings()
|
||||
|
|
@ -470,9 +590,6 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
|
|||
|
||||
const model = useLoadedModelName();
|
||||
const key = apiKey || KEY_PLACEHOLDER;
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
const base =
|
||||
useTunnel && cloudflareUrl ? cloudflareUrl : (serverUrl ?? origin);
|
||||
|
||||
const autoSwitchOn = autoSwitch?.enabled ?? false;
|
||||
const snippets = useMemo(
|
||||
|
|
@ -481,8 +598,8 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
|
|||
);
|
||||
// Agent command must target the server the panel shows, not the :8888 default.
|
||||
const agentCommand = useMemo(
|
||||
() => buildAgentCommand(base, key, os),
|
||||
[base, key, os],
|
||||
() => buildAgentCommand(base, key, os, agent),
|
||||
[base, key, os, agent],
|
||||
);
|
||||
|
||||
const osAware = OS_AWARE[lang];
|
||||
|
|
@ -710,6 +827,42 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
|
|||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("settings.apiKeys.codingAgentsHint")}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{availableAgents.map((id) => {
|
||||
const installed = detectedAgents.includes(id);
|
||||
const active = agent === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
agentPickedByUserRef.current = true;
|
||||
setAgent(id);
|
||||
}}
|
||||
aria-pressed={active}
|
||||
title={
|
||||
installed
|
||||
? t("settings.apiKeys.codingAgentDetected")
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "hub-tab-toggle-pill text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{AGENT_LABELS[id] ?? id}
|
||||
{installed ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-1.5 rounded-full bg-emerald-500"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative mt-0.5 min-w-0">
|
||||
<code className="block min-w-0 overflow-x-auto rounded border border-border bg-muted/30 px-2 py-1.5 pr-14 font-mono text-[11px] text-foreground">
|
||||
{agentCommand}
|
||||
|
|
@ -727,7 +880,13 @@ export function UsageExamples({ apiKey }: { apiKey?: string | null }) {
|
|||
</button>
|
||||
</div>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("settings.apiKeys.codingAgentsSwap")}
|
||||
{detectedAgents.length > 0
|
||||
? t("settings.apiKeys.codingAgentsDetectedHint", {
|
||||
agents: detectedAgents
|
||||
.map((id) => AGENT_LABELS[id] ?? id)
|
||||
.join(", "),
|
||||
})
|
||||
: t("settings.apiKeys.codingAgentsSwap")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -445,6 +445,8 @@ export const en = {
|
|||
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.",
|
||||
codingAgentDetected: "Installed on this machine",
|
||||
codingAgentsDetectedHint: "Detected on this machine: {agents}.",
|
||||
relativeNever: "never",
|
||||
relativeJustNow: "just now",
|
||||
relativeHoursAgo: "{count}h ago",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue