feat(i18n): localize Settings → Agents (#2193)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-03 12:16:25 +02:00
committed by GitHub
parent fc48311d0f
commit b6848712f8
34 changed files with 1359 additions and 512 deletions
+4 -2
View File
@@ -1,11 +1,13 @@
"use client";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { Card, CardContent } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
export default function AgentEditPage() {
const { t } = useTranslation();
const router = useRouter();
useEffect(() => {
@@ -15,9 +17,9 @@ export default function AgentEditPage() {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Manage agents from the main Agents page.</p>
<p className="text-sm text-muted-foreground">{t("agents:manageAgentsFromMainPage")}</p>
<Button className="mt-4" onClick={() => router.push("/settings/agents")}>
Go to Agents
{t("agents:goToAgents")}
</Button>
</CardContent>
</Card>
@@ -17,8 +17,13 @@ import type {
import { arePermissionsDirty, permissionsToProfilePatch } from "@/lib/agent-permissions";
import { areCLIFlagsEqual } from "@/lib/cli-flags";
import { areConfigOptionsEqual } from "@/lib/config-options";
import { t } from "@/lib/i18n";
import type { ProfileFormData } from "@/components/settings/profile-form-fields";
// The JSON key the MCP editor validates against — an identifier, interpolated
// into the parse error rather than written into the catalog.
const MCP_SERVERS_KEY = "mcpServers";
/**
* Translates a ProfileFormData patch (snake_case form keys) into a
* Partial<AgentProfile> (camelCase). Profiles in client state use the
@@ -82,12 +87,12 @@ export const parseProfileMcpServers = (raw: string): Record<string, McpServerDef
if (!raw.trim()) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("MCP servers config must be a JSON object");
throw new Error(t("agents:mcpConfigMustBeJsonObject"));
}
if ("mcpServers" in parsed) {
if (MCP_SERVERS_KEY in parsed) {
const nested = (parsed as { mcpServers?: unknown }).mcpServers;
if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
throw new Error("mcpServers must be a JSON object");
throw new Error(t("agents:mcpKeyMustBeJsonObject", { key: MCP_SERVERS_KEY }));
}
return nested as Record<string, McpServerDef>;
}
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
AlertDialog,
@@ -61,19 +62,18 @@ export function AgentHeader({
savedAgent,
onDelete,
}: AgentHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between gap-6">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-bold">{displayName}</h2>
<span className="text-xs text-muted-foreground border border-muted-foreground/30 rounded-full px-2 py-1">
{matchedPath ?? "Installation not detected"}
{matchedPath ?? t("agents:installationNotDetected")}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
{isCreateMode
? "Create a new profile for this agent."
: "Configure profiles and defaults for this agent."}
{isCreateMode ? t("agents:createProfileIntro") : t("agents:configureProfilesIntro")}
</p>
</div>
{savedAgent?.tui_config && onDelete && (
@@ -81,21 +81,20 @@ export function AgentHeader({
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" className="cursor-pointer">
<IconTrash className="h-4 w-4 mr-2" />
Delete Agent
{t("agents:deleteAgent")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove the agent and all its profiles. This action cannot be
undone.
</AlertDialogDescription>
<AlertDialogTitle>
{t("agents:deleteAgentTitle", { name: displayName })}
</AlertDialogTitle>
<AlertDialogDescription>{t("agents:deleteAgentDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onDelete} className="cursor-pointer">
Delete
{t("agents:delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -221,15 +220,18 @@ export function ProfilesCard({
onRemoveProfile,
onToastError,
}: ProfilesCardProps) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={isAgentDirty}>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>
{isCreateMode ? `Create ${displayName} Profile` : `${displayName} Profiles`}
{isCreateMode
? t("agents:createAgentProfileTitle", { name: displayName })
: t("agents:agentProfilesTitle", { name: displayName })}
</CardTitle>
<Button size="sm" variant="outline" onClick={onAddProfile} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-2" />
Add profile
{t("agents:addProfile")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { useParams, useRouter, useSearchParams } from "@/lib/routing/client-router";
import { Button } from "@kandev/ui/button";
@@ -207,17 +208,18 @@ function useAgentSaveHandlers({
onToastError,
replaceRoute,
}: AgentSaveHandlersProps) {
const { t } = useTranslation();
const handleSave = async () => {
if (draftAgent.profiles.some((p) => !p.name.trim())) {
onToastError(new Error("Profile name is required."));
onToastError(new Error(t("agents:profileNameRequired")));
return;
}
if (draftAgent.profiles.some((p) => !p.model.trim())) {
onToastError(new Error("Model is required for all profiles."));
onToastError(new Error(t("agents:modelRequiredForAllProfiles")));
return;
}
if (hasInvalidMcpConfig) {
onToastError(new Error("Fix invalid MCP JSON before saving."));
onToastError(new Error(t("agents:fixInvalidMcpJson")));
return;
}
setSaveStatus("loading");
@@ -367,6 +369,7 @@ function AgentSetupForm({
onToastError,
isCreateMode = false,
}: AgentSetupFormProps) {
const { t } = useTranslation();
const router = useRouter();
const availableAgents = useAvailableAgents().items;
const { upsertAgent } = useAgentStoreSync();
@@ -417,8 +420,8 @@ function AgentSetupForm({
};
const profilesValid = areAgentProfilesValid(draftAgent);
let saveInvalidReason: string | undefined;
if (!profilesValid) saveInvalidReason = "Every profile needs a name and model.";
else if (hasInvalidMcpConfig) saveInvalidReason = "Fix invalid MCP configuration before saving.";
if (!profilesValid) saveInvalidReason = t("agents:everyProfileNeedsNameAndModel");
else if (hasInvalidMcpConfig) saveInvalidReason = t("agents:fixInvalidMcpConfig");
useSettingsSaveContributor({
id: `agent:${draftAgent.id}`,
revision: saveRevision.revision,
@@ -462,6 +465,7 @@ function AgentSetupForm({
}
export default function AgentSetupPage() {
const { t } = useTranslation();
const { toast } = useToast();
const params = useParams();
const searchParams = useSearchParams();
@@ -531,9 +535,9 @@ export default function AgentSetupPage() {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Agent not found.</p>
<p className="text-sm text-muted-foreground">{t("agents:agentNotFound")}</p>
<Button className="mt-4" asChild>
<Link href="/settings/agents">Back to Agents</Link>
<Link href="/settings/agents">{t("agents:backToAgents")}</Link>
</Button>
</CardContent>
</Card>
@@ -544,8 +548,8 @@ export default function AgentSetupPage() {
const handleToastError = (error: unknown) => {
toast({
title: "Failed to save agent",
description: error instanceof Error ? error.message : "Request failed",
title: t("agents:failedToSaveAgent"),
description: error instanceof Error ? error.message : t("agents:requestFailed"),
variant: "error",
});
};
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Label } from "@kandev/ui/label";
import { Switch } from "@kandev/ui/switch";
@@ -7,6 +8,10 @@ import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
// `validateDraftServers` runs outside React (from an onChange handler and from
// the parent's draft state), so it uses the module-level `t`, which resolves at
// call time. Components in this file use the `useTranslation()` hook.
import { t as translate } from "@/lib/i18n";
import { useProfileMcpConfig } from "./use-profile-mcp-config";
import type { AgentProfileMcpConfig } from "@/lib/types/http";
@@ -69,8 +74,24 @@ const POPULAR_SERVERS: Record<string, Record<string, unknown>> = {
},
};
const KANDEV_TOOLS_DESCRIPTION =
"Tools: list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task";
// Tool names and MCP-server product names are identifiers, not copy: they are
// interpolated as values so the pseudo-locale cannot turn them into something
// the user cannot type or look up.
const KANDEV_MCP_NAME = "Kandev MCP";
const KANDEV_TOOL_NAMES =
"list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task";
const POPULAR_SERVER_NAMES: Record<string, string> = {
playwright: "Playwright MCP",
"chrome-devtools": "Chrome DevTools MCP",
context7: "Context7 MCP",
github: "GitHub MCP",
};
// The JSON key the editor validates against. Interpolated into the error
// messages below rather than written into the catalog.
const MCP_SERVERS_KEY = "mcpServers";
type PopularServerButtonProps = {
label: string;
@@ -129,16 +150,16 @@ function validateDraftServers(value: string): string | null {
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "MCP servers config must be a JSON object";
return translate("agents:mcpConfigMustBeJsonObject");
}
if ("mcpServers" in parsed) {
if (MCP_SERVERS_KEY in parsed) {
const nested = (parsed as { mcpServers?: unknown }).mcpServers;
if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
return "mcpServers must be a JSON object";
return translate("agents:mcpKeyMustBeJsonObject", { key: MCP_SERVERS_KEY });
}
}
} catch {
return "Invalid JSON";
return translate("agents:invalidJson");
}
return null;
}
@@ -150,10 +171,11 @@ function PassthroughMcpInjectionHint({
cliPassthrough?: boolean;
mcpInjection?: string;
}) {
const { t } = useTranslation();
if (!cliPassthrough || !mcpInjection) return null;
return (
<p className="text-xs text-muted-foreground">
In CLI passthrough mode, kandev injects these MCP servers via {mcpInjection}.
{t("agents:mcpPassthroughInjectionHint", { mechanism: mcpInjection })}
</p>
);
}
@@ -183,6 +205,7 @@ function McpServersEditor({
onDraftStateChange,
handleMcpServersChange,
}: McpServersEditorProps) {
const { t } = useTranslation();
const handleApplyServer = (label: string) => {
applyPopularServerToJson(
currentServers,
@@ -201,7 +224,7 @@ function McpServersEditor({
return (
<div className="space-y-2">
<Label htmlFor={`mcp-servers-${profileId}`}>MCP servers (JSON)</Label>
<Label htmlFor={`mcp-servers-${profileId}`}>{t("agents:mcpServersJson")}</Label>
<Textarea
id={`mcp-servers-${profileId}`}
className="min-h-[200px] font-mono text-xs"
@@ -217,43 +240,32 @@ function McpServersEditor({
data-settings-dirty={isDirty}
data-testid={`mcp-servers-${profileId}`}
/>
<p className="text-xs text-muted-foreground">
MCP definitions are stored in the database and resolved per executor at runtime. This does
not override your local agent config.
</p>
<p className="text-xs text-muted-foreground">{t("agents:mcpServersHelp")}</p>
<PassthroughMcpInjectionHint cliPassthrough={cliPassthrough} mcpInjection={mcpInjection} />
<p className="text-xs font-medium text-muted-foreground">Built-in</p>
<p className="text-xs font-medium text-muted-foreground">{t("agents:builtIn")}</p>
<div className="flex flex-wrap gap-2 mb-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs rounded-full border border-primary/50 bg-primary/10 px-2 py-1 text-primary">
✓ Kandev MCP
{t("agents:kandevMcpChip", { name: KANDEV_MCP_NAME })}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[320px] text-xs">
<p className="font-medium mb-1">Automatically available</p>
<p>{KANDEV_TOOLS_DESCRIPTION}</p>
<p className="font-medium mb-1">{t("agents:automaticallyAvailable")}</p>
<p>{t("agents:kandevMcpTools", { tools: KANDEV_TOOL_NAMES })}</p>
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs font-medium text-muted-foreground">Popular servers</p>
<p className="text-xs font-medium text-muted-foreground">{t("agents:popularServers")}</p>
<div className="flex flex-wrap gap-2">
<PopularServerButton
label="playwright"
displayName="Playwright MCP"
onApply={handleApplyServer}
/>
<PopularServerButton
label="chrome-devtools"
displayName="Chrome DevTools MCP"
onApply={handleApplyServer}
/>
<PopularServerButton
label="context7"
displayName="Context7 MCP"
onApply={handleApplyServer}
/>
<PopularServerButton label="github" displayName="GitHub MCP" onApply={handleApplyServer} />
{Object.keys(POPULAR_SERVERS).map((label) => (
<PopularServerButton
key={label}
label={label}
displayName={POPULAR_SERVER_NAMES[label] ?? label}
onApply={handleApplyServer}
/>
))}
</div>
{currentError && <p className="text-sm text-destructive">{currentError}</p>}
</div>
@@ -320,6 +332,7 @@ function McpEnableToggle({
onDraftStateChange,
setMcpEnabled,
}: McpEnableToggleProps) {
const { t } = useTranslation();
return (
<div
className="flex items-center justify-between rounded-md border p-3"
@@ -328,10 +341,8 @@ function McpEnableToggle({
data-testid="mcp-enabled-row"
>
<div className="space-y-1">
<Label>Enable MCP</Label>
<p className="text-xs text-muted-foreground">
Allow this profile to use MCP servers during sessions.
</p>
<Label>{t("agents:enableMcp")}</Label>
<p className="text-xs text-muted-foreground">{t("agents:enableMcpDescription")}</p>
</div>
<Switch
checked={currentEnabled}
@@ -357,12 +368,11 @@ function McpProfileHint({
isDraft: boolean;
isEditableProfile: boolean;
}) {
const { t } = useTranslation();
if (isEditableProfile) return null;
return (
<p className="text-xs text-muted-foreground">
{isDraft
? "MCP config will be applied after the profile is saved."
: "Save this profile to configure MCP servers."}
{isDraft ? t("agents:mcpDraftHint") : t("agents:mcpSaveProfileHint")}
</p>
);
}
@@ -377,6 +387,7 @@ export function ProfileMcpConfigCard({
onDraftStateChange,
onToastError,
}: ProfileMcpConfigCardProps) {
const { t } = useTranslation();
const {
mcpEnabled,
mcpServers,
@@ -416,7 +427,7 @@ export function ProfileMcpConfigCard({
return (
<SettingsCard isDirty={state.currentDirty}>
<CardHeader>
<CardTitle>MCP Configuration</CardTitle>
<CardTitle>{t("agents:mcpConfiguration")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<McpProfileHint isDraft={state.isDraft} isEditableProfile={state.isEditableProfile} />
@@ -70,4 +70,49 @@ describe("CommandPreviewCard", () => {
expect(screen.queryByText("agent --stale")).not.toBeTruthy();
});
});
// The `{prompt}` hint is a <Trans>: its <0> index addresses the JSX children
// positionally, so a prettier reflow can silently reassemble the sentence
// into fragments without failing anything. Assert the whole reconstructed
// sentence, and that the token itself is never translated or re-interpolated.
it("renders the {prompt} hint as one sentence with the token intact", async () => {
previewAgentCommandActionMock.mockImplementation(async () => response("agent run {prompt}"));
render(
<CommandPreviewCard
agentName="claude"
model="claude-sonnet-4-5"
permissionSettings={{}}
cliPassthrough={false}
cliFlags={[]}
commandPrefix=""
/>,
);
const hint = await screen.findByText(
(_content, element) =>
element?.tagName === "P" &&
element.textContent ===
"{prompt} will be replaced with your task description or follow-up message.",
);
expect(hint.querySelector("code")?.textContent).toBe("{prompt}");
});
it("omits the {prompt} hint in CLI passthrough mode", async () => {
previewAgentCommandActionMock.mockImplementation(async () => response("agent"));
render(
<CommandPreviewCard
agentName="claude"
model="claude-sonnet-4-5"
permissionSettings={{}}
cliPassthrough
cliFlags={[]}
commandPrefix=""
/>,
);
await waitFor(() => expect(screen.getByText("agent")).toBeTruthy());
expect(screen.queryByText(/will be replaced with your task description/)).toBeNull();
});
});
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState, useMemo, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { IconCopy, IconCheck, IconTerminal2 } from "@tabler/icons-react";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
@@ -20,6 +21,11 @@ type CommandPreviewCardProps = {
secrets?: { id: string; name: string }[];
};
// The placeholder the backend substitutes into the launch command. A token the
// user reads verbatim in the preview, so it is interpolated as a value rather
// than written into the catalog.
const PROMPT_TOKEN = "{prompt}";
/**
* POSIX-style single-quote escaping: wrap in single quotes and escape
* embedded single quotes via `'\''`. Used so the previewed env-var prefix
@@ -65,9 +71,10 @@ function CommandPreviewError({ error }: { error: string }) {
}
function CommandPreviewEmpty() {
const { t } = useTranslation();
return (
<div className="rounded-md border border-muted p-4">
<p className="text-sm text-muted-foreground">No command preview available.</p>
<p className="text-sm text-muted-foreground">{t("agents:noCommandPreview")}</p>
</div>
);
}
@@ -79,6 +86,7 @@ type CommandPreviewContentProps = {
};
function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPreviewContentProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const displayCommand = `${envPrefix}${preview.command_string ?? ""}`;
@@ -102,7 +110,7 @@ function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPr
size="sm"
className="absolute right-2 top-2"
onClick={handleCopy}
title="Copy to clipboard"
title={t("agents:copyToClipboard")}
>
{copied ? (
<IconCheck className="h-4 w-4 text-green-500" />
@@ -114,8 +122,10 @@ function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPr
{!cliPassthrough && (
<p className="text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">{"{prompt}"}</code> will be replaced with
your task description or follow-up message.
<Trans i18nKey="agents:promptTokenHint" values={{ token: PROMPT_TOKEN }}>
<code className="rounded bg-muted px-1 py-0.5">{PROMPT_TOKEN}</code> will be replaced
with your task description or follow-up message.
</Trans>
</p>
)}
</>
@@ -132,6 +142,7 @@ export function CommandPreviewCard({
envVars,
secrets,
}: CommandPreviewCardProps) {
const { t } = useTranslation();
const envPrefix = useMemo(() => buildEnvPrefix(envVars, secrets), [envVars, secrets]);
const [preview, setPreview] = useState<CommandPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -166,7 +177,7 @@ export function CommandPreviewCard({
setError(null);
} catch (err) {
if (requestId !== latestRequestId.current) return;
setError(err instanceof Error ? err.message : "Failed to load command preview");
setError(err instanceof Error ? err.message : t("agents:failedToLoadCommandPreview"));
setPreview(null);
} finally {
if (requestId === latestRequestId.current) setLoading(false);
@@ -182,13 +193,11 @@ export function CommandPreviewCard({
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconTerminal2 className="h-5 w-5" />
<span>Command Preview</span>
<span>{t("agents:commandPreview")}</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
The CLI command that will be executed based on the current settings.
</p>
<p className="text-xs text-muted-foreground">{t("agents:commandPreviewHelp")}</p>
{loading && <CommandPreviewLoading />}
{error && <CommandPreviewError error={error} />}
@@ -5,6 +5,7 @@ import {
getAgentProfileMcpConfigAction,
updateAgentProfileMcpConfigAction,
} from "@/app/actions/agents";
import { t } from "@/lib/i18n";
import type { AgentProfileMcpConfig, McpServerDef } from "@/lib/types/http";
type McpStatus = "idle" | "loading" | "success" | "error";
@@ -31,6 +32,9 @@ type UseProfileMcpConfigResult = {
};
const EMPTY_EXAMPLE = '{\n "mcpServers": {}\n}';
// The JSON key the editor validates against — an identifier, interpolated into
// the error messages below rather than written into the catalog.
const MCP_SERVERS_KEY = "mcpServers";
const isEmptyExample = (value: string) => value.trim() === EMPTY_EXAMPLE.trim();
type McpStateSetters = {
@@ -51,12 +55,12 @@ function serializeServers(config: AgentProfileMcpConfig | null): string {
function normalizeServers(value: unknown): Record<string, McpServerDef> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("MCP servers config must be a JSON object");
throw new Error(t("agents:mcpConfigMustBeJsonObject"));
}
if ("mcpServers" in value) {
if (MCP_SERVERS_KEY in value) {
const nested = (value as { mcpServers?: unknown }).mcpServers;
if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
throw new Error("mcpServers must be a JSON object");
throw new Error(t("agents:mcpKeyMustBeJsonObject", { key: MCP_SERVERS_KEY }));
}
return nested as Record<string, McpServerDef>;
}
@@ -113,7 +117,9 @@ function useFetchConfig(
})
.catch((error) => {
if (!active) return;
setters.setMcpError(error instanceof Error ? error.message : "Failed to load MCP config");
setters.setMcpError(
error instanceof Error ? error.message : t("agents:failedToLoadMcpConfig"),
);
setters.setMcpStatus("error");
});
@@ -196,7 +202,7 @@ export function useProfileMcpConfig({
normalizeServers(parsed);
setMcpError(null);
} catch {
setMcpError("Invalid JSON");
setMcpError(t("agents:invalidJson"));
}
};
@@ -213,7 +219,7 @@ export function useProfileMcpConfig({
servers = normalizeServers(parsed);
} catch (error) {
setMcpStatus("error");
setMcpError(error instanceof Error ? error.message : "Invalid MCP config");
setMcpError(error instanceof Error ? error.message : t("agents:invalidMcpConfig"));
throw error;
}
+24 -23
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import {
IconAlertTriangle,
@@ -66,13 +67,14 @@ function CopyButton({
copiedValue: string | null;
onCopy: (text: string) => void;
}) {
const { t } = useTranslation();
const isCopied = copiedValue === text;
return (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 cursor-pointer shrink-0"
aria-label={isCopied ? "Copied" : "Copy install command"}
aria-label={isCopied ? t("agents:copied") : t("agents:copyInstallCommand")}
onClick={() => onCopy(text)}
>
{isCopied ? (
@@ -123,6 +125,7 @@ function ToolInstallCard({
copiedValue: string | null;
onCopy: (text: string) => void;
}) {
const { t } = useTranslation();
return (
<Card className="border-dashed">
<CardContent className="py-4 flex flex-col gap-2">
@@ -132,7 +135,7 @@ function ToolInstallCard({
{tool.available && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<IconCheck className="h-3.5 w-3.5" />
Installed
{t("agents:installed")}
</span>
)}
</div>
@@ -211,13 +214,12 @@ function InstalledAgentsHeader({
onOpenTuiDialog: () => void;
onRescan: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-lg font-semibold">Installed Agents</h3>
<p className="text-sm text-muted-foreground">
Agents detected on this machine are ready to configure.
</p>
<h3 className="text-lg font-semibold">{t("agents:installedAgents")}</h3>
<p className="text-sm text-muted-foreground">{t("agents:installedAgentsDescription")}</p>
</div>
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
<Button
@@ -228,11 +230,11 @@ function InstalledAgentsHeader({
data-testid="open-host-shell"
>
<IconTerminal2 className="h-4 w-4 mr-2" />
Terminal
{t("agents:terminal")}
</Button>
<Button variant="outline" size="sm" onClick={onOpenTuiDialog} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-2" />
Add TUI Agent
{t("agents:addTuiAgent")}
</Button>
<Button
variant="outline"
@@ -246,7 +248,7 @@ function InstalledAgentsHeader({
) : (
<IconRefresh className="h-4 w-4 mr-2" />
)}
Rescan
{t("agents:rescan")}
</Button>
</div>
</div>
@@ -268,6 +270,7 @@ function InstalledAgentsSection({
setTuiDialogOpen,
handleRescan,
}: InstalledAgentsSectionProps) {
const { t } = useTranslation();
const [shellOpen, setShellOpen] = useState(false);
return (
@@ -293,11 +296,11 @@ function InstalledAgentsSection({
<CardContent className="py-8 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
{discoveryLoading ? (
<span>Scanning for installed agents...</span>
<span>{t("agents:scanningForInstalledAgents")}</span>
) : (
<>
<IconAlertTriangle className="h-4 w-4" />
No installed agents were detected. Install one below, then click Rescan.
{t("agents:noInstalledAgentsDetected")}
</>
)}
</div>
@@ -341,18 +344,16 @@ function SuggestInstallSection({
installJobs: Record<string, InstallJob>;
onInstall: (name: string) => void;
}) {
const notInstalledTools = tools.filter((t) => !t.available);
const { t } = useTranslation();
const notInstalledTools = tools.filter((tool) => !tool.available);
if (notInstalledAgents.length === 0 && notInstalledTools.length === 0) return null;
return (
<div className="space-y-4">
<Separator />
<div>
<h3 className="text-lg font-semibold">Available to Install</h3>
<p className="text-sm text-muted-foreground">
Click Install to run the agent&apos;s install script on the kandev host. Progress streams
live; you can start multiple installs in parallel.
</p>
<h3 className="text-lg font-semibold">{t("agents:availableToInstall")}</h3>
<p className="text-sm text-muted-foreground">{t("agents:availableToInstallDescription")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
@@ -379,6 +380,7 @@ type AgentProfilesSectionProps = {
};
function AgentProfilesSection({ savedAgents }: AgentProfilesSectionProps) {
const { t } = useTranslation();
if (!savedAgents.some((agent: Agent) => agent.profiles.length > 0)) {
return null;
}
@@ -387,8 +389,8 @@ function AgentProfilesSection({ savedAgents }: AgentProfilesSectionProps) {
<div className="space-y-4">
<Separator />
<div>
<h3 className="text-lg font-semibold">Agent Profiles</h3>
<p className="text-sm text-muted-foreground">Manage existing profiles by agent.</p>
<h3 className="text-lg font-semibold">{t("agents:agentProfiles")}</h3>
<p className="text-sm text-muted-foreground">{t("agents:agentProfilesDescription")}</p>
</div>
<div className="space-y-2">
@@ -586,14 +588,13 @@ export default function AgentsSettingsPage() {
startUpdate,
} = useAgentPageState();
const { copiedValue, copy } = useCopyCommand();
const { t } = useTranslation();
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">Agents</h2>
<p className="text-sm text-muted-foreground mt-1">
Discover installed agents, install new ones, and manage their profiles.
</p>
<h2 className="text-2xl font-bold">{t("common:agents")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("agents:pageDescription")}</p>
</div>
<Separator />
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { IconRobot } from "@tabler/icons-react";
import { AgentLogo } from "@/components/agent-logo";
import { useAppStore } from "@/components/state-provider";
@@ -15,12 +16,13 @@ type AgentsGroupProps = {
};
export function AgentsGroup({ pathname, expanded, onToggle }: AgentsGroupProps) {
const { t } = useTranslation();
const agents = useAppStore((s) => s.settingsAgents.items);
useAvailableAgents();
return (
<SettingsGroup
label="Agents"
label={t("common:agents")}
icon={IconRobot}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
@@ -1,6 +1,7 @@
"use client";
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
@@ -246,11 +247,12 @@ function ConfigOptionSubSelector({
onBack: () => void;
onChange?: (configId: string, value: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex min-h-0 flex-col gap-2">
<button
type="button"
aria-label={`Back to model settings from ${option.name}`}
aria-label={t("agents:backToModelSettings", { name: option.name })}
autoFocus
className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs/relaxed hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:outline-none"
onClick={onBack}
@@ -336,6 +338,7 @@ function ModelConfigSelectorContent({
onConfigBack,
onConfigChange,
}: ModelConfigSelectorContentProps) {
const { t } = useTranslation();
const pendingFocusConfigId = useRef<string | null>(null);
const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const showModelFilter = modelOptions.length > 5;
@@ -368,10 +371,10 @@ function ModelConfigSelectorContent({
return (
<>
<Command>
{showModelFilter && <CommandInput placeholder="Filter models..." className="h-8" />}
{showModelFilter && <CommandInput placeholder={t("agents:filterModels")} className="h-8" />}
<CommandList className="max-h-60">
<CommandEmpty>No models found.</CommandEmpty>
<CommandGroup heading="Model">
<CommandEmpty>{t("agents:noModelsFound")}</CommandEmpty>
<CommandGroup heading={t("agents:modelHeading")}>
{modelOptions.map((model) => (
<ModelRow
key={model.id}
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
afterEach(cleanup);
function renderDialog(onSubmit = vi.fn()) {
return render(<AddTUIAgentDialog open onOpenChange={vi.fn()} onSubmit={onSubmit} />);
}
describe("AddTUIAgentDialog", () => {
// The command help is a <Trans>: its <0> index addresses the JSX children
// positionally, so a prettier reflow can silently reassemble the sentence
// into fragments without failing anything.
it("renders the command help as one sentence with the {{model}} token intact", () => {
renderDialog();
const hint = screen.getByText(
(_content, element) =>
element?.tagName === "P" &&
element.textContent ===
"Binary name looked up on PATH. Use {{model}} to insert the model value.",
);
expect(hint.querySelector("code")?.textContent).toBe("{{model}}");
});
// `{{model}}` is a substitution token the user types verbatim. It is passed
// through `t()` as an interpolation *value*, so i18next must not treat it as
// a placeholder of its own and blank it out.
it("keeps the {{model}} token in the command placeholder", () => {
renderDialog();
expect(screen.getByLabelText("Command").getAttribute("placeholder")).toBe(
"e.g. superclaude --yolo --model {{model}}",
);
});
it("rejects an empty display name without calling onSubmit", () => {
const onSubmit = vi.fn();
renderDialog(onSubmit);
fireEvent.click(screen.getByText("Create"));
expect(screen.getByText("Display name is required")).toBeTruthy();
expect(onSubmit).not.toHaveBeenCalled();
});
it("rejects an empty command without calling onSubmit", () => {
const onSubmit = vi.fn();
renderDialog(onSubmit);
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
fireEvent.click(screen.getByText("Create"));
expect(screen.getByText("Command is required")).toBeTruthy();
expect(onSubmit).not.toHaveBeenCalled();
});
// A non-Error rejection has no `.message`, so the handler falls back to the
// catalog string. Nothing else exercises that branch.
it("surfaces the translated fallback when onSubmit rejects with a non-Error", async () => {
const onSubmit = vi.fn().mockRejectedValue("boom");
renderDialog(onSubmit);
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
fireEvent.click(screen.getByText("Create"));
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
expect(onSubmit).toHaveBeenCalledOnce();
});
});
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { t as translate } from "@/lib/i18n";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -15,6 +17,11 @@ import { Label } from "@kandev/ui/label";
type TUIAgentFormData = { display_name: string; model?: string; command: string };
// The placeholder kandev substitutes into the TUI command. The user types it
// verbatim, so it is interpolated as a value rather than written into the
// catalog. Same for the example command and model names.
const MODEL_TOKEN = "{{model}}";
type AddTUIAgentDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -44,11 +51,11 @@ function useDialogHandlers({
}: DialogHandlersParams) {
const handleSubmit = async () => {
if (!displayName.trim()) {
setError("Display name is required");
setError(translate("agents:displayNameRequired"));
return;
}
if (!command.trim()) {
setError("Command is required");
setError(translate("agents:commandRequired"));
return;
}
setError(null);
@@ -62,7 +69,7 @@ function useDialogHandlers({
reset();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create agent");
setError(err instanceof Error ? err.message : translate("agents:failedToCreateAgent"));
} finally {
setLoading(false);
}
@@ -77,6 +84,7 @@ function useDialogHandlers({
}
export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentDialogProps) {
const { t } = useTranslation();
const [displayName, setDisplayName] = useState("");
const [model, setModel] = useState("");
const [command, setCommand] = useState("");
@@ -106,56 +114,53 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add TUI Agent</DialogTitle>
<DialogDescription>
Register a CLI tool as a TUI agent. It will appear in the agent list with a default
passthrough profile.
</DialogDescription>
<DialogTitle>{t("agents:addTuiAgent")}</DialogTitle>
<DialogDescription>{t("agents:addTuiAgentDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tui-display-name">Display Name</Label>
<Label htmlFor="tui-display-name">{t("agents:displayName")}</Label>
<Input
id="tui-display-name"
placeholder="e.g. superclaude"
placeholder={t("agents:exampleValue", { example: "superclaude" })}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tui-model">Model</Label>
<Label htmlFor="tui-model">{t("agents:model")}</Label>
<Input
id="tui-model"
placeholder="e.g. best"
placeholder={t("agents:exampleValue", { example: "best" })}
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Profile label shown in the agent selector dropdown
</p>
<p className="text-xs text-muted-foreground">{t("agents:tuiModelHelp")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="tui-command">Command</Label>
<Label htmlFor="tui-command">{t("agents:command")}</Label>
<Input
id="tui-command"
placeholder="e.g. superclaude --yolo --model {{model}}"
placeholder={t("agents:exampleValue", {
example: `superclaude --yolo --model ${MODEL_TOKEN}`,
})}
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Binary name looked up on PATH. Use{" "}
<code className="rounded bg-muted px-1 py-0.5">{"{{model}}"}</code> to insert the
model value.
<Trans i18nKey="agents:tuiCommandHelp" values={{ token: MODEL_TOKEN }}>
<code className="rounded bg-muted px-1 py-0.5" />
</Trans>
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
{t("common:cancel")}
</Button>
<Button onClick={handleSubmit} disabled={loading} className="cursor-pointer">
{loading ? "Creating..." : "Create"}
{loading ? t("agents:creating") : t("agents:create")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { startAgentLogin } from "@/lib/api";
import { PtyTerminalDialog, type StartPtySession } from "@/components/settings/pty-terminal-dialog";
@@ -30,6 +31,7 @@ export function AgentLoginDialog({
command,
onLoginSuccess,
}: Props) {
const { t } = useTranslation();
const startSession: StartPtySession = useCallback(
(size, options) => startAgentLogin(agentName, size, options),
[agentName],
@@ -39,7 +41,7 @@ export function AgentLoginDialog({
<PtyTerminalDialog
open={open}
onOpenChange={onOpenChange}
title={`Sign in: ${agentName}`}
title={t("agents:signInToAgent", { name: agentName })}
description={description}
command={command}
testIdPrefix="agent-login"
@@ -119,6 +119,31 @@ describe("AgentProfileDeleteConflictDialog", () => {
expect(screen.queryByText(/Delete Anyway/)).toBeNull();
});
// The watcher `kind` is the wire enum and its label is a catalog key, so a
// renamed key or a dropped entry must fail here rather than silently render
// the raw enum value to the user.
it("resolves every known watcher kind to its label, and echoes an unknown one", () => {
renderConflictDialog({
activeSessions: [],
watchers: [
{ id: "w1", kind: "linear", label: "team ENG" },
{ id: "w2", kind: "jira", label: "project = ENG" },
{ id: "w3", kind: "github_issue", label: "kdlbs/kandev" },
{ id: "w4", kind: "github_review", label: "kdlbs/kandev PRs" },
// A kind the frontend does not know yet — the raw value is echoed
// rather than rendering an empty label.
{ id: "w5", kind: "sentry" as never, label: "proj/backend" },
],
routingTiers: [],
});
expect(screen.getByText(/Linear:/)).toBeTruthy();
expect(screen.getByText(/Jira:/)).toBeTruthy();
expect(screen.getByText(/GitHub Issues:/)).toBeTruthy();
expect(screen.getByText(/GitHub PR Reviews:/)).toBeTruthy();
expect(screen.getByText(/sentry:/)).toBeTruthy();
});
it("does not render the dialog when conflict is null", () => {
renderConflictDialog(null);
@@ -1,5 +1,7 @@
"use client";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import {
AlertDialog,
AlertDialogAction,
@@ -17,11 +19,13 @@ import type {
WatcherReference,
} from "@/lib/types/agent-profile-errors";
const WATCHER_KIND_LABELS: Record<WatcherReference["kind"], string> = {
linear: "Linear",
jira: "Jira",
github_issue: "GitHub Issues",
github_review: "GitHub PR Reviews",
// The watcher `kind` values are the wire enum and are never translated; only
// their labels are copy, so they travel as catalog keys and resolve at render.
const WATCHER_KIND_LABEL_KEYS: Record<WatcherReference["kind"], string> = {
linear: "agents:watcherKindLinear",
jira: "agents:watcherKindJira",
github_issue: "agents:watcherKindGithubIssue",
github_review: "agents:watcherKindGithubReview",
};
type AgentProfileDeleteConfirmDialogProps = {
@@ -35,22 +39,23 @@ export function AgentProfileDeleteConfirmDialog({
onOpenChange,
onConfirm,
}: AgentProfileDeleteConfirmDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete agent profile?</AlertDialogTitle>
<AlertDialogTitle>{t("agents:deleteAgentProfileTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this profile. This action cannot be undone.
{t("agents:deleteAgentProfileDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
{t("agents:delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -78,6 +83,7 @@ export function AgentProfileDeleteConflictDialog({
onOpenChange,
onConfirm,
}: AgentProfileDeleteConflictDialogProps) {
const { t } = useTranslation();
const tasks = conflict?.activeSessions.filter((s) => !s.is_ephemeral) ?? [];
const quickChats = conflict?.activeSessions.filter((s) => s.is_ephemeral) ?? [];
const watchers = conflict?.watchers ?? [];
@@ -92,16 +98,22 @@ export function AgentProfileDeleteConflictDialog({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{hasHardBlockers ? "Cannot delete agent profile" : "Delete agent profile?"}
{hasHardBlockers
? t("agents:cannotDeleteAgentProfile")
: t("agents:deleteAgentProfileTitle")}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>This profile is currently in use. Deleting it will affect the following:</p>
<SessionConflictSection title="Tasks:" sessions={tasks} fallback="Untitled task" />
<p>{t("agents:profileInUseIntro")}</p>
<SessionConflictSection
title="Quick Chats:"
title={t("agents:conflictTasksTitle")}
sessions={tasks}
fallback={t("agents:untitledTask")}
/>
<SessionConflictSection
title={t("agents:conflictQuickChatsTitle")}
sessions={quickChats}
fallback="Untitled quick chat"
fallback={t("agents:untitledQuickChat")}
/>
<WatcherConflictSection watchersByKind={watchersByKind} />
<RoutingTierConflictSection
@@ -110,26 +122,21 @@ export function AgentProfileDeleteConflictDialog({
providerLabels={new Map(providers.map((p) => [p.id, p.name]))}
/>
{hasHardBlockers ? (
<p className="mt-2">
Change these workspace tier mappings before deleting this profile.
</p>
<p className="mt-2">{t("agents:changeTierMappingsFirst")}</p>
) : (
<p className="mt-2">
These sessions will no longer be able to use this profile and the listed watchers
will be disabled. This action cannot be undone.
</p>
<p className="mt-2">{t("agents:deleteAnywayConsequences")}</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
{hasHardBlockers ? null : (
<AlertDialogAction
onClick={onConfirm}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Anyway
{t("agents:deleteAnyway")}
</AlertDialogAction>
)}
</AlertDialogFooter>
@@ -167,16 +174,17 @@ function WatcherConflictSection({
}: {
watchersByKind: Record<string, WatcherReference[]>;
}) {
const { t } = useTranslation();
const entries = Object.entries(watchersByKind);
if (entries.length === 0) return null;
return (
<div className="mt-2">
<p className="font-medium text-sm">Watchers (will be disabled):</p>
<p className="font-medium text-sm">{t("agents:conflictWatchersTitle")}</p>
<ul className="list-disc list-inside mt-1 space-y-0.5">
{entries.map(([kind, items]) => (
<li key={kind} className="text-sm">
<span className="font-medium">
{WATCHER_KIND_LABELS[kind as WatcherReference["kind"]] ?? kind}:
{watcherKindLabel(t, kind as WatcherReference["kind"])}:
</span>{" "}
{items.map((w) => w.label || w.id).join(", ")}
</li>
@@ -195,16 +203,24 @@ function RoutingTierConflictSection({
workspaceLabels: Map<string, string>;
providerLabels: Map<string, string>;
}) {
const { t } = useTranslation();
if (routingTiers.length === 0) return null;
return (
<div className="mt-2">
<p className="font-medium text-sm">Workspace tier mappings:</p>
<p className="font-medium text-sm">{t("agents:conflictTierMappingsTitle")}</p>
<ul className="list-disc list-inside mt-1 space-y-0.5">
{routingTiers.map((ref) => (
<li key={`${ref.workspace_id}-${ref.provider_id}-${ref.tier}`} className="text-sm">
<span className="font-medium">{formatRoutingTier(ref.tier)}</span> tier in{" "}
{formatLookupLabel(workspaceLabels, ref.workspace_id)} for{" "}
{formatLookupLabel(providerLabels, ref.provider_id)}
<Trans
i18nKey="agents:conflictTierMappingRow"
values={{
tier: formatRoutingTier(ref.tier),
workspace: formatLookupLabel(workspaceLabels, ref.workspace_id),
provider: formatLookupLabel(providerLabels, ref.provider_id),
}}
>
<span className="font-medium" />
</Trans>
</li>
))}
</ul>
@@ -212,6 +228,19 @@ function RoutingTierConflictSection({
);
}
/**
* The watcher `kind` is the wire enum; only its label is copy. An unknown kind
* echoes the raw value rather than inventing one.
*/
function watcherKindLabel(t: TFunction, kind: WatcherReference["kind"]): string {
const key = WATCHER_KIND_LABEL_KEYS[kind];
return key ? t(key) : kind;
}
/**
* `tier` is a routing wire value (`fast`, `heavy`, …), not copy — it is
* title-cased for display and never translated.
*/
function formatRoutingTier(tier: string): string {
return tier.charAt(0).toUpperCase() + tier.slice(1);
}
@@ -0,0 +1,229 @@
"use client";
/**
* Draft state, save/delete actions and store synchronisation for the agent
* profile editor. Split out of `agent-profile-page.tsx` so that file stays
* presentation-only and under the 600-line cap.
*
* This module holds no JSX, so `i18next/no-literal-string` never inspects it —
* its toast copy is guarded only by the pseudo-locale.
*/
import { useMemo, useState } from "react";
import { areCLIFlagsEqual } from "@/lib/cli-flags";
import { areConfigOptionsEqual } from "@/lib/config-options";
import { arePermissionsDirty, permissionsToProfilePatch } from "@/lib/agent-permissions";
import { deleteAgentProfileAction, updateAgentProfileAction } from "@/app/actions/agents";
import { useAppStore } from "@/components/state-provider";
import type { useToast } from "@/components/toast-provider";
import type { AgentProfileDeleteConflict } from "@/components/settings/agent-profile-delete-dialog";
import { areEnvVarsEqual } from "@/components/settings/profile-edit/profile-env-vars-section";
import { t as translate } from "@/lib/i18n";
import type { Agent, AgentProfile, PermissionSetting } from "@/lib/types/http";
export type SaveStatus = "idle" | "loading" | "success" | "error";
export function useSyncAgentsToStore() {
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
return (nextAgents: Agent[]) => {
setSettingsAgents(nextAgents);
setAgentProfiles(
nextAgents.flatMap((agentItem) =>
agentItem.profiles.map((agentProfile) => ({
id: agentProfile.id,
label: `${agentProfile.agentDisplayName ?? ""} • ${agentProfile.name}`,
agent_id: agentItem.id,
agent_name: agentItem.name,
cli_passthrough: agentProfile.cliPassthrough ?? false,
})),
),
);
};
}
export function useProfileEditorState(
profile: AgentProfile,
permissionSettings: Record<string, PermissionSetting>,
) {
const [draft, setDraft] = useState<AgentProfile>({ ...profile });
const [savedProfile, setSavedProfile] = useState<AgentProfile>(profile);
const [saveStatus, setSaveStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const isDirty = useMemo(
() =>
draft.name !== savedProfile.name ||
draft.model !== savedProfile.model ||
(draft.mode ?? "") !== (savedProfile.mode ?? "") ||
!areConfigOptionsEqual(draft.configOptions, savedProfile.configOptions) ||
arePermissionsDirty(draft, savedProfile, permissionSettings) ||
draft.cliPassthrough !== savedProfile.cliPassthrough ||
!areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []) ||
(draft.commandPrefix ?? "") !== (savedProfile.commandPrefix ?? "") ||
!areEnvVarsEqual(draft.envVars, savedProfile.envVars),
[draft, savedProfile, permissionSettings],
);
return { draft, setDraft, savedProfile, setSavedProfile, saveStatus, setSaveStatus, isDirty };
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : translate("agents:requestFailed");
}
type ProfileEditorActionsOptions = {
agent: Agent;
draft: AgentProfile;
setSavedProfile: (p: AgentProfile) => void;
setDraft: React.Dispatch<React.SetStateAction<AgentProfile>>;
setSaveStatus: (s: SaveStatus) => void;
settingsAgents: Agent[];
syncAgentsToStore: (agents: Agent[]) => void;
toast: ReturnType<typeof useToast>["toast"];
};
export function useProfileSave({
agent,
draft,
setSavedProfile,
setDraft,
setSaveStatus,
settingsAgents,
syncAgentsToStore,
toast,
}: ProfileEditorActionsOptions) {
return async () => {
if (!draft.name.trim()) {
toast({
title: translate("agents:profileNameRequiredTitle"),
description: translate("agents:profileNameRequiredDescription"),
variant: "error",
});
return;
}
// Model is optional — an empty profile model means "use the agent's
// default", which is applied through ACP session model selection at session start.
setSaveStatus("loading");
try {
const updated = await updateAgentProfileAction(draft.id, {
name: draft.name,
model: draft.model,
mode: draft.mode,
config_options: draft.configOptions ?? {},
...permissionsToProfilePatch(draft),
cli_passthrough: draft.cliPassthrough,
cli_flags: draft.cliFlags,
command_prefix: draft.commandPrefix ?? "",
env_vars: draft.envVars ?? [],
});
setSavedProfile(updated);
setDraft((current) => preserveNewerProfileDraft(current, draft, updated));
const nextAgents = settingsAgents.map((agentItem: Agent) =>
agentItem.id === agent.id
? {
...agentItem,
profiles: agentItem.profiles.map((p: AgentProfile) =>
p.id === updated.id ? updated : p,
),
}
: agentItem,
);
syncAgentsToStore(nextAgents);
setSaveStatus("success");
} catch (error) {
setSaveStatus("error");
toast({
title: translate("agents:failedToSaveProfile"),
description: errorMessage(error),
variant: "error",
});
throw error;
}
};
}
export function preserveNewerProfileDraft(
current: AgentProfile,
submitted: AgentProfile,
saved: AgentProfile,
): AgentProfile {
return current === submitted ? saved : current;
}
export function useProfileDelete(
agent: Agent,
draft: AgentProfile,
settingsAgents: Agent[],
syncAgentsToStore: (agents: Agent[]) => void,
toast: ReturnType<typeof useToast>["toast"],
) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [conflict, setConflict] = useState<AgentProfileDeleteConflict | null>(null);
const removeProfileFromStore = () => {
const nextAgents = settingsAgents.map((agentItem: Agent) =>
agentItem.id === agent.id
? {
...agentItem,
profiles: agentItem.profiles.filter((p: AgentProfile) => p.id !== draft.id),
}
: agentItem,
);
syncAgentsToStore(nextAgents);
window.location.assign("/settings/agents");
};
const requestDelete = () => {
setShowDeleteConfirm(true);
};
const handleDeleteProfile = async () => {
setShowDeleteConfirm(false);
const result = await deleteAgentProfileAction(draft.id);
if (result.status === "ok") {
removeProfileFromStore();
} else if (result.status === "conflict") {
setConflict({
activeSessions: result.activeSessions,
watchers: result.watchers,
routingTiers: result.routingTiers,
});
} else {
toast({
title: translate("agents:failedToDeleteProfile"),
description: result.message,
variant: "error",
});
}
};
const handleForceDelete = async () => {
const result = await deleteAgentProfileAction(draft.id, true);
setConflict(null);
if (result.status === "ok") {
removeProfileFromStore();
} else if (result.status === "conflict") {
setConflict({
activeSessions: result.activeSessions,
watchers: result.watchers,
routingTiers: result.routingTiers,
});
} else if (result.status === "error") {
toast({
title: translate("agents:failedToDeleteProfile"),
description: result.message,
variant: "error",
});
}
};
return {
requestDelete,
showDeleteConfirm,
setShowDeleteConfirm,
handleDeleteProfile,
conflict,
setConflict,
handleForceDelete,
};
}
@@ -1,11 +1,10 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { useParams } from "@/lib/routing/client-router";
import { IconTrash } from "@tabler/icons-react";
import { areCLIFlagsEqual } from "@/lib/cli-flags";
import { areConfigOptionsEqual } from "@/lib/config-options";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -14,13 +13,8 @@ import { useToast } from "@/components/toast-provider";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
import {
arePermissionsDirty,
permissionsToProfilePatch,
profilePermissionValues,
} from "@/lib/agent-permissions";
import { profilePermissionValues } from "@/lib/agent-permissions";
import { toAgentProfilePatch } from "@/app/settings/agents/[agentId]/agent-save-helpers";
import { deleteAgentProfileAction, updateAgentProfileAction } from "@/app/actions/agents";
import {
AgentProfileDeleteConfirmDialog,
AgentProfileDeleteConflictDialog,
@@ -30,12 +24,20 @@ import {
ProfileEnvVarsSection,
areEnvVarsEqual,
} from "@/components/settings/profile-edit/profile-env-vars-section";
import {
errorMessage,
useProfileDelete,
useProfileEditorState,
useProfileSave,
useSyncAgentsToStore,
} from "@/components/settings/agent-profile-page-state";
import { CustomCLIFlagsCard } from "@/components/settings/cli-flags-field";
export {
ProfileEnvVarsEditor,
ProfileEnvVarsSection,
} from "@/components/settings/profile-edit/profile-env-vars-section";
export { preserveNewerProfileDraft } from "@/components/settings/agent-profile-page-state";
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
import type {
Agent,
@@ -60,8 +62,6 @@ type ProfileEditorProps = {
initialMcpConfig?: AgentProfileMcpConfig | null;
};
type SaveStatus = "idle" | "loading" | "success" | "error";
type ProfileEditorHeaderProps = {
agentName: string;
agentDisplayName: string;
@@ -73,6 +73,7 @@ function ProfileEditorHeader({
agentDisplayName,
savedProfileName,
}: ProfileEditorHeaderProps) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between">
<div>
@@ -80,7 +81,9 @@ function ProfileEditorHeader({
<AgentLogo agentName={agentName} size={28} className="shrink-0" />
{agentDisplayName} • {savedProfileName}
</h2>
<p className="text-sm text-muted-foreground mt-1">{agentDisplayName} profile settings</p>
<p className="text-sm text-muted-foreground mt-1">
{t("agents:agentProfileSettings", { name: agentDisplayName })}
</p>
</div>
</div>
);
@@ -91,19 +94,20 @@ type DeleteProfileCardProps = {
};
function DeleteProfileCard({ onDelete }: DeleteProfileCardProps) {
const { t } = useTranslation();
return (
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Delete profile</CardTitle>
<CardTitle className="text-destructive">{t("agents:deleteProfile")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Remove this profile</p>
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
<p className="text-sm font-medium">{t("agents:removeThisProfile")}</p>
<p className="text-xs text-muted-foreground">{t("agents:actionCannotBeUndone")}</p>
</div>
<Button variant="destructive" onClick={onDelete}>
<IconTrash className="h-4 w-4 mr-2" />
Delete
{t("agents:delete")}
</Button>
</CardContent>
</Card>
@@ -131,6 +135,7 @@ function ProfileSettingsCard({
permissionSettings,
passthroughConfig,
}: ProfileSettingsCardProps) {
const { t } = useTranslation();
const handleFormChange = (patch: Partial<ProfileFormData>) => {
onDraftChange(toAgentProfilePatch(patch));
};
@@ -141,7 +146,7 @@ function ProfileSettingsCard({
<SettingsCard isDirty={isDirty}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>Profile settings</span>
<span>{t("agents:profileSettings")}</span>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
</CardTitle>
</CardHeader>
@@ -182,205 +187,6 @@ function ProfileSettingsCard({
);
}
function useSyncAgentsToStore() {
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
return (nextAgents: Agent[]) => {
setSettingsAgents(nextAgents);
setAgentProfiles(
nextAgents.flatMap((agentItem) =>
agentItem.profiles.map((agentProfile) => ({
id: agentProfile.id,
label: `${agentProfile.agentDisplayName ?? ""} • ${agentProfile.name}`,
agent_id: agentItem.id,
agent_name: agentItem.name,
cli_passthrough: agentProfile.cliPassthrough ?? false,
})),
),
);
};
}
function useProfileEditorState(
profile: AgentProfile,
permissionSettings: Record<string, PermissionSetting>,
) {
const [draft, setDraft] = useState<AgentProfile>({ ...profile });
const [savedProfile, setSavedProfile] = useState<AgentProfile>(profile);
const [saveStatus, setSaveStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const isDirty = useMemo(
() =>
draft.name !== savedProfile.name ||
draft.model !== savedProfile.model ||
(draft.mode ?? "") !== (savedProfile.mode ?? "") ||
!areConfigOptionsEqual(draft.configOptions, savedProfile.configOptions) ||
arePermissionsDirty(draft, savedProfile, permissionSettings) ||
draft.cliPassthrough !== savedProfile.cliPassthrough ||
!areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []) ||
(draft.commandPrefix ?? "") !== (savedProfile.commandPrefix ?? "") ||
!areEnvVarsEqual(draft.envVars, savedProfile.envVars),
[draft, savedProfile, permissionSettings],
);
return { draft, setDraft, savedProfile, setSavedProfile, saveStatus, setSaveStatus, isDirty };
}
const FALLBACK_ERROR = "Request failed";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : FALLBACK_ERROR;
}
type ProfileEditorActionsOptions = {
agent: Agent;
draft: AgentProfile;
setSavedProfile: (p: AgentProfile) => void;
setDraft: React.Dispatch<React.SetStateAction<AgentProfile>>;
setSaveStatus: (s: SaveStatus) => void;
settingsAgents: Agent[];
syncAgentsToStore: (agents: Agent[]) => void;
toast: ReturnType<typeof useToast>["toast"];
};
function useProfileSave({
agent,
draft,
setSavedProfile,
setDraft,
setSaveStatus,
settingsAgents,
syncAgentsToStore,
toast,
}: ProfileEditorActionsOptions) {
return async () => {
if (!draft.name.trim()) {
toast({
title: "Profile name is required",
description: "Please enter a profile name before saving.",
variant: "error",
});
return;
}
// Model is optional — an empty profile model means "use the agent's
// default", which is applied through ACP session model selection at session start.
setSaveStatus("loading");
try {
const updated = await updateAgentProfileAction(draft.id, {
name: draft.name,
model: draft.model,
mode: draft.mode,
config_options: draft.configOptions ?? {},
...permissionsToProfilePatch(draft),
cli_passthrough: draft.cliPassthrough,
cli_flags: draft.cliFlags,
command_prefix: draft.commandPrefix ?? "",
env_vars: draft.envVars ?? [],
});
setSavedProfile(updated);
setDraft((current) => preserveNewerProfileDraft(current, draft, updated));
const nextAgents = settingsAgents.map((agentItem: Agent) =>
agentItem.id === agent.id
? {
...agentItem,
profiles: agentItem.profiles.map((p: AgentProfile) =>
p.id === updated.id ? updated : p,
),
}
: agentItem,
);
syncAgentsToStore(nextAgents);
setSaveStatus("success");
} catch (error) {
setSaveStatus("error");
toast({
title: "Failed to save profile",
description: errorMessage(error),
variant: "error",
});
throw error;
}
};
}
export function preserveNewerProfileDraft(
current: AgentProfile,
submitted: AgentProfile,
saved: AgentProfile,
): AgentProfile {
return current === submitted ? saved : current;
}
function useProfileDelete(
agent: Agent,
draft: AgentProfile,
settingsAgents: Agent[],
syncAgentsToStore: (agents: Agent[]) => void,
toast: ReturnType<typeof useToast>["toast"],
) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [conflict, setConflict] = useState<AgentProfileDeleteConflict | null>(null);
const removeProfileFromStore = () => {
const nextAgents = settingsAgents.map((agentItem: Agent) =>
agentItem.id === agent.id
? {
...agentItem,
profiles: agentItem.profiles.filter((p: AgentProfile) => p.id !== draft.id),
}
: agentItem,
);
syncAgentsToStore(nextAgents);
window.location.assign("/settings/agents");
};
const requestDelete = () => {
setShowDeleteConfirm(true);
};
const handleDeleteProfile = async () => {
setShowDeleteConfirm(false);
const result = await deleteAgentProfileAction(draft.id);
if (result.status === "ok") {
removeProfileFromStore();
} else if (result.status === "conflict") {
setConflict({
activeSessions: result.activeSessions,
watchers: result.watchers,
routingTiers: result.routingTiers,
});
} else {
toast({ title: "Failed to delete profile", description: result.message, variant: "error" });
}
};
const handleForceDelete = async () => {
const result = await deleteAgentProfileAction(draft.id, true);
setConflict(null);
if (result.status === "ok") {
removeProfileFromStore();
} else if (result.status === "conflict") {
setConflict({
activeSessions: result.activeSessions,
watchers: result.watchers,
routingTiers: result.routingTiers,
});
} else if (result.status === "error") {
toast({ title: "Failed to delete profile", description: result.message, variant: "error" });
}
};
return {
requestDelete,
showDeleteConfirm,
setShowDeleteConfirm,
handleDeleteProfile,
conflict,
setConflict,
handleForceDelete,
};
}
type ProfileDeleteDialogsProps = {
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
@@ -503,6 +309,7 @@ function ProfileEditor({
passthroughConfig,
initialMcpConfig,
}: ProfileEditorProps) {
const { t } = useTranslation();
const { toast } = useToast();
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
const syncAgentsToStore = useSyncAgentsToStore();
@@ -538,7 +345,7 @@ function ProfileEditor({
revision: JSON.stringify(draft),
isDirty,
canSave: Boolean(draft.name.trim()),
invalidReason: draft.name.trim() ? undefined : "Profile name is required.",
invalidReason: draft.name.trim() ? undefined : t("agents:profileNameRequired"),
save: handleSave,
discard: () => setDraft(savedProfile),
});
@@ -575,7 +382,7 @@ function ProfileEditor({
initialMcpConfig={initialMcpConfig}
onToastError={(error) =>
toast({
title: "Failed to save MCP config",
title: t("agents:failedToSaveMcpConfig"),
description: errorMessage(error),
variant: "error",
})
@@ -601,6 +408,7 @@ type AgentProfilePageClientProps = {
};
export function AgentProfilePage({ initialMcpConfig }: AgentProfilePageClientProps) {
const { t } = useTranslation();
const params = useParams();
const agentParam = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
const profileParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId;
@@ -613,9 +421,9 @@ export function AgentProfilePage({ initialMcpConfig }: AgentProfilePageClientPro
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Profile not found.</p>
<p className="text-sm text-muted-foreground">{t("agents:profileNotFound")}</p>
<Button className="mt-4" asChild>
<Link href="/settings/agents">Back to Agents</Link>
<Link href="/settings/agents">{t("agents:backToAgents")}</Link>
</Button>
</CardContent>
</Card>
@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
@@ -28,6 +30,8 @@ import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
import type { RuntimeUpdate } from "@/lib/types/http";
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
const UPDATE_AGENT_KEY = "agents:updateAgent";
const ACTIVE_UPDATE_STATUSES = new Set<AgentUpdateJob["status"]>([
"queued",
"resolving",
@@ -35,22 +39,22 @@ const ACTIVE_UPDATE_STATUSES = new Set<AgentUpdateJob["status"]>([
"refreshing",
]);
function updatePhase(status: AgentUpdateJob["status"] | undefined): string | null {
switch (status) {
case "queued":
return "Update queued…";
case "resolving":
return "Checking latest version…";
case "updating":
return "Updating runtime…";
case "refreshing":
return "Refreshing agent capabilities…";
default:
return null;
}
// The job `status` values are the wire enum; only the phase labels are copy, so
// they travel as catalog keys and resolve at render.
const UPDATE_PHASE_KEYS: Partial<Record<AgentUpdateJob["status"], string>> = {
queued: "agents:updatePhaseQueued",
resolving: "agents:updatePhaseResolving",
updating: "agents:updatePhaseUpdating",
refreshing: "agents:updatePhaseRefreshing",
};
function updatePhase(t: TFunction, status: AgentUpdateJob["status"] | undefined): string | null {
const key = status ? UPDATE_PHASE_KEYS[status] : undefined;
return key ? t(key) : null;
}
function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) {
const { t } = useTranslation();
if (!job) return null;
const { versionsMatch } = resolveRuntimeVersionPair(null, job);
if (job.status === "succeeded" && job.refresh_error) {
@@ -60,7 +64,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
role="alert"
data-testid={`agent-update-result-${agentName}`}
>
Runtime updated, but capabilities could not be refreshed: {job.refresh_error}
{t("agents:runtimeUpdatedRefreshFailed", { error: job.refresh_error })}
</p>
);
}
@@ -71,7 +75,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
role="status"
data-testid={`agent-update-result-${agentName}`}
>
{versionsMatch ? "Runtime already up to date." : "Runtime updated successfully."}
{versionsMatch ? t("agents:runtimeAlreadyUpToDate") : t("agents:runtimeUpdatedSuccess")}
</p>
);
}
@@ -82,7 +86,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
role="alert"
data-testid={`agent-update-result-${agentName}`}
>
{job.error || "The runtime update failed."}
{job.error || t("agents:runtimeUpdateFailed")}
</p>
);
}
@@ -106,17 +110,18 @@ function RuntimeVersionSummary({
preview: AgentUpdatePreview;
job?: AgentUpdateJob;
}) {
const { t } = useTranslation();
const { currentVersion, targetVersion, versionsMatch } = resolveRuntimeVersionPair(preview, job);
return (
<div className="space-y-1">
<p className="font-medium">Runtime version</p>
<p className="font-medium">{t("agents:runtimeVersion")}</p>
<p className="break-words font-mono text-sm">
{versionsMatch ? currentVersion : `${currentVersion} → ${targetVersion}`}
</p>
{versionsMatch && (
<p className="text-sm text-muted-foreground" role="status">
Up to date
{t("agents:upToDate")}
</p>
)}
</div>
@@ -132,7 +137,8 @@ function UpdateBody({
job,
onRetryPreview,
}: UpdateBodyProps) {
const phase = updatePhase(job?.status);
const { t } = useTranslation();
const phase = updatePhase(t, job?.status);
return (
<div
className="max-h-[calc(80dvh-10rem)] min-h-0 space-y-4 overflow-y-auto overscroll-contain px-4 py-3 text-xs/relaxed"
@@ -141,7 +147,7 @@ function UpdateBody({
{loading && (
<p className="flex items-center gap-2 text-muted-foreground" role="status">
<IconLoader2 className="size-4 animate-spin" />
Checking the latest runtime version…
{t("agents:checkingLatestRuntimeVersion")}
</p>
)}
{previewError && (
@@ -154,7 +160,7 @@ function UpdateBody({
<span>{previewError}</span>
</p>
<Button type="button" variant="outline" size="sm" onClick={onRetryPreview}>
Retry version check
{t("agents:retryVersionCheck")}
</Button>
</div>
)}
@@ -166,7 +172,7 @@ function UpdateBody({
>
<p className="flex items-start gap-2 text-destructive">
<IconAlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>Unable to start update: {approveError}</span>
<span>{t("agents:unableToStartUpdate", { error: approveError })}</span>
</p>
</div>
)}
@@ -174,17 +180,11 @@ function UpdateBody({
<>
<RuntimeVersionSummary preview={preview} job={job} />
<div className="space-y-1 text-muted-foreground">
<p>
This updates the managed runtime on this Kandev host, then refreshes its models and
capabilities.
</p>
<p>
Active sessions keep running; only later probes and launches use the refreshed
runtime.
</p>
<p>{t("agents:runtimeUpdateExplainer")}</p>
<p>{t("agents:runtimeUpdateSessionsNote")}</p>
</div>
<div className="space-y-1">
<p className="font-medium">Command that will run</p>
<p className="font-medium">{t("agents:commandThatWillRun")}</p>
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
{preview.command_string}
</pre>
@@ -239,6 +239,7 @@ function UpdateFooter({
onClose,
mobile,
}: UpdateFooterProps) {
const { t } = useTranslation();
const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status));
const canRetry = job?.status === "failed";
const canApprove = canApproveAgentRuntimeUpdate({
@@ -254,7 +255,7 @@ function UpdateFooter({
const content = (
<>
<Button type="button" variant="outline" onClick={onClose}>
{job?.status === "succeeded" ? "Done" : "Cancel"}
{job?.status === "succeeded" ? t("agents:done") : t("common:cancel")}
</Button>
{showApprove && (
<Button
@@ -264,7 +265,7 @@ function UpdateFooter({
data-testid={`agent-update-confirm-${agentName}`}
>
{starting && <IconLoader2 className="mr-2 size-4 animate-spin" />}
{canRetry ? "Retry update" : "Approve update"}
{canRetry ? t("agents:retryUpdate") : t("agents:approveUpdate")}
</Button>
)}
</>
@@ -290,6 +291,7 @@ function UpdateTrigger({
installInFlight: boolean;
onOpen: () => void;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -299,7 +301,7 @@ function UpdateTrigger({
variant="ghost"
size="icon"
className="h-11 w-11 cursor-pointer active:scale-95 sm:h-7 sm:w-7"
aria-label={`Update ${displayName}`}
aria-label={t(UPDATE_AGENT_KEY, { name: displayName })}
disabled={installInFlight}
onClick={onOpen}
data-testid={`agent-update-trigger-${agentName}`}
@@ -309,7 +311,9 @@ function UpdateTrigger({
</span>
</TooltipTrigger>
<TooltipContent>
{installInFlight ? "Agent installation is in progress" : `Update ${displayName}`}
{installInFlight
? t("agents:agentInstallationInProgress")
: t(UPDATE_AGENT_KEY, { name: displayName })}
</TooltipContent>
</Tooltip>
);
@@ -332,6 +336,7 @@ export function AgentRuntimeUpdateControl({
onPreview: (agentName: string) => Promise<AgentUpdatePreview>;
onUpdate: (agentName: string) => Promise<AgentUpdateJob>;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const {
activeJob,
@@ -388,10 +393,8 @@ export function AgentRuntimeUpdateControl({
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent className="max-h-[80dvh]" data-testid={`agent-update-drawer-${agentName}`}>
<DrawerHeader className="shrink-0 text-left">
<DrawerTitle>Update {displayName}</DrawerTitle>
<DrawerDescription>
Review the update before Kandev changes this host runtime.
</DrawerDescription>
<DrawerTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DrawerTitle>
<DrawerDescription>{t("agents:reviewUpdateBeforeApplying")}</DrawerDescription>
</DrawerHeader>
{body}
{footer(true)}
@@ -404,10 +407,8 @@ export function AgentRuntimeUpdateControl({
data-testid={`agent-update-dialog-${agentName}`}
>
<DialogHeader className="px-4 pt-4">
<DialogTitle>Update {displayName}</DialogTitle>
<DialogDescription>
Review the update before Kandev changes this host runtime.
</DialogDescription>
<DialogTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DialogTitle>
<DialogDescription>{t("agents:reviewUpdateBeforeApplying")}</DialogDescription>
</DialogHeader>
{body}
{footer()}
@@ -1,6 +1,7 @@
"use client";
import { useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -18,6 +19,9 @@ import { SettingsCard } from "@/components/settings/settings-card";
* have to think about quoting, and re-concatenate on save. Description
* is preserved through the round-trip but no longer shown in the UI.
*/
// An example flag name — code the user types verbatim, never translated.
const FLAG_PLACEHOLDER = "--my-flag";
type CustomFlagRow = {
flag: string;
value: string;
@@ -208,6 +212,7 @@ function CuratedFlagsSection({
onToggle: (setting: CuratedSetting, enabled: boolean) => void;
compact: boolean;
}) {
const { t } = useTranslation();
const labelCls = compact ? "text-xs" : undefined;
const switchSize = compact ? ("sm" as const) : ("default" as const);
return (
@@ -231,7 +236,11 @@ function CuratedFlagsSection({
checked={enabled}
onCheckedChange={(checked) => onToggle(setting, checked)}
data-testid={`cli-flag-curated-enabled-${setting.key}`}
aria-label={`${enabled ? "Disable" : "Enable"} ${setting.label}`}
aria-label={
enabled
? t("agents:disableSetting", { name: setting.label })
: t("agents:enableSetting", { name: setting.label })
}
/>
</div>
);
@@ -251,11 +260,12 @@ function CustomFlagsSection({
onRemove: (index: number) => void;
onAdd: (next: CLIFlag) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-3">
{customFlags.length === 0 ? (
<p className="text-xs italic text-muted-foreground" data-testid="cli-flags-empty">
No CLI flags configured. Add one below.
{t("agents:noCliFlagsConfigured")}
</p>
) : (
<ul className="space-y-2" data-testid="cli-flags-list">
@@ -286,6 +296,7 @@ function CLIFlagRow({
onUpdateRow: (index: number, next: CLIFlag) => void;
onRemove: (index: number) => void;
}) {
const { t } = useTranslation();
const row = flagToRow(flag);
const update = (patch: Partial<CustomFlagRow>) => {
onUpdateRow(index, rowToFlag({ ...row, ...patch }));
@@ -295,14 +306,14 @@ function CLIFlagRow({
<Input
value={row.flag}
onChange={(e) => update({ flag: e.target.value })}
placeholder="--my-flag"
placeholder={FLAG_PLACEHOLDER}
className="flex-[2] font-mono text-xs"
data-testid={`cli-flag-flag-${index}`}
/>
<Input
value={row.value}
onChange={(e) => update({ value: e.target.value })}
placeholder="value (optional)"
placeholder={t("agents:valueOptional")}
className="flex-[3] font-mono text-xs"
data-testid={`cli-flag-value-${index}`}
/>
@@ -310,7 +321,11 @@ function CLIFlagRow({
checked={row.enabled}
onCheckedChange={(checked) => update({ enabled: checked })}
data-testid={`cli-flag-enabled-${index}`}
aria-label={`${row.enabled ? "Disable" : "Enable"} ${row.flag}`}
aria-label={
row.enabled
? t("agents:disableSetting", { name: row.flag })
: t("agents:enableSetting", { name: row.flag })
}
/>
<Button
type="button"
@@ -319,7 +334,7 @@ function CLIFlagRow({
onClick={() => onRemove(index)}
className="h-8 w-8 shrink-0 cursor-pointer"
data-testid={`cli-flag-remove-${index}`}
aria-label={`Remove ${row.flag || "flag"}`}
aria-label={t("agents:removeCliFlag", { name: row.flag || t("agents:flag") })}
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
@@ -328,6 +343,7 @@ function CLIFlagRow({
}
function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
const { t } = useTranslation();
const uid = useId();
const flagId = `${uid}-flag`;
const valueId = `${uid}-value`;
@@ -350,13 +366,13 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex-[2] space-y-1">
<Label className="text-xs" htmlFor={flagId}>
Flag
{t("agents:flag")}
</Label>
<Input
id={flagId}
value={newFlag}
onChange={(e) => setNewFlag(e.target.value)}
placeholder="--my-flag"
placeholder={FLAG_PLACEHOLDER}
className="font-mono text-xs"
data-testid="cli-flag-new-flag-input"
onKeyDown={onEnter}
@@ -364,13 +380,13 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
</div>
<div className="flex-[3] space-y-1">
<Label className="text-xs" htmlFor={valueId}>
Value (optional)
{t("agents:valueOptionalLabel")}
</Label>
<Input
id={valueId}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="value"
placeholder={t("agents:valuePlaceholder")}
className="font-mono text-xs"
data-testid="cli-flag-new-value-input"
onKeyDown={onEnter}
@@ -386,7 +402,7 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
data-testid="cli-flag-add-button"
>
<IconPlus className="h-3.5 w-3.5 mr-1" />
Add
{t("agents:add")}
</Button>
</div>
);
@@ -411,6 +427,7 @@ export function CustomCLIFlagsCard({
onChange,
permissionSettings,
}: CustomCLIFlagsCardProps) {
const { t } = useTranslation();
const curatedFlagTexts = useMemo(
() => new Set(extractCuratedSettings(permissionSettings).map((s) => s.flag)),
[permissionSettings],
@@ -437,14 +454,15 @@ export function CustomCLIFlagsCard({
<CardHeader>
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Agent CLI flags</CardTitle>
<CardDescription>
Flags passed to the agent CLI on launch. Only enabled entries are applied.
</CardDescription>
<CardTitle>{t("agents:agentCliFlags")}</CardTitle>
<CardDescription>{t("agents:agentCliFlagsDescription")}</CardDescription>
</div>
{customFlags.length > 0 && (
<span className="text-[10px] text-muted-foreground" data-testid="cli-flags-count">
{enabledCount} of {customFlags.length} enabled
{t("agents:cliFlagsEnabledCount", {
count: customFlags.length,
enabled: enabledCount,
})}
</span>
)}
</div>
@@ -76,4 +76,24 @@ describe("CommandPrefixField", () => {
fireEvent.change(screen.getByTestId("command-prefix-input"), { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith({ command_prefix: "" });
});
// The help text is a <Trans>: its <0> index addresses the JSX children
// positionally, so a prettier reflow can silently reassemble the sentence
// into fragments without failing anything.
it("renders the help text as one sentence with the example launcher intact", () => {
render(
<CommandPrefixField profile={formData()} baselineProfile={formData()} onChange={vi.fn()} />,
);
const help = screen.getByText(
(_content, element) =>
element?.tagName === "P" &&
element.textContent ===
"Tokens prepended to the agent launch command, so it runs under a sandbox launcher " +
"(e.g. greywall --). The value is shell-tokenised. Leave empty to run the agent " +
"directly. Applies to ACP sessions only \u2014 it has no effect when the profile " +
"uses TUI passthrough.",
);
expect(help.querySelector("code")?.textContent).toBe(PREFIX);
});
});
@@ -1,9 +1,15 @@
"use client";
import { Trans, useTranslation } from "react-i18next";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import type { ProfileFormData } from "@/components/settings/profile-form-fields";
// An example sandbox launcher invocation — a shell fragment the user types
// verbatim, so it is interpolated as a value rather than written into the
// catalog.
const EXAMPLE_PREFIX = "greywall --";
export function CommandPrefixField({
profile,
baselineProfile,
@@ -13,6 +19,7 @@ export function CommandPrefixField({
baselineProfile?: ProfileFormData;
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
const { t } = useTranslation();
return (
<div
className="space-y-2"
@@ -22,19 +29,18 @@ export function CommandPrefixField({
}
data-settings-dirty-level="container"
>
<Label htmlFor="profile-command-prefix">Command prefix</Label>
<Label htmlFor="profile-command-prefix">{t("agents:commandPrefix")}</Label>
<Input
id="profile-command-prefix"
data-testid="command-prefix-input"
value={profile.command_prefix ?? ""}
onChange={(event) => onChange({ command_prefix: event.target.value })}
placeholder="e.g. greywall --"
placeholder={t("agents:exampleValue", { example: EXAMPLE_PREFIX })}
/>
<p className="text-xs text-muted-foreground">
Tokens prepended to the agent launch command, so it runs under a sandbox launcher (e.g.{" "}
<code>greywall --</code>). The value is shell-tokenised. Leave empty to run the agent
directly. Applies to ACP sessions only — it has no effect when the profile uses TUI
passthrough.
<Trans i18nKey="agents:commandPrefixHelp" values={{ example: EXAMPLE_PREFIX }}>
<code />
</Trans>
</p>
</div>
);
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { startHostShell } from "@/lib/api";
import { PtyTerminalDialog } from "@/components/settings/pty-terminal-dialog";
@@ -20,12 +21,13 @@ type Props = {
* Used for ad-hoc setup commands that don't fit the install/login flows.
*/
export function HostShellDialog({ open, onOpenChange, onClose, initialInput }: Props) {
const { t } = useTranslation();
return (
<PtyTerminalDialog
open={open}
onOpenChange={onOpenChange}
title="Host terminal"
description="Runs a shell on the kandev host. Use it to install or configure agents manually."
title={t("agents:hostTerminal")}
description={t("agents:hostTerminalDescription")}
testIdPrefix="host-shell"
startSession={startHostShell}
onDone={onClose}
@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconDownload, IconLoader2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
@@ -9,19 +11,27 @@ import type { AvailableAgent } from "@/lib/types/http";
type InstallStatus = InstallJob["status"] | "idle";
function installButtonContent(status: InstallStatus): {
/**
* The install-job `status` values are the wire enum; only the button labels are
* copy. The rule never inspects this function (`mode: "jsx-only"` skips a
* non-JSX body), so the labels are resolved through `t` at render.
*/
function installButtonContent(
t: TFunction,
status: InstallStatus,
): {
icon: "spinner" | "download";
label: string;
} {
switch (status) {
case "queued":
return { icon: "spinner", label: "Queued…" };
return { icon: "spinner", label: t("agents:installQueued") };
case "running":
return { icon: "spinner", label: "Installing…" };
return { icon: "spinner", label: t("agents:installing") };
case "failed":
return { icon: "download", label: "Retry" };
return { icon: "download", label: t("agents:retry") };
default:
return { icon: "download", label: "Install" };
return { icon: "download", label: t("agents:install") };
}
}
@@ -34,8 +44,9 @@ function InstallButton({
status: InstallStatus;
onInstall: (name: string) => void;
}) {
const { t } = useTranslation();
const isInFlight = status === "queued" || status === "running";
const btn = installButtonContent(status);
const btn = installButtonContent(t, status);
return (
<Button
size="sm"
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "@/components/routing/app-link";
import { IconLoader2, IconLock, IconSettings } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
@@ -50,13 +51,14 @@ function InstalledAgentIdentity({
loginAvailable: boolean;
onAuthClick: () => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<AgentLogo agentName={agent.name} size={20} className="shrink-0" />
<h4 className="min-w-0 truncate font-medium">{displayName}</h4>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
{configured && <Badge variant="outline">Configured</Badge>}
{configured && <Badge variant="outline">{t("agents:configured")}</Badge>}
<div className="ml-auto flex shrink-0 items-center gap-1">
{probing && (
<Tooltip>
@@ -64,12 +66,12 @@ function InstalledAgentIdentity({
<span
data-testid={`probing-icon-${agent.name}`}
className="flex items-center text-muted-foreground cursor-help"
aria-label="Checking authentication"
aria-label={t("agents:checkingAuthentication")}
>
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
</span>
</TooltipTrigger>
<TooltipContent>Checking agent capabilities and authentication...</TooltipContent>
<TooltipContent>{t("agents:checkingCapabilitiesAndAuth")}</TooltipContent>
</Tooltip>
)}
{authRequired && (
@@ -80,15 +82,15 @@ function InstalledAgentIdentity({
onClick={onAuthClick}
data-testid={`auth-icon-${agent.name}`}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-amber-500 cursor-pointer hover:bg-amber-500/10"
aria-label="Authentication required"
aria-label={t("agents:authenticationRequired")}
>
<IconLock className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
{loginAvailable
? "Authentication required - click to open login terminal"
: "Authentication required - click to open a shell and sign in"}
? t("agents:authRequiredOpenLoginTerminal")
: t("agents:authRequiredOpenShell")}
</TooltipContent>
</Tooltip>
)}
@@ -98,7 +100,7 @@ function InstalledAgentIdentity({
className="text-xs text-muted-foreground line-clamp-2 min-h-[2rem]"
title={agent.matched_path ?? undefined}
>
{agent.matched_path ? `Detected at ${agent.matched_path}` : ""}
{agent.matched_path ? t("agents:detectedAt", { path: agent.matched_path }) : ""}
</p>
</div>
);
@@ -122,6 +124,7 @@ export function InstalledAgentCard({
onUpdate,
onAuthComplete,
}: Props) {
const { t } = useTranslation();
const configured = Boolean(savedAgent && savedAgent.profiles.length > 0);
const hasAgentRecord = Boolean(savedAgent);
const [loginOpen, setLoginOpen] = useState(false);
@@ -164,7 +167,7 @@ export function InstalledAgentCard({
}
>
<IconSettings className="mr-2 h-4 w-4" />
{hasAgentRecord ? "Create new profile" : "Setup Profile"}
{hasAgentRecord ? t("agents:createNewProfile") : t("agents:setupProfile")}
</Link>
</Button>
{runtimeUpdate?.supported && onPreview && onUpdate && (
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconSelector } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
@@ -27,6 +28,7 @@ type ModeComboboxProps = {
* trigger text (which happens with Radix Select).
*/
export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selected = value || currentModeId || modes[0]?.id || "";
const activeMode = modes.find((m) => m.id === selected);
@@ -43,7 +45,7 @@ export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComb
<span className="flex items-center gap-2 truncate">
{activeMode?.name ?? selected}
{activeMode?.id === currentModeId && (
<span className="text-muted-foreground">(default)</span>
<span className="text-muted-foreground">{t("agents:defaultSuffix")}</span>
)}
</span>
<IconSelector className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@@ -55,12 +57,12 @@ export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComb
onWheel={(e) => e.stopPropagation()}
>
<Command>
<CommandInput placeholder="Search modes..." />
<CommandInput placeholder={t("agents:searchModes")} />
<CommandList
className="max-h-[min(60vh,24rem)] overflow-y-auto overscroll-contain"
onWheel={(e) => e.stopPropagation()}
>
<CommandEmpty>No mode found.</CommandEmpty>
<CommandEmpty>{t("agents:noModeFound")}</CommandEmpty>
<CommandGroup>
{modes.map((m) => (
<CommandItem
@@ -77,7 +79,9 @@ export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComb
<div className="flex items-center gap-2 truncate">
<span className="truncate">{m.name}</span>
{m.id === currentModeId && (
<span className="text-muted-foreground text-xs">(default)</span>
<span className="text-muted-foreground text-xs">
{t("agents:defaultSuffix")}
</span>
)}
</div>
{m.description && m.description !== m.name && (
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { CommandsButton } from "./profile-capability-helpers";
import type { CommandEntry } from "@/lib/types/http";
afterEach(cleanup);
const COMMANDS: CommandEntry[] = [
{ name: "init", description: "Bootstrap the repo" },
{ name: "review", description: "" },
];
describe("CommandsButton", () => {
it("renders nothing when the agent advertises no commands", () => {
const { container } = render(<CommandsButton commands={[]} />);
expect(container.firstChild).toBeNull();
});
it("counts the advertised commands on the trigger", () => {
render(<CommandsButton commands={COMMANDS} />);
expect(screen.getByTestId("profile-commands-button").textContent).toContain(
"Available commands (2)",
);
});
// The dialog description is a <Trans>: its <0> index addresses the JSX
// children positionally, so a prettier reflow can silently reassemble the
// sentence into fragments without failing anything. The `/init` example is a
// command the user types verbatim and is interpolated as a value.
it("renders the dialog description as one sentence with the example intact", () => {
render(<CommandsButton commands={COMMANDS} />);
fireEvent.click(screen.getByTestId("profile-commands-button"));
const description = screen.getByText(
(_content, element) =>
element?.textContent === "Type these during a session chat to invoke them - e.g. /init." &&
Boolean(element.querySelector("code")),
);
expect(description.querySelector("code")?.textContent).toBe("/init");
});
});
@@ -1,3 +1,4 @@
import { Trans, useTranslation } from "react-i18next";
import { IconTerminal2 } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
@@ -11,7 +12,11 @@ import {
import type { CommandEntry, ModeEntry } from "@/lib/types/http";
import type { ProfileFormData } from "./profile-form-fields";
// An example slash command the user types verbatim — an identifier, not copy.
const EXAMPLE_COMMAND = "/init";
export function CommandsButton({ commands }: { commands: CommandEntry[] }) {
const { t } = useTranslation();
if (commands.length === 0) return null;
return (
<Dialog>
@@ -24,14 +29,19 @@ export function CommandsButton({ commands }: { commands: CommandEntry[] }) {
data-testid="profile-commands-button"
>
<IconTerminal2 className="mr-2 h-4 w-4" />
Available commands ({commands.length})
{t("agents:availableCommandsCount", { count: commands.length })}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Available slash commands</DialogTitle>
<DialogTitle>{t("agents:availableSlashCommands")}</DialogTitle>
<DialogDescription>
Type these during a session chat to invoke them - e.g. <code>/init</code>.
<Trans
i18nKey="agents:availableSlashCommandsHelp"
values={{ example: EXAMPLE_COMMAND }}
>
<code />
</Trans>
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto space-y-2">
@@ -1,6 +1,8 @@
"use client";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { IconAlertCircle, IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
import { NoAuthPanel, ProbingPanel } from "@/components/settings/profile-status-panels";
import { Button } from "@kandev/ui/button";
@@ -245,23 +247,23 @@ function PermissionToggles({
);
}
function capabilityStatusMessage(status: ModelConfig["status"]): string | null {
switch (status) {
case "probing":
return "Checking agent capabilities…";
case "auth_required":
return "Authentication required. Run the agent CLI in your terminal to authenticate, then refresh.";
case "not_installed":
return "Agent CLI not installed.";
case "failed":
return "Probe failed. Check agent logs for details.";
default:
return null;
}
// The probe `status` values are the wire enum; only the messages are copy, so
// they travel as catalog keys and resolve at render.
const CAPABILITY_STATUS_KEYS: Record<string, string> = {
probing: "agents:capabilityProbing",
auth_required: "agents:capabilityAuthRequired",
not_installed: "agents:capabilityNotInstalled",
failed: "agents:capabilityProbeFailed",
};
function capabilityStatusMessage(t: TFunction, status: ModelConfig["status"]): string | null {
const key = status ? CAPABILITY_STATUS_KEYS[status] : undefined;
return key ? t(key) : null;
}
function CapabilityStatusMessage({ status }: { status: ModelConfig["status"] }) {
const msg = capabilityStatusMessage(status);
const { t } = useTranslation();
const msg = capabilityStatusMessage(t, status);
if (!msg) return null;
return (
<p
@@ -283,6 +285,7 @@ function RefreshCapabilitiesButton({
isLoading: boolean;
error: string | null;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<Tooltip>
@@ -299,7 +302,7 @@ function RefreshCapabilitiesButton({
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh agent capabilities (models + modes)</p>
<p>{t("agents:refreshCapabilitiesTooltip")}</p>
</TooltipContent>
</Tooltip>
{error && (
@@ -310,7 +313,7 @@ function RefreshCapabilitiesButton({
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">Failed to refresh: {error}</p>
<p className="max-w-xs">{t("agents:failedToRefresh", { error })}</p>
</TooltipContent>
</Tooltip>
)}
@@ -331,6 +334,7 @@ function ModelPicker({
configOptions: SelectConfigOption[];
onChange: (patch: Partial<ProfileFormData>) => void;
}) {
const { t } = useTranslation();
const modelConfig = configOptions.find(isModelConfigOption);
const modelOptions = modelConfig
? configOptionToModelOptions(modelConfig)
@@ -358,8 +362,8 @@ function ModelPicker({
onConfigChange={(configId, value) =>
onChange({ config_options: { ...(profile.config_options ?? {}), [configId]: value } })
}
placeholder="Select a model..."
ariaLabel="Profile start model settings"
placeholder={t("agents:selectAModel")}
ariaLabel={t("agents:profileStartModelSettings")}
/>
);
}
@@ -433,6 +437,7 @@ function CapabilitiesRow({
agentName,
baselineProfile,
}: CapabilitiesRowProps) {
const { t } = useTranslation();
const hasModes = modes.length > 0;
const configOptions = modelConfigOptions(modelConfig);
const activeMode = findActiveMode(modes, profile.mode, currentModeId);
@@ -442,7 +447,7 @@ function CapabilitiesRow({
if (isLoading && models.length === 0) {
return (
<div className={gapCls}>
<Label className={labelCls}>Start model</Label>
<Label className={labelCls}>{t("agents:startModel")}</Label>
<Skeleton className="h-7 w-full" />
</div>
);
@@ -472,7 +477,7 @@ function CapabilitiesRow({
data-settings-dirty={profileModelIsDirty(profile, baselineProfile)}
data-settings-dirty-level="container"
>
<Label className={labelCls}>Start model</Label>
<Label className={labelCls}>{t("agents:startModel")}</Label>
<ModelPicker
profile={profile}
models={models}
@@ -488,7 +493,7 @@ function CapabilitiesRow({
data-settings-dirty={profileModeIsDirty(profile, baselineProfile)}
data-settings-dirty-level="container"
>
<Label className={labelCls}>Start mode</Label>
<Label className={labelCls}>{t("agents:startMode")}</Label>
<ModePicker
profile={profile}
modes={modes}
@@ -521,21 +526,22 @@ function NameField({
onRemove?: () => void;
baselineName?: string;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2">
<Label>Profile name</Label>
<Label>{t("agents:profileName")}</Label>
<Input
data-testid="profile-name-input"
value={profile.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder="Default profile"
placeholder={t("agents:defaultProfile")}
data-settings-dirty={baselineName !== undefined && profile.name !== baselineName}
/>
</div>
{canRemove && onRemove && (
<Button size="sm" variant="ghost" className="cursor-pointer" onClick={onRemove}>
Remove
{t("agents:remove")}
</Button>
)}
</div>
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import {
IconAlertCircle,
IconLoader2,
@@ -21,6 +23,7 @@ import { HostShellDialog } from "@/components/settings/host-shell-dialog";
* users don't mistake the in-flight probe for a failed install.
*/
export function ProbingPanel() {
const { t } = useTranslation();
return (
<div
data-testid="profile-probing-panel"
@@ -28,21 +31,22 @@ export function ProbingPanel() {
className="flex items-center gap-3 rounded-md border border-muted bg-muted/40 p-3"
>
<IconLoader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Probing agent capabilities… this usually takes a few seconds.
</p>
<p className="text-sm text-muted-foreground">{t("agents:probingCapabilities")}</p>
</div>
);
}
function noAuthHint({ isAuth, canLogin }: { isAuth: boolean; canLogin: boolean }): React.ReactNode {
function noAuthHint(
t: TFunction,
{ isAuth, canLogin }: { isAuth: boolean; canLogin: boolean },
): React.ReactNode {
if (!isAuth) {
return "Install the agent CLI, then click Refresh.";
return t("agents:noAuthHintNotInstalled");
}
if (canLogin) {
return "Click Open terminal to sign in, then refresh.";
return t("agents:noAuthHintLogin");
}
return "Click Open terminal to launch a shell, run the agent's sign-in command, then refresh.";
return t("agents:noAuthHintShell");
}
function NoAuthActions({
@@ -56,6 +60,7 @@ function NoAuthActions({
onRefresh: () => Promise<void>;
isLoading: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex shrink-0 items-center gap-2">
{showTerminal && (
@@ -68,7 +73,7 @@ function NoAuthActions({
data-testid="profile-no-auth-open-terminal"
>
<IconTerminal2 className="mr-2 h-4 w-4" />
Open terminal
{t("agents:openTerminal")}
</Button>
)}
<Button
@@ -81,7 +86,7 @@ function NoAuthActions({
data-testid="profile-no-auth-refresh"
>
<IconRefresh className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
Refresh
{t("agents:refresh")}
</Button>
</div>
);
@@ -102,9 +107,10 @@ export function NoAuthPanel({
error: string | null;
rawError: string | null;
}) {
const { t } = useTranslation();
const isAuth = status === "auth_required";
const Icon = isAuth ? IconLock : IconPackageOff;
const title = isAuth ? "No auth - login required" : "Not installed";
const title = isAuth ? t("agents:noAuthLoginRequired") : t("agents:notInstalled");
const detail = error || rawError;
const [loginOpen, setLoginOpen] = useState(false);
const [shellOpen, setShellOpen] = useState(false);
@@ -118,7 +124,7 @@ export function NoAuthPanel({
)?.login_command;
const canLogin = isAuth && Boolean(loginCommand);
const showTerminal = isAuth;
const hint = noAuthHint({ isAuth, canLogin });
const hint = noAuthHint(t, { isAuth, canLogin });
const handleOpenTerminal = () => {
if (canLogin) setLoginOpen(true);
else setShellOpen(true);
@@ -143,7 +149,7 @@ export function NoAuthPanel({
data-testid="profile-no-auth-details"
>
<IconAlertCircle className="h-3 w-3" />
details
{t("agents:details")}
</button>
</TooltipTrigger>
<TooltipContent className="max-w-md">
@@ -1,6 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
// The WebSocket error handler runs outside React, so it uses the module-level
// `t`, which resolves at call time. Components in this file use the hook.
import { t as translate } from "@/lib/i18n";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
@@ -146,7 +150,7 @@ function openSessionWebSocket(
ws.binaryType = "arraybuffer";
ws.onmessage = makeWsMessageHandler(term, setters);
ws.onerror = () => {
setters.setError("Connection error");
setters.setError(translate("agents:ptyConnectionError"));
setters.setStatus("error");
};
if (initialInput) {
@@ -256,6 +260,7 @@ function PtySessionView({
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const sessionIDRef = useRef<string | null>(null);
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<SessionStatus>("connecting");
const [exitCode, setExitCode] = useState<number | null>(null);
@@ -283,11 +288,13 @@ function PtySessionView({
className="h-[420px] rounded-md bg-[#0b0b0c] p-2 overflow-hidden"
/>
{status === "connecting" && (
<p className="text-xs text-muted-foreground">Starting session…</p>
<p className="text-xs text-muted-foreground">{t("agents:startingSession")}</p>
)}
{status === "exited" && (
<p className="text-xs text-muted-foreground">
Session ended{exitCode != null ? ` (exit ${exitCode})` : ""}.
{exitCode != null
? t("agents:sessionEndedWithCode", { code: exitCode })
: t("agents:sessionEnded")}
</p>
)}
{status === "error" && error && <p className="text-xs text-destructive">{error}</p>}
@@ -298,7 +305,7 @@ function PtySessionView({
className="cursor-pointer"
data-testid={`${testIdPrefix ?? "pty"}-done`}
>
Done
{t("agents:done")}
</Button>
</DialogFooter>
</>
+66
View File
@@ -454,4 +454,70 @@ export const i18nGuardFiles = [
// values so the pseudo-locale cannot turn them into dead pointers.
"app/settings/integrations/slack/**/*.{ts,tsx}",
"components/slack/**/*.{ts,tsx}",
// Settings → Agents: the agents list, the per-agent setup page, the agent
// profile editor, and the `/settings/agent/:id` redirect. The whole
// `app/settings/agents` tree is migrated, plus the agent half of
// `components/settings` — the loose `agent-*` / profile / CLI-flag files those
// three routes render, and the shared model picker and PTY dialog they reach.
//
// Deliberately absent, each reached only from a different surface:
// `components/settings/model-combobox.tsx` (the task-side CLI profile editor
// and Utility Agents), `components/settings/agent-card.tsx` (Office), and
// `components/settings/inference-agent-status.tsx` (Utility Agents). The
// string counter listed all three for this route; walking the import closure
// of the four pages shows none of them is in it.
//
// Also absent: `components/settings/profile-edit/**` (`env-vars-card.tsx` and
// `profile-env-vars-section.tsx`). The profile editor does render the env-vars
// card, but that directory is the *executor* profile tree and belongs to the
// sibling migration; its strings are the only plain English left on the agent
// profile page under the pseudo-locale.
//
// `agent-save-helpers.ts`, `use-profile-mcp-config.ts`,
// `use-agent-profile-settings.ts`, `use-agent-update-dialog-state.ts` and
// `agent-profile-page-state.ts` hold no JSX, so `mode: "jsx-only"` never
// inspects them; the entries record that they are migrated (the first two
// carry MCP parse errors and the last the profile save/delete toasts, all of
// which reach the user) and only the pseudo-locale can prove it stays that
// way. Same for the WebSocket error in `pty-terminal-dialog.tsx`, which is
// set from a non-JSX handler.
//
// Deliberately left in English, each an identifier the user must read or type
// verbatim, all interpolated as values so the pseudo-locale cannot turn them
// into dead pointers: the `{prompt}` and `{{model}}` substitution tokens, the
// `--my-flag` / `greywall --` / `/init` / `superclaude` examples, the
// `mcpServers` JSON key, the MCP server product names (`Playwright MCP`,
// `Chrome DevTools MCP`, `Context7 MCP`, `GitHub MCP`) and the Kandev MCP tool
// list, and the whole of the assembled command preview. Wire values stay wire
// values: the install/update job `status` unions, the capability-probe
// `status`, the routing `tier`, the permission `apply_method`, the watcher
// `kind`, and every model/mode `id` — only their labels are copy, and those
// travel as catalog keys and resolve at render.
"app/settings/agent/**/*.{ts,tsx}",
"app/settings/agents/**/*.{ts,tsx}",
"components/app-sidebar/sections/settings/agents-group.tsx",
"components/settings/add-tui-agent-dialog.tsx",
"components/settings/agent-login-dialog.tsx",
"components/settings/agent-profile-delete-dialog.tsx",
"components/settings/agent-profile-page-state.ts",
"components/settings/agent-profile-page.tsx",
"components/settings/agent-runtime-update-control.tsx",
"components/settings/cli-flags-field.tsx",
"components/settings/command-prefix-field.tsx",
"components/settings/install-agent-card.tsx",
"components/settings/installed-agent-card.tsx",
"components/settings/mode-combobox.tsx",
"components/settings/profile-capability-helpers.tsx",
"components/settings/profile-form-fields.tsx",
"components/settings/profile-status-panels.tsx",
"components/settings/use-agent-update-dialog-state.ts",
// Shared with surfaces that are not migrated. The guard is per-file, so the
// task-side CLI profile editor, the onboarding dialog, Utility Agents, the
// chat model selector and the session tabs are all unaffected.
// `host-shell-dialog.tsx` is also opened from a chat action message;
// `pty-terminal-dialog.tsx` is reached only through it and the agent login
// dialog, both of which are migrated here.
"components/model-config-selector.tsx",
"components/settings/host-shell-dialog.tsx",
"components/settings/pty-terminal-dialog.tsx",
];
+193
View File
@@ -0,0 +1,193 @@
{
"actionCannotBeUndone": "This action cannot be undone.",
"add": "Add",
"addProfile": "Add profile",
"addTuiAgent": "Add TUI Agent",
"addTuiAgentDescription": "Register a CLI tool as a TUI agent. It will appear in the agent list with a default passthrough profile.",
"agentCliFlags": "Agent CLI flags",
"agentCliFlagsDescription": "Flags passed to the agent CLI on launch. Only enabled entries are applied.",
"agentInstallationInProgress": "Agent installation is in progress",
"agentNotFound": "Agent not found.",
"agentProfiles": "Agent Profiles",
"agentProfilesDescription": "Manage existing profiles by agent.",
"agentProfileSettings": "{{name}} profile settings",
"agentProfilesTitle": "{{name}} Profiles",
"approveUpdate": "Approve update",
"authenticationRequired": "Authentication required",
"authRequiredOpenLoginTerminal": "Authentication required - click to open login terminal",
"authRequiredOpenShell": "Authentication required - click to open a shell and sign in",
"automaticallyAvailable": "Automatically available",
"availableCommandsCount_one": "Available commands ({{count}})",
"availableCommandsCount_other": "Available commands ({{count}})",
"availableSlashCommands": "Available slash commands",
"availableSlashCommandsHelp": "Type these during a session chat to invoke them - e.g. <0>{{example}}</0>.",
"availableToInstall": "Available to Install",
"availableToInstallDescription": "Click Install to run the agent's install script on the kandev host. Progress streams live; you can start multiple installs in parallel.",
"backToAgents": "Back to Agents",
"backToModelSettings": "Back to model settings from {{name}}",
"builtIn": "Built-in",
"cannotDeleteAgentProfile": "Cannot delete agent profile",
"changeTierMappingsFirst": "Change these workspace tier mappings before deleting this profile.",
"capabilityAuthRequired": "Authentication required. Run the agent CLI in your terminal to authenticate, then refresh.",
"capabilityNotInstalled": "Agent CLI not installed.",
"capabilityProbeFailed": "Probe failed. Check agent logs for details.",
"capabilityProbing": "Checking agent capabilities…",
"checkingAuthentication": "Checking authentication",
"checkingCapabilitiesAndAuth": "Checking agent capabilities and authentication...",
"checkingLatestRuntimeVersion": "Checking the latest runtime version…",
"cliFlagsEnabledCount_one": "{{enabled}} of {{count}} enabled",
"cliFlagsEnabledCount_other": "{{enabled}} of {{count}} enabled",
"command": "Command",
"commandPrefix": "Command prefix",
"commandPrefixHelp": "Tokens prepended to the agent launch command, so it runs under a sandbox launcher (e.g. <0>{{example}}</0>). The value is shell-tokenised. Leave empty to run the agent directly. Applies to ACP sessions only — it has no effect when the profile uses TUI passthrough.",
"commandPreview": "Command Preview",
"commandPreviewHelp": "The CLI command that will be executed based on the current settings.",
"commandRequired": "Command is required",
"commandThatWillRun": "Command that will run",
"configured": "Configured",
"configureProfilesIntro": "Configure profiles and defaults for this agent.",
"conflictQuickChatsTitle": "Quick Chats:",
"conflictTasksTitle": "Tasks:",
"conflictTierMappingRow": "<0>{{tier}}</0> tier in {{workspace}} for {{provider}}",
"conflictTierMappingsTitle": "Workspace tier mappings:",
"conflictWatchersTitle": "Watchers (will be disabled):",
"copied": "Copied",
"copyInstallCommand": "Copy install command",
"copyToClipboard": "Copy to clipboard",
"create": "Create",
"createAgentProfileTitle": "Create {{name}} Profile",
"createNewProfile": "Create new profile",
"createProfileIntro": "Create a new profile for this agent.",
"creating": "Creating...",
"defaultProfile": "Default profile",
"defaultSuffix": "(default)",
"delete": "Delete",
"deleteAgent": "Delete Agent",
"deleteAgentDescription": "This will permanently remove the agent and all its profiles. This action cannot be undone.",
"deleteAgentProfileDescription": "This will permanently delete this profile. This action cannot be undone.",
"deleteAgentProfileTitle": "Delete agent profile?",
"deleteAgentTitle": "Delete {{name}}?",
"deleteAnyway": "Delete Anyway",
"deleteAnywayConsequences": "These sessions will no longer be able to use this profile and the listed watchers will be disabled. This action cannot be undone.",
"deleteProfile": "Delete profile",
"details": "details",
"detectedAt": "Detected at {{path}}",
"disableSetting": "Disable {{name}}",
"displayName": "Display Name",
"displayNameRequired": "Display name is required",
"done": "Done",
"enableMcp": "Enable MCP",
"enableMcpDescription": "Allow this profile to use MCP servers during sessions.",
"enableSetting": "Enable {{name}}",
"everyProfileNeedsNameAndModel": "Every profile needs a name and model.",
"exampleValue": "e.g. {{example}}",
"failedToCreateAgent": "Failed to create agent",
"failedToDeleteProfile": "Failed to delete profile",
"failedToLoadCommandPreview": "Failed to load command preview",
"failedToLoadMcpConfig": "Failed to load MCP config",
"failedToRefresh": "Failed to refresh: {{error}}",
"failedToSaveAgent": "Failed to save agent",
"failedToSaveMcpConfig": "Failed to save MCP config",
"failedToSaveProfile": "Failed to save profile",
"filterModels": "Filter models...",
"fixInvalidMcpConfig": "Fix invalid MCP configuration before saving.",
"fixInvalidMcpJson": "Fix invalid MCP JSON before saving.",
"flag": "Flag",
"goToAgents": "Go to Agents",
"hostTerminal": "Host terminal",
"hostTerminalDescription": "Runs a shell on the kandev host. Use it to install or configure agents manually.",
"install": "Install",
"installationNotDetected": "Installation not detected",
"installed": "Installed",
"installedAgents": "Installed Agents",
"installedAgentsDescription": "Agents detected on this machine are ready to configure.",
"installing": "Installing…",
"installQueued": "Queued…",
"invalidJson": "Invalid JSON",
"invalidMcpConfig": "Invalid MCP config",
"kandevMcpChip": "✓ {{name}}",
"kandevMcpTools": "Tools: {{tools}}",
"manageAgentsFromMainPage": "Manage agents from the main Agents page.",
"mcpConfigMustBeJsonObject": "MCP servers config must be a JSON object",
"mcpConfiguration": "MCP Configuration",
"mcpDraftHint": "MCP config will be applied after the profile is saved.",
"mcpKeyMustBeJsonObject": "{{key}} must be a JSON object",
"mcpPassthroughInjectionHint": "In CLI passthrough mode, kandev injects these MCP servers via {{mechanism}}.",
"mcpSaveProfileHint": "Save this profile to configure MCP servers.",
"mcpServersHelp": "MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.",
"mcpServersJson": "MCP servers (JSON)",
"model": "Model",
"modelHeading": "Model",
"modelRequiredForAllProfiles": "Model is required for all profiles.",
"noAuthHintLogin": "Click Open terminal to sign in, then refresh.",
"noAuthHintNotInstalled": "Install the agent CLI, then click Refresh.",
"noAuthHintShell": "Click Open terminal to launch a shell, run the agent's sign-in command, then refresh.",
"noAuthLoginRequired": "No auth - login required",
"noCliFlagsConfigured": "No CLI flags configured. Add one below.",
"noCommandPreview": "No command preview available.",
"noInstalledAgentsDetected": "No installed agents were detected. Install one below, then click Rescan.",
"noModeFound": "No mode found.",
"noModelsFound": "No models found.",
"notInstalled": "Not installed",
"openTerminal": "Open terminal",
"pageDescription": "Discover installed agents, install new ones, and manage their profiles.",
"popularServers": "Popular servers",
"probingCapabilities": "Probing agent capabilities… this usually takes a few seconds.",
"profileInUseIntro": "This profile is currently in use. Deleting it will affect the following:",
"profileName": "Profile name",
"profileNameRequired": "Profile name is required.",
"profileNameRequiredDescription": "Please enter a profile name before saving.",
"profileNameRequiredTitle": "Profile name is required",
"profileNotFound": "Profile not found.",
"profileSettings": "Profile settings",
"profileStartModelSettings": "Profile start model settings",
"ptyConnectionError": "Connection error",
"promptTokenHint": "<0>{{token}}</0> will be replaced with your task description or follow-up message.",
"refresh": "Refresh",
"refreshCapabilitiesTooltip": "Refresh agent capabilities (models + modes)",
"remove": "Remove",
"removeCliFlag": "Remove {{name}}",
"removeThisProfile": "Remove this profile",
"requestFailed": "Request failed",
"rescan": "Rescan",
"retry": "Retry",
"retryUpdate": "Retry update",
"retryVersionCheck": "Retry version check",
"reviewUpdateBeforeApplying": "Review the update before Kandev changes this host runtime.",
"runtimeAlreadyUpToDate": "Runtime already up to date.",
"runtimeUpdatedRefreshFailed": "Runtime updated, but capabilities could not be refreshed: {{error}}",
"runtimeUpdatedSuccess": "Runtime updated successfully.",
"runtimeUpdateExplainer": "This updates the managed runtime on this Kandev host, then refreshes its models and capabilities.",
"runtimeUpdateFailed": "The runtime update failed.",
"runtimeUpdateSessionsNote": "Active sessions keep running; only later probes and launches use the refreshed runtime.",
"runtimeVersion": "Runtime version",
"scanningForInstalledAgents": "Scanning for installed agents...",
"searchModes": "Search modes...",
"selectAModel": "Select a model...",
"sessionEnded": "Session ended.",
"sessionEndedWithCode": "Session ended (exit {{code}}).",
"setupProfile": "Setup Profile",
"signInToAgent": "Sign in: {{name}}",
"startingSession": "Starting session…",
"startMode": "Start mode",
"startModel": "Start model",
"terminal": "Terminal",
"tuiCommandHelp": "Binary name looked up on PATH. Use <0>{{token}}</0> to insert the model value.",
"tuiModelHelp": "Profile label shown in the agent selector dropdown",
"unableToStartUpdate": "Unable to start update: {{error}}",
"untitledQuickChat": "Untitled quick chat",
"untitledTask": "Untitled task",
"updateAgent": "Update {{name}}",
"updatePhaseQueued": "Update queued…",
"updatePhaseRefreshing": "Refreshing agent capabilities…",
"updatePhaseResolving": "Checking latest version…",
"updatePhaseUpdating": "Updating runtime…",
"upToDate": "Up to date",
"valueOptional": "value (optional)",
"valueOptionalLabel": "Value (optional)",
"valuePlaceholder": "value",
"watcherKindGithubIssue": "GitHub Issues",
"watcherKindGithubReview": "GitHub PR Reviews",
"watcherKindJira": "Jira",
"watcherKindLinear": "Linear"
}
+193
View File
@@ -0,0 +1,193 @@
{
"actionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"add": "Àďď",
"addProfile": "Àďď ƥŕōƒĩĺē",
"addTuiAgent": "Àďď ŢŨĨ Àĝēńţ",
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ţōōĺ àś à ŢŨĨ àĝēńţ. Ĩţ ŵĩĺĺ àƥƥēàŕ ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥàśśţĥŕōũĝĥ ƥŕōƒĩĺē.",
"agentCliFlags": "Àĝēńţ ĆĹĨ ƒĺàĝś",
"agentCliFlagsDescription": "Ƒĺàĝś ƥàśśēď ţō ţĥē àĝēńţ ĆĹĨ ōń ĺàũńćĥ. Ōńĺŷ ēńàƀĺēď ēńţŕĩēś àŕē àƥƥĺĩēď.",
"agentInstallationInProgress": "Àĝēńţ ĩńśţàĺĺàţĩōń ĩś ĩń ƥŕōĝŕēśś",
"agentNotFound": "Àĝēńţ ńōţ ƒōũńď.",
"agentProfiles": "Àĝēńţ Ƥŕōƒĩĺēś",
"agentProfilesDescription": "Ḿàńàĝē ēxĩśţĩńĝ ƥŕōƒĩĺēś ƀŷ àĝēńţ.",
"agentProfileSettings": "{{name}} ƥŕōƒĩĺē śēţţĩńĝś",
"agentProfilesTitle": "{{name}} Ƥŕōƒĩĺēś",
"approveUpdate": "Àƥƥŕōvē ũƥďàţē",
"authenticationRequired": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď",
"authRequiredOpenLoginTerminal": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń ĺōĝĩń ţēŕḿĩńàĺ",
"authRequiredOpenShell": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń à śĥēĺĺ àńď śĩĝń ĩń",
"automaticallyAvailable": "Àũţōḿàţĩćàĺĺŷ àvàĩĺàƀĺē",
"availableCommandsCount_one": "Àvàĩĺàƀĺē ćōḿḿàńďś ({{count}})",
"availableCommandsCount_other": "Àvàĩĺàƀĺē ćōḿḿàńďś ({{count}})",
"availableSlashCommands": "Àvàĩĺàƀĺē śĺàśĥ ćōḿḿàńďś",
"availableSlashCommandsHelp": "Ţŷƥē ţĥēśē ďũŕĩńĝ à śēśśĩōń ćĥàţ ţō ĩńvōķē ţĥēḿ - ē.ĝ. <0>{{example}}</0>.",
"availableToInstall": "Àvàĩĺàƀĺē ţō Ĩńśţàĺĺ",
"availableToInstallDescription": "Ćĺĩćķ Ĩńśţàĺĺ ţō ŕũń ţĥē àĝēńţ'ś ĩńśţàĺĺ śćŕĩƥţ ōń ţĥē ķàńďēv ĥōśţ. Ƥŕōĝŕēśś śţŕēàḿś ĺĩvē; ŷōũ ćàń śţàŕţ ḿũĺţĩƥĺē ĩńśţàĺĺś ĩń ƥàŕàĺĺēĺ.",
"backToAgents": "Ɓàćķ ţō Àĝēńţś",
"backToModelSettings": "Ɓàćķ ţō ḿōďēĺ śēţţĩńĝś ƒŕōḿ {{name}}",
"builtIn": "Ɓũĩĺţ-ĩń",
"cannotDeleteAgentProfile": "Ćàńńōţ ďēĺēţē àĝēńţ ƥŕōƒĩĺē",
"changeTierMappingsFirst": "Ćĥàńĝē ţĥēśē ŵōŕķśƥàćē ţĩēŕ ḿàƥƥĩńĝś ƀēƒōŕē ďēĺēţĩńĝ ţĥĩś ƥŕōƒĩĺē.",
"capabilityAuthRequired": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď. Ŕũń ţĥē àĝēńţ ĆĹĨ ĩń ŷōũŕ ţēŕḿĩńàĺ ţō àũţĥēńţĩćàţē, ţĥēń ŕēƒŕēśĥ.",
"capabilityNotInstalled": "Àĝēńţ ĆĹĨ ńōţ ĩńśţàĺĺēď.",
"capabilityProbeFailed": "Ƥŕōƀē ƒàĩĺēď. Ćĥēćķ àĝēńţ ĺōĝś ƒōŕ ďēţàĩĺś.",
"capabilityProbing": "Ćĥēćķĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś…",
"checkingAuthentication": "Ćĥēćķĩńĝ àũţĥēńţĩćàţĩōń",
"checkingCapabilitiesAndAuth": "Ćĥēćķĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś àńď àũţĥēńţĩćàţĩōń...",
"checkingLatestRuntimeVersion": "Ćĥēćķĩńĝ ţĥē ĺàţēśţ ŕũńţĩḿē vēŕśĩōń…",
"cliFlagsEnabledCount_one": "{{enabled}} ōƒ {{count}} ēńàƀĺēď",
"cliFlagsEnabledCount_other": "{{enabled}} ōƒ {{count}} ēńàƀĺēď",
"command": "Ćōḿḿàńď",
"commandPrefix": "Ćōḿḿàńď ƥŕēƒĩx",
"commandPrefixHelp": "Ţōķēńś ƥŕēƥēńďēď ţō ţĥē àĝēńţ ĺàũńćĥ ćōḿḿàńď, śō ĩţ ŕũńś ũńďēŕ à śàńďƀōx ĺàũńćĥēŕ (ē.ĝ. <0>{{example}}</0>). Ţĥē vàĺũē ĩś śĥēĺĺ-ţōķēńĩśēď. Ĺēàvē ēḿƥţŷ ţō ŕũń ţĥē àĝēńţ ďĩŕēćţĺŷ. Àƥƥĺĩēś ţō ÀĆƤ śēśśĩōńś ōńĺŷ — ĩţ ĥàś ńō ēƒƒēćţ ŵĥēń ţĥē ƥŕōƒĩĺē ũśēś ŢŨĨ ƥàśśţĥŕōũĝĥ.",
"commandPreview": "Ćōḿḿàńď Ƥŕēvĩēŵ",
"commandPreviewHelp": "Ţĥē ĆĹĨ ćōḿḿàńď ţĥàţ ŵĩĺĺ ƀē ēxēćũţēď ƀàśēď ōń ţĥē ćũŕŕēńţ śēţţĩńĝś.",
"commandRequired": "Ćōḿḿàńď ĩś ŕēqũĩŕēď",
"commandThatWillRun": "Ćōḿḿàńď ţĥàţ ŵĩĺĺ ŕũń",
"configured": "Ćōńƒĩĝũŕēď",
"configureProfilesIntro": "Ćōńƒĩĝũŕē ƥŕōƒĩĺēś àńď ďēƒàũĺţś ƒōŕ ţĥĩś àĝēńţ.",
"conflictQuickChatsTitle": "Qũĩćķ Ćĥàţś:",
"conflictTasksTitle": "Ţàśķś:",
"conflictTierMappingRow": "<0>{{tier}}</0> ţĩēŕ ĩń {{workspace}} ƒōŕ {{provider}}",
"conflictTierMappingsTitle": "Ŵōŕķśƥàćē ţĩēŕ ḿàƥƥĩńĝś:",
"conflictWatchersTitle": "Ŵàţćĥēŕś (ŵĩĺĺ ƀē ďĩśàƀĺēď):",
"copied": "Ćōƥĩēď",
"copyInstallCommand": "Ćōƥŷ ĩńśţàĺĺ ćōḿḿàńď",
"copyToClipboard": "Ćōƥŷ ţō ćĺĩƥƀōàŕď",
"create": "Ćŕēàţē",
"createAgentProfileTitle": "Ćŕēàţē {{name}} Ƥŕōƒĩĺē",
"createNewProfile": "Ćŕēàţē ńēŵ ƥŕōƒĩĺē",
"createProfileIntro": "Ćŕēàţē à ńēŵ ƥŕōƒĩĺē ƒōŕ ţĥĩś àĝēńţ.",
"creating": "Ćŕēàţĩńĝ...",
"defaultProfile": "Ďēƒàũĺţ ƥŕōƒĩĺē",
"defaultSuffix": "(ďēƒàũĺţ)",
"delete": "Ďēĺēţē",
"deleteAgent": "Ďēĺēţē Àĝēńţ",
"deleteAgentDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē ţĥē àĝēńţ àńď àĺĺ ĩţś ƥŕōƒĩĺēś. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleteAgentProfileDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē ţĥĩś ƥŕōƒĩĺē. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleteAgentProfileTitle": "Ďēĺēţē àĝēńţ ƥŕōƒĩĺē?",
"deleteAgentTitle": "Ďēĺēţē {{name}}?",
"deleteAnyway": "Ďēĺēţē Àńŷŵàŷ",
"deleteAnywayConsequences": "Ţĥēśē śēśśĩōńś ŵĩĺĺ ńō ĺōńĝēŕ ƀē àƀĺē ţō ũśē ţĥĩś ƥŕōƒĩĺē àńď ţĥē ĺĩśţēď ŵàţćĥēŕś ŵĩĺĺ ƀē ďĩśàƀĺēď. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleteProfile": "Ďēĺēţē ƥŕōƒĩĺē",
"details": "ďēţàĩĺś",
"detectedAt": "Ďēţēćţēď àţ {{path}}",
"disableSetting": "Ďĩśàƀĺē {{name}}",
"displayName": "Ďĩśƥĺàŷ Ńàḿē",
"displayNameRequired": "Ďĩśƥĺàŷ ńàḿē ĩś ŕēqũĩŕēď",
"done": "Ďōńē",
"enableMcp": "Ēńàƀĺē ḾĆƤ",
"enableMcpDescription": "Àĺĺōŵ ţĥĩś ƥŕōƒĩĺē ţō ũśē ḾĆƤ śēŕvēŕś ďũŕĩńĝ śēśśĩōńś.",
"enableSetting": "Ēńàƀĺē {{name}}",
"everyProfileNeedsNameAndModel": "Ēvēŕŷ ƥŕōƒĩĺē ńēēďś à ńàḿē àńď ḿōďēĺ.",
"exampleValue": "ē.ĝ. {{example}}",
"failedToCreateAgent": "Ƒàĩĺēď ţō ćŕēàţē àĝēńţ",
"failedToDeleteProfile": "Ƒàĩĺēď ţō ďēĺēţē ƥŕōƒĩĺē",
"failedToLoadCommandPreview": "Ƒàĩĺēď ţō ĺōàď ćōḿḿàńď ƥŕēvĩēŵ",
"failedToLoadMcpConfig": "Ƒàĩĺēď ţō ĺōàď ḾĆƤ ćōńƒĩĝ",
"failedToRefresh": "Ƒàĩĺēď ţō ŕēƒŕēśĥ: {{error}}",
"failedToSaveAgent": "Ƒàĩĺēď ţō śàvē àĝēńţ",
"failedToSaveMcpConfig": "Ƒàĩĺēď ţō śàvē ḾĆƤ ćōńƒĩĝ",
"failedToSaveProfile": "Ƒàĩĺēď ţō śàvē ƥŕōƒĩĺē",
"filterModels": "Ƒĩĺţēŕ ḿōďēĺś...",
"fixInvalidMcpConfig": "Ƒĩx ĩńvàĺĩď ḾĆƤ ćōńƒĩĝũŕàţĩōń ƀēƒōŕē śàvĩńĝ.",
"fixInvalidMcpJson": "Ƒĩx ĩńvàĺĩď ḾĆƤ ĴŚŌŃ ƀēƒōŕē śàvĩńĝ.",
"flag": "Ƒĺàĝ",
"goToAgents": "Ĝō ţō Àĝēńţś",
"hostTerminal": "Ĥōśţ ţēŕḿĩńàĺ",
"hostTerminalDescription": "Ŕũńś à śĥēĺĺ ōń ţĥē ķàńďēv ĥōśţ. Ũśē ĩţ ţō ĩńśţàĺĺ ōŕ ćōńƒĩĝũŕē àĝēńţś ḿàńũàĺĺŷ.",
"install": "Ĩńśţàĺĺ",
"installationNotDetected": "Ĩńśţàĺĺàţĩōń ńōţ ďēţēćţēď",
"installed": "Ĩńśţàĺĺēď",
"installedAgents": "Ĩńśţàĺĺēď Àĝēńţś",
"installedAgentsDescription": "Àĝēńţś ďēţēćţēď ōń ţĥĩś ḿàćĥĩńē àŕē ŕēàďŷ ţō ćōńƒĩĝũŕē.",
"installing": "Ĩńśţàĺĺĩńĝ…",
"installQueued": "Qũēũēď…",
"invalidJson": "Ĩńvàĺĩď ĴŚŌŃ",
"invalidMcpConfig": "Ĩńvàĺĩď ḾĆƤ ćōńƒĩĝ",
"kandevMcpChip": "✓ {{name}}",
"kandevMcpTools": "Ţōōĺś: {{tools}}",
"manageAgentsFromMainPage": "Ḿàńàĝē àĝēńţś ƒŕōḿ ţĥē ḿàĩń Àĝēńţś ƥàĝē.",
"mcpConfigMustBeJsonObject": "ḾĆƤ śēŕvēŕś ćōńƒĩĝ ḿũśţ ƀē à ĴŚŌŃ ōƀĵēćţ",
"mcpConfiguration": "ḾĆƤ Ćōńƒĩĝũŕàţĩōń",
"mcpDraftHint": "ḾĆƤ ćōńƒĩĝ ŵĩĺĺ ƀē àƥƥĺĩēď àƒţēŕ ţĥē ƥŕōƒĩĺē ĩś śàvēď.",
"mcpKeyMustBeJsonObject": "{{key}} ḿũśţ ƀē à ĴŚŌŃ ōƀĵēćţ",
"mcpPassthroughInjectionHint": "Ĩń ĆĹĨ ƥàśśţĥŕōũĝĥ ḿōďē, ķàńďēv ĩńĵēćţś ţĥēśē ḾĆƤ śēŕvēŕś vĩà {{mechanism}}.",
"mcpSaveProfileHint": "Śàvē ţĥĩś ƥŕōƒĩĺē ţō ćōńƒĩĝũŕē ḾĆƤ śēŕvēŕś.",
"mcpServersHelp": "ḾĆƤ ďēƒĩńĩţĩōńś àŕē śţōŕēď ĩń ţĥē ďàţàƀàśē àńď ŕēśōĺvēď ƥēŕ ēxēćũţōŕ àţ ŕũńţĩḿē. Ţĥĩś ďōēś ńōţ ōvēŕŕĩďē ŷōũŕ ĺōćàĺ àĝēńţ ćōńƒĩĝ.",
"mcpServersJson": "ḾĆƤ śēŕvēŕś (ĴŚŌŃ)",
"model": "Ḿōďēĺ",
"modelHeading": "Ḿōďēĺ",
"modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.",
"noAuthHintLogin": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō śĩĝń ĩń, ţĥēń ŕēƒŕēśĥ.",
"noAuthHintNotInstalled": "Ĩńśţàĺĺ ţĥē àĝēńţ ĆĹĨ, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.",
"noAuthHintShell": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō ĺàũńćĥ à śĥēĺĺ, ŕũń ţĥē àĝēńţ'ś śĩĝń-ĩń ćōḿḿàńď, ţĥēń ŕēƒŕēśĥ.",
"noAuthLoginRequired": "Ńō àũţĥ - ĺōĝĩń ŕēqũĩŕēď",
"noCliFlagsConfigured": "Ńō ĆĹĨ ƒĺàĝś ćōńƒĩĝũŕēď. Àďď ōńē ƀēĺōŵ.",
"noCommandPreview": "Ńō ćōḿḿàńď ƥŕēvĩēŵ àvàĩĺàƀĺē.",
"noInstalledAgentsDetected": "Ńō ĩńśţàĺĺēď àĝēńţś ŵēŕē ďēţēćţēď. Ĩńśţàĺĺ ōńē ƀēĺōŵ, ţĥēń ćĺĩćķ Ŕēśćàń.",
"noModeFound": "Ńō ḿōďē ƒōũńď.",
"noModelsFound": "Ńō ḿōďēĺś ƒōũńď.",
"notInstalled": "Ńōţ ĩńśţàĺĺēď",
"openTerminal": "Ōƥēń ţēŕḿĩńàĺ",
"pageDescription": "Ďĩśćōvēŕ ĩńśţàĺĺēď àĝēńţś, ĩńśţàĺĺ ńēŵ ōńēś, àńď ḿàńàĝē ţĥēĩŕ ƥŕōƒĩĺēś.",
"popularServers": "Ƥōƥũĺàŕ śēŕvēŕś",
"probingCapabilities": "Ƥŕōƀĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś… ţĥĩś ũśũàĺĺŷ ţàķēś à ƒēŵ śēćōńďś.",
"profileInUseIntro": "Ţĥĩś ƥŕōƒĩĺē ĩś ćũŕŕēńţĺŷ ĩń ũśē. Ďēĺēţĩńĝ ĩţ ŵĩĺĺ àƒƒēćţ ţĥē ƒōĺĺōŵĩńĝ:",
"profileName": "Ƥŕōƒĩĺē ńàḿē",
"profileNameRequired": "Ƥŕōƒĩĺē ńàḿē ĩś ŕēqũĩŕēď.",
"profileNameRequiredDescription": "Ƥĺēàśē ēńţēŕ à ƥŕōƒĩĺē ńàḿē ƀēƒōŕē śàvĩńĝ.",
"profileNameRequiredTitle": "Ƥŕōƒĩĺē ńàḿē ĩś ŕēqũĩŕēď",
"profileNotFound": "Ƥŕōƒĩĺē ńōţ ƒōũńď.",
"profileSettings": "Ƥŕōƒĩĺē śēţţĩńĝś",
"profileStartModelSettings": "Ƥŕōƒĩĺē śţàŕţ ḿōďēĺ śēţţĩńĝś",
"ptyConnectionError": "Ćōńńēćţĩōń ēŕŕōŕ",
"promptTokenHint": "<0>{{token}}</0> ŵĩĺĺ ƀē ŕēƥĺàćēď ŵĩţĥ ŷōũŕ ţàśķ ďēśćŕĩƥţĩōń ōŕ ƒōĺĺōŵ-ũƥ ḿēśśàĝē.",
"refresh": "Ŕēƒŕēśĥ",
"refreshCapabilitiesTooltip": "Ŕēƒŕēśĥ àĝēńţ ćàƥàƀĩĺĩţĩēś (ḿōďēĺś + ḿōďēś)",
"remove": "Ŕēḿōvē",
"removeCliFlag": "Ŕēḿōvē {{name}}",
"removeThisProfile": "Ŕēḿōvē ţĥĩś ƥŕōƒĩĺē",
"requestFailed": "Ŕēqũēśţ ƒàĩĺēď",
"rescan": "Ŕēśćàń",
"retry": "Ŕēţŕŷ",
"retryUpdate": "Ŕēţŕŷ ũƥďàţē",
"retryVersionCheck": "Ŕēţŕŷ vēŕśĩōń ćĥēćķ",
"reviewUpdateBeforeApplying": "Ŕēvĩēŵ ţĥē ũƥďàţē ƀēƒōŕē Ķàńďēv ćĥàńĝēś ţĥĩś ĥōśţ ŕũńţĩḿē.",
"runtimeAlreadyUpToDate": "Ŕũńţĩḿē àĺŕēàďŷ ũƥ ţō ďàţē.",
"runtimeUpdatedRefreshFailed": "Ŕũńţĩḿē ũƥďàţēď, ƀũţ ćàƥàƀĩĺĩţĩēś ćōũĺď ńōţ ƀē ŕēƒŕēśĥēď: {{error}}",
"runtimeUpdatedSuccess": "Ŕũńţĩḿē ũƥďàţēď śũććēśśƒũĺĺŷ.",
"runtimeUpdateExplainer": "Ţĥĩś ũƥďàţēś ţĥē ḿàńàĝēď ŕũńţĩḿē ōń ţĥĩś Ķàńďēv ĥōśţ, ţĥēń ŕēƒŕēśĥēś ĩţś ḿōďēĺś àńď ćàƥàƀĩĺĩţĩēś.",
"runtimeUpdateFailed": "Ţĥē ŕũńţĩḿē ũƥďàţē ƒàĩĺēď.",
"runtimeUpdateSessionsNote": "Àćţĩvē śēśśĩōńś ķēēƥ ŕũńńĩńĝ; ōńĺŷ ĺàţēŕ ƥŕōƀēś àńď ĺàũńćĥēś ũśē ţĥē ŕēƒŕēśĥēď ŕũńţĩḿē.",
"runtimeVersion": "Ŕũńţĩḿē vēŕśĩōń",
"scanningForInstalledAgents": "Śćàńńĩńĝ ƒōŕ ĩńśţàĺĺēď àĝēńţś...",
"searchModes": "Śēàŕćĥ ḿōďēś...",
"selectAModel": "Śēĺēćţ à ḿōďēĺ...",
"sessionEnded": "Śēśśĩōń ēńďēď.",
"sessionEndedWithCode": "Śēśśĩōń ēńďēď (ēxĩţ {{code}}).",
"setupProfile": "Śēţũƥ Ƥŕōƒĩĺē",
"signInToAgent": "Śĩĝń ĩń: {{name}}",
"startingSession": "Śţàŕţĩńĝ śēśśĩōń…",
"startMode": "Śţàŕţ ḿōďē",
"startModel": "Śţàŕţ ḿōďēĺ",
"terminal": "Ţēŕḿĩńàĺ",
"tuiCommandHelp": "Ɓĩńàŕŷ ńàḿē ĺōōķēď ũƥ ōń ƤÀŢĤ. Ũśē <0>{{token}}</0> ţō ĩńśēŕţ ţĥē ḿōďēĺ vàĺũē.",
"tuiModelHelp": "Ƥŕōƒĩĺē ĺàƀēĺ śĥōŵń ĩń ţĥē àĝēńţ śēĺēćţōŕ ďŕōƥďōŵń",
"unableToStartUpdate": "Ũńàƀĺē ţō śţàŕţ ũƥďàţē: {{error}}",
"untitledQuickChat": "Ũńţĩţĺēď qũĩćķ ćĥàţ",
"untitledTask": "Ũńţĩţĺēď ţàśķ",
"updateAgent": "Ũƥďàţē {{name}}",
"updatePhaseQueued": "Ũƥďàţē qũēũēď…",
"updatePhaseRefreshing": "Ŕēƒŕēśĥĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś…",
"updatePhaseResolving": "Ćĥēćķĩńĝ ĺàţēśţ vēŕśĩōń…",
"updatePhaseUpdating": "Ũƥďàţĩńĝ ŕũńţĩḿē…",
"upToDate": "Ũƥ ţō ďàţē",
"valueOptional": "vàĺũē (ōƥţĩōńàĺ)",
"valueOptionalLabel": "Vàĺũē (ōƥţĩōńàĺ)",
"valuePlaceholder": "vàĺũē",
"watcherKindGithubIssue": "ĜĩţĤũƀ Ĩśśũēś",
"watcherKindGithubReview": "ĜĩţĤũƀ ƤŔ Ŕēvĩēŵś",
"watcherKindJira": "Ĵĩŕà",
"watcherKindLinear": "Ĺĩńēàŕ"
}