Initial CodeCraft: web-based coding harness for AI models

Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash,
web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare),
permissions with modes and rules, Anthropic + OpenAI-compatible providers,
MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics,
compaction, commented TOML config editable via toml_edit.

React PWA: chat with live streaming, permission/question cards, modes
(build/plan/craft), settings for providers/models/prices/MCP/permissions,
raw config editor, statistics dashboard, i18n (en/de). Docker setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+729
View File
@@ -0,0 +1,729 @@
import { useEffect, useState } from "react";
import { get, patch as apiPatch, post, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { applyTheme, getTheme, type Theme } from "../theme";
import { ModelSelect } from "./ModelSelect";
type Json = any;
interface ConfigResponse {
config: Json;
workspace: string;
currency: string;
}
type Patch = { path: string[]; value: unknown };
const MASK = "••••••••";
async function savePatches(patches: Patch[]) {
await apiPatch("/config", { patches });
}
function useSaver(reload: () => void) {
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
const t = useT();
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
setNotice({ ok: true, text: t("settings.saved") });
reload();
} catch (e: any) {
setNotice({ ok: false, text: e.message });
}
};
const view = notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>;
return { run, view };
}
// --- general ----------------------------------------------------------------
function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const t = useT();
const s = cfg.config.server;
const c = cfg.config.craft;
const [form, setForm] = useState({
default_model: s.default_model ?? "",
small_model: s.small_model ?? "",
currency: s.currency ?? "USD",
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
clean_room: c.clean_room ?? true,
chromium: c.chromium ?? "chromium",
widths: (c.widths ?? []).join(", "),
});
const [theme, setThemeState] = useState<Theme>(getTheme());
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
const { run, view } = useSaver(reload);
const save = () =>
run(() =>
savePatches([
{ path: ["server", "default_model"], value: form.default_model },
{ path: ["server", "small_model"], value: form.small_model },
{ path: ["server", "currency"], value: form.currency },
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
{ path: ["craft", "clean_room"], value: form.clean_room },
{ path: ["craft", "chromium"], value: form.chromium },
{ path: ["craft", "widths"], value: String(form.widths).split(/[\s,]+/).filter(Boolean).map(Number) },
]),
);
return (
<>
<div className="panel">
<h3>{t("settings.interface")}</h3>
<div className="form">
<label>{t("settings.language")}</label>
<select value={getLang()} onChange={(e) => setLang(e.target.value)}>
{Object.entries(languages).map(([k, v]) => (
<option key={k} value={k}>
{v.name}
</option>
))}
</select>
<label>{t("settings.theme")}</label>
<select
value={theme}
onChange={(e) => {
const v = e.target.value as Theme;
setThemeState(v);
applyTheme(v);
}}
>
<option value="system">{t("settings.themeSystem")}</option>
<option value="dark">{t("settings.themeDark")}</option>
<option value="light">{t("settings.themeLight")}</option>
</select>
<label>{t("settings.notifications")}</label>
<div>
{notif === "granted" ? (
<span className="chip">{t("settings.notificationsOn")}</span>
) : (
<button className="small" onClick={() => Notification.requestPermission().then(setNotif)} disabled={notif === "denied"}>
{t("settings.notificationsEnable")}
</button>
)}
</div>
<div className="hint">{t("settings.notificationsHint")}</div>
</div>
</div>
<div className="panel">
<h3>{t("settings.server")}</h3>
<div className="form">
<label>{t("settings.workspace")}</label>
<code>{cfg.workspace}</code>
<label>{t("settings.defaultModel")}</label>
<ModelSelect value={form.default_model} onChange={(v) => setForm({ ...form, default_model: v })} />
<label>{t("settings.smallModel")}</label>
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
<div className="hint">{t("settings.smallModelHint")}</div>
<label>{t("settings.currency")}</label>
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
<label>{t("settings.autoCompact")}</label>
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
<div className="hint">{t("settings.autoCompactHint")}</div>
</div>
</div>
<div className="panel">
<h3>{t("settings.craft")}</h3>
<div className="form">
<label>{t("settings.cleanRoom")}</label>
<input type="checkbox" checked={form.clean_room} onChange={(e) => setForm({ ...form, clean_room: e.target.checked })} style={{ justifySelf: "start" }} />
<div className="hint">{t("settings.cleanRoomHint")}</div>
<label>{t("settings.chromium")}</label>
<input value={form.chromium} onChange={(e) => setForm({ ...form, chromium: e.target.value })} />
<label>{t("settings.widths")}</label>
<input value={form.widths} onChange={(e) => setForm({ ...form, widths: e.target.value })} />
</div>
</div>
{view}
<button className="primary" onClick={save}>
{t("common.save")}
</button>
</>
);
}
// --- providers --------------------------------------------------------------
interface ModelRow {
id: string;
name?: string;
context?: number;
max_output?: number;
reasoning?: boolean;
vision?: boolean;
effort?: string;
input_price?: number;
output_price?: number;
cache_read_price?: number;
cache_write_price?: number;
}
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
custom: { kind: "openai", base_url: "http://", api_key: "" },
};
function normalizeModels(models: Json[]): ModelRow[] {
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
}
function cleanModel(m: ModelRow) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(m)) {
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
if (v === false && (k === "reasoning" || k === "vision")) continue;
out[k] = v;
}
return out;
}
function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: () => void }) {
const t = useT();
const [form, setForm] = useState({ ...p, api_key: p.api_key === MASK ? "" : p.api_key });
const [models, setModels] = useState<ModelRow[]>(normalizeModels(p.models));
const [open, setOpen] = useState(false);
const discovered = useModels().find((x) => x.provider === name)?.models ?? [];
const { run, view } = useSaver(reload);
const num = (v: string) => (v === "" ? undefined : Number(v));
const setModel = (i: number, patch: Partial<ModelRow>) => setModels((ms) => ms.map((m, j) => (j === i ? { ...m, ...patch } : m)));
const save = () =>
run(() => {
const patches: Patch[] = [
{ path: ["providers", name, "kind"], value: form.kind },
{ path: ["providers", name, "base_url"], value: form.base_url },
{ path: ["providers", name, "enabled"], value: !!form.enabled },
{ path: ["providers", name, "models"], value: models.filter((m) => m.id.trim()).map(cleanModel) },
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
];
// Empty key field = keep the stored (masked) key.
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
return savePatches(patches);
});
const remove = () => {
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
void run(() => savePatches([{ path: ["providers", name], value: null }]));
};
const addDiscovered = () => {
const have = new Set(models.map((m) => m.id));
setModels([...models, ...discovered.filter((d) => !have.has(d.id)).map((d) => ({ id: d.id, context: d.context }))]);
};
return (
<div className="panel">
<h3>
<input type="checkbox" checked={!!form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} title={t("settings.enabled")} />
{name}
<span className="chip">{form.kind}</span>
<span className="chip">{t("settings.modelCount", { n: models.length || discovered.length })}</span>
<span className="spacer" />
<button className="small" onClick={() => setOpen(!open)}>
{open ? t("common.collapse") : t("common.edit")}
</button>
</h3>
{open && (
<>
<div className="form">
<label>{t("settings.kind")}</label>
<select value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
<option value="anthropic">Anthropic Messages API</option>
<option value="openai">OpenAI-compatible</option>
</select>
<label>{t("settings.baseUrl")}</label>
<input value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} />
<label>{t("settings.apiKey")}</label>
<input
type="password"
value={form.api_key}
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
/>
<div className="hint">{t("settings.apiKeyHint")}</div>
<label>{t("settings.authPrefix")}</label>
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
</div>
<h3 style={{ marginTop: 16 }}>
{t("settings.models")}
<span className="spacer" />
{discovered.length > 0 && (
<button className="small" onClick={addDiscovered}>
{t("settings.addDiscovered", { n: discovered.length })}
</button>
)}
<button className="small" onClick={() => setModels([...models, { id: "" }])}>
+ {t("settings.addModel")}
</button>
</h3>
<div className="hint" style={{ marginBottom: 6, fontSize: 12, color: "var(--muted)" }}>
{t("settings.pricesHint")}
</div>
<div style={{ overflowX: "auto" }}>
<table className="grid">
<thead>
<tr>
<th>ID</th>
<th>{t("settings.context")}</th>
<th>{t("settings.maxOutput")}</th>
<th title={t("settings.reasoning")}>🧠</th>
<th title={t("settings.vision")}>👁</th>
<th>{t("settings.effort")}</th>
<th>{t("settings.priceIn")}</th>
<th>{t("settings.priceOut")}</th>
<th>{t("settings.priceCacheRead")}</th>
<th>{t("settings.priceCacheWrite")}</th>
<th />
</tr>
</thead>
<tbody>
{models.map((m, i) => (
<tr key={i}>
<td>
<input value={m.id} onChange={(e) => setModel(i, { id: e.target.value })} style={{ minWidth: 160 }} />
</td>
<td>
<input type="number" value={m.context ?? ""} onChange={(e) => setModel(i, { context: num(e.target.value) })} />
</td>
<td>
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
</td>
<td>
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
</td>
<td>
<input type="checkbox" checked={!!m.vision} onChange={(e) => setModel(i, { vision: e.target.checked })} />
</td>
<td>
<select value={m.effort ?? ""} onChange={(e) => setModel(i, { effort: e.target.value || undefined })}>
<option value="">–</option>
{["low", "medium", "high", "xhigh", "max"].map((x) => (
<option key={x}>{x}</option>
))}
</select>
</td>
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
<td key={k}>
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
</td>
))}
<td>
<button className="small ghost danger" onClick={() => setModels(models.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
{view}
<div className="row" style={{ marginTop: 12 }}>
<button className="primary" onClick={save}>
{t("common.save")}
</button>
<span className="spacer" />
<button className="danger" onClick={remove}>
{t("common.delete")}
</button>
</div>
</>
)}
</div>
);
}
function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const t = useT();
const [name, setName] = useState("");
const [preset, setPreset] = useState("anthropic");
const { run, view } = useSaver(reload);
const providers = cfg.config.providers ?? {};
const add = () => {
const n = name.trim() || preset;
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
const p = PRESETS[preset];
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
void run(() => savePatches([{ path: ["providers", n], value }]));
setName("");
};
return (
<>
<div className="row" style={{ marginBottom: 12 }}>
<button onClick={() => post("/providers/reload").then(reload)}>{t("settings.reloadProviders")}</button>
</div>
{Object.entries(providers).map(([n, p]) => (
<ProviderEditor key={n + JSON.stringify(p)} name={n} p={p} reload={reload} />
))}
<div className="panel">
<h3>{t("settings.addProvider")}</h3>
<div className="row">
<select value={preset} onChange={(e) => setPreset(e.target.value)}>
{Object.keys(PRESETS).map((k) => (
<option key={k}>{k}</option>
))}
</select>
<input placeholder={t("settings.providerName")} value={name} onChange={(e) => setName(e.target.value)} />
<button className="primary" onClick={add}>
+ {t("common.add")}
</button>
</div>
{view}
</div>
</>
);
}
// --- MCP --------------------------------------------------------------------
function linesToMap(text: string, sep: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const i = line.indexOf(sep);
if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + sep.length).trim();
}
return out;
}
function mapToLines(m: Record<string, string> | undefined, sep: string) {
return Object.entries(m ?? {})
.map(([k, v]) => `${k}${sep}${v}`)
.join("\n");
}
function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload: () => void; isNew?: boolean }) {
const t = useT();
const status = useStore((s) => s.mcp[name]);
const [form, setForm] = useState({
name,
type: m.type ?? "stdio",
command: m.command ?? "",
args: (m.args ?? []).join("\n"),
env: mapToLines(m.env, "="),
url: m.url ?? "",
headers: mapToLines(m.headers, ": "),
enabled: m.enabled ?? true,
});
const [open, setOpen] = useState(!!isNew);
const { run, view } = useSaver(reload);
const save = () => {
const n = form.name.trim();
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
if (form.type === "stdio") {
value.command = form.command;
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
value.env = linesToMap(form.env, "=");
} else {
value.url = form.url;
value.headers = linesToMap(form.headers, ":");
}
return run(async () => {
const patches: Patch[] = [];
if (!isNew && n !== name) patches.push({ path: ["mcp", name], value: null });
patches.push({ path: ["mcp", n], value });
await savePatches(patches);
});
};
const st = status?.status;
const stateLabel = st ? t(`mcp.state.${st.state}` as Key) : isNew ? "" : t("mcp.state.unknown");
return (
<div className="panel">
<h3>
{isNew ? t("settings.addMcp") : name}
{!isNew && <span className={`chip ${st?.state === "failed" ? "warn" : ""}`}>{stateLabel}</span>}
{st?.state === "connected" && <span className="chip">{t("mcp.tools", { n: st.tools ?? 0 })}</span>}
<span className="spacer" />
{!isNew && (
<button className="small" onClick={() => setOpen(!open)}>
{open ? t("common.collapse") : t("common.edit")}
</button>
)}
</h3>
{st?.state === "failed" && <div className="notice err">{st.error}</div>}
{open && (
<>
<div className="form">
<label>{t("settings.name")}</label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<label>{t("settings.kind")}</label>
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="stdio">stdio</option>
<option value="http">Streamable HTTP</option>
</select>
{form.type === "stdio" ? (
<>
<label>{t("mcp.command")}</label>
<input value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="npx" />
<label>{t("mcp.args")}</label>
<textarea rows={3} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} className="mono" />
<label>{t("mcp.env")}</label>
<textarea rows={2} value={form.env} onChange={(e) => setForm({ ...form, env: e.target.value })} className="mono" placeholder="KEY=${SECRET}" />
</>
) : (
<>
<label>URL</label>
<input value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://…/mcp" />
<label>{t("mcp.headers")}</label>
<textarea rows={2} value={form.headers} onChange={(e) => setForm({ ...form, headers: e.target.value })} className="mono" placeholder="Authorization: Bearer ${TOKEN}" />
</>
)}
<label>{t("settings.enabled")}</label>
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} style={{ justifySelf: "start" }} />
</div>
{status?.tools?.length ? (
<details style={{ marginTop: 10 }}>
<summary style={{ cursor: "pointer" }}>{t("mcp.tools", { n: status.tools.length })}</summary>
<ul style={{ fontSize: 12.5 }}>
{status.tools.map((tool) => (
<li key={tool.name}>
<code>{tool.name}</code> – {tool.description}
</li>
))}
</ul>
</details>
) : null}
{view}
<div className="row" style={{ marginTop: 12 }}>
<button className="primary" onClick={save}>
{t("common.save")}
</button>
<span className="spacer" />
{!isNew && (
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
{t("common.delete")}
</button>
)}
</div>
</>
)}
</div>
);
}
function Mcp({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const t = useT();
const servers = cfg.config.mcp ?? {};
return (
<>
<div className="row" style={{ marginBottom: 12 }}>
<button onClick={() => post("/mcp/reload")}>{t("mcp.reconnect")}</button>
</div>
{Object.keys(servers).length === 0 && <div className="notice">{t("mcp.none")}</div>}
{Object.entries(servers).map(([n, m]) => (
<McpEditor key={n + JSON.stringify(m)} name={n} m={m} reload={reload} />
))}
<McpEditor key={`new-${Object.keys(servers).length}`} name="" m={{}} reload={reload} isNew />
</>
);
}
// --- permissions ------------------------------------------------------------
function RuleTable({ rules, setRules, choices, keyLabel }: { rules: [string, string][]; setRules: (r: [string, string][]) => void; choices: string[]; keyLabel: string }) {
const t = useT();
return (
<table className="grid">
<thead>
<tr>
<th>{keyLabel}</th>
<th>{t("permissions.rule")}</th>
<th />
</tr>
</thead>
<tbody>
{rules.map(([k, v], i) => (
<tr key={i}>
<td>
<input className="mono" value={k} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [e.target.value, r[1]] : r)))} />
</td>
<td>
<select value={v} onChange={(e) => setRules(rules.map((r, j) => (j === i ? [r[0], e.target.value] : r)))}>
{choices.map((c) => (
<option key={c} value={c}>
{t(`permissions.${c}` as Key)}
</option>
))}
</select>
</td>
<td>
<button className="small ghost danger" onClick={() => setRules(rules.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
</button>
</td>
</tr>
))}
<tr>
<td colSpan={3}>
<button className="small" onClick={() => setRules([...rules, ["", choices[0]]])}>
+ {t("common.add")}
</button>
</td>
</tr>
</tbody>
</table>
);
}
function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
const t = useT();
const { bash_rules = {}, ...tools } = cfg.config.permissions ?? {};
const [toolRules, setToolRules] = useState<[string, string][]>(Object.entries(tools) as [string, string][]);
const [bashRules, setBashRules] = useState<[string, string][]>(Object.entries(bash_rules) as [string, string][]);
const { run, view } = useSaver(reload);
const save = () =>
run(() => {
const patches: Patch[] = [];
for (const k of Object.keys(tools)) if (!toolRules.some(([n]) => n === k)) patches.push({ path: ["permissions", k], value: null });
for (const [k, v] of toolRules) if (k.trim()) patches.push({ path: ["permissions", k.trim()], value: v });
for (const k of Object.keys(bash_rules)) if (!bashRules.some(([n]) => n === k)) patches.push({ path: ["permissions", "bash_rules", k], value: null });
for (const [k, v] of bashRules) if (k.trim()) patches.push({ path: ["permissions", "bash_rules", k.trim()], value: v });
return savePatches(patches);
});
return (
<>
<div className="notice">{t("permissions.intro")}</div>
<div className="two-col">
<div className="panel">
<h3>{t("permissions.tools")}</h3>
<RuleTable rules={toolRules} setRules={setToolRules} choices={["allow", "ask", "deny"]} keyLabel={t("permissions.tool")} />
</div>
<div className="panel">
<h3>{t("permissions.bash")}</h3>
<div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 6 }}>{t("permissions.bashHint")}</div>
<RuleTable rules={bashRules} setRules={setBashRules} choices={["allow", "deny"]} keyLabel={t("permissions.pattern")} />
</div>
</div>
{view}
<button className="primary" onClick={save}>
{t("common.save")}
</button>
</>
);
}
// --- agents (read-only overview) ----------------------------------------------
function Agents() {
const t = useT();
const agents = useFetch<Json[]>("/agents").data ?? [];
const cmds = useFetch<{ commands: Json[]; skills: Json[] }>("/commands").data;
return (
<>
<div className="notice">{t("agents.intro")}</div>
<div className="panel">
<h3>{t("agents.title")}</h3>
<table className="grid">
<tbody>
{agents.map((a) => (
<tr key={a.name}>
<td>
<code>{a.name}</code>
</td>
<td>
<span className="chip">{a.mode}</span> {a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
</td>
<td>{a.description}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="two-col">
<div className="panel">
<h3>{t("agents.commands")}</h3>
{cmds?.commands.length ? cmds.commands.map((c) => <div key={c.name}><code>/{c.name}</code> – {c.description}</div>) : <div className="empty">–</div>}
</div>
<div className="panel">
<h3>{t("agents.skills")}</h3>
{cmds?.skills.length ? cmds.skills.map((s) => <div key={s.name}><code>{s.name}</code> – {s.description}</div>) : <div className="empty">–</div>}
</div>
</div>
</>
);
}
// --- raw ----------------------------------------------------------------------
function Raw({ reload }: { reload: () => void }) {
const t = useT();
const [text, setText] = useState<string>();
const { run, view } = useSaver(reload);
useEffect(() => {
get<{ text: string }>("/config/raw").then((r) => setText(r.text));
}, []);
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
return (
<>
<div className="notice">{t("settings.rawHint")}</div>
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
{view}
<div className="row" style={{ marginTop: 8 }}>
<button className="primary" onClick={() => run(() => put("/config/raw", { text }))}>
{t("common.save")}
</button>
<button onClick={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))}>{t("settings.revert")}</button>
</div>
</>
);
}
// --- page -------------------------------------------------------------------
const TABS = ["general", "providers", "mcp", "permissions", "agents", "raw"] as const;
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
const t = useT();
const active = (TABS as readonly string[]).includes(tab ?? "") ? tab! : "general";
const { data: cfg, error, reload } = useFetch<ConfigResponse>("/config");
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
</button>
<div className="grow">
<h1>{t("nav.settings")}</h1>
</div>
</div>
<div className="page">
<div className="page-inner">
<div className="tabs" role="tablist">
{TABS.map((x) => (
<button key={x} role="tab" aria-selected={active === x} className={active === x ? "active" : ""} onClick={() => navigate(`/settings/${x}`)}>
{t(`settings.tab.${x}` as Key)}
</button>
))}
</div>
{error && <div className="notice err">{error}</div>}
{!cfg && !error && <div className="empty">{t("common.loading")}</div>}
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}
{cfg && active === "permissions" && <Permissions key={JSON.stringify(cfg.config.permissions)} cfg={cfg} reload={reload} />}
{active === "agents" && <Agents />}
{active === "raw" && <Raw reload={reload} />}
</div>
</div>
</>
);
}