Add web terminal, git credentials, OpenSCAD, review loops, automations

- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay)
- Per-user git credentials and commit identity (credential store in the user's home)
- OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card
- Craft phase inferred from tool activity when the agent does not report it
- Configurable review loop per mode (rounds, reviewer model, criteria)
- Delete config and Markdown agents from the agent editor
- Automations: cron-scheduled agents from Markdown job files, schedule tool, page
- change_directory tool (only on the user's request)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:00:38 +01:00
1 parent a0d0e707f2
commit df1924b0a1
41 files changed
+2809 -34

No files matched your search

+7 -1
View File
@@ -112,7 +112,13 @@ export function ActivityLine({
return (
<div className="activity" role="status">
<Dots />
<span>{kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}</span>
<span>
{kind === "review"
? t("activity.reviewing", { round: what })
: kind === "model"
? t("chat.waitingModel", { model: what })
: t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}
</span>
</div>
);
}
+36 -4
View File
@@ -110,6 +110,28 @@ function BackgroundResult({ text }: { text: string }) {
);
}
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
/** Findings of the review loop, sent back to the agent. */
function RefineFeedback({ text }: { text: string }) {
const t = useT();
const m = text.match(REFINE_RE);
if (!m) return null;
const [, round, reviewer, body] = m;
return (
<details className="tool refine-card" open>
<summary>
<Icon name="shield" />
<span className="tool-title">{t("chat.reviewRound", { round })}</span>
<span className="chip">{reviewer}</span>
</summary>
<div className="tool-body">
<Markdown text={body} />
</div>
</details>
);
}
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
@@ -285,6 +307,16 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<Icon name="share" />
</button>
)}
<button
className="small ghost hide-mobile"
onClick={() => {
sessionStorage.setItem("codecraft.termCwd", session.cwd);
navigate("/terminal/new");
}}
title={t("terminal.here")}
>
<Icon name="terminal" />
</button>
{!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" />
@@ -352,14 +384,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
);
}
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const background = allTexts.filter((p) => BG_RE.test(p.text));
const texts = allTexts.filter((p) => !BG_RE.test(p.text));
const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
if (texts.length === 0 && imgs.length === 0) {
return background.length ? (
<div key={m.id} className="msg">
{background.map((p, i) => (
<BackgroundResult key={i} text={p.text} />
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
))}
</div>
) : null;
@@ -367,7 +399,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
return (
<div key={m.id} className="msg">
{background.map((p, i) => (
<BackgroundResult key={i} text={p.text} />
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
))}
<div className="msg-user">
{!busy && !readOnly && (
+10
View File
@@ -140,6 +140,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
}
};
// "Edit file" links from other pages (e.g. automations) open the file directly.
useEffect(() => {
const target = sessionStorage.getItem("codecraft.openFile");
if (!target) return;
sessionStorage.removeItem("codecraft.openFile");
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
void openFile(target);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = useCallback(
async (force = false) => {
if (pane.kind !== "file") return;
+7
View File
@@ -12,6 +12,7 @@ const P: Record<string, ReactNode> = {
"arrow-up": <path d="M12 19V5M6 11l6-6 6 6" />,
"arrow-down": <path d="M12 5v14M6 13l6 6 6-6" />,
"arrow-left": <path d="M19 12H5M11 6l-6 6 6 6" />,
"arrow-right": <path d="M5 12h14M13 6l6 6-6 6" />,
"chevron-right": <path d="m9 6 6 6-6 6" />,
"chevron-down": <path d="m6 9 6 6 6-6" />,
share: (
@@ -192,6 +193,12 @@ const P: Record<string, ReactNode> = {
),
dot: <circle cx="12" cy="12" r="4.5" className="fill" />,
plus: <path d="M12 5v14M5 12h14" />,
clock: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</>
),
minus: <path d="M5 12h14" />,
upload: <path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />,
more: (
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import { del, get, patch, post } from "../api";
import { fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
interface JobInfo {
id: string;
path: string;
enabled: boolean;
last_run?: number | null;
last_session_id?: string | null;
last_status?: string | null;
next_run?: number | null;
error?: string;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
}
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
/** Automations: agents started on a schedule from a Markdown job file. */
export function Jobs({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [jobs, setJobs] = useState<JobInfo[]>();
const [path, setPath] = useState("");
const [busy, setBusy] = useState<string>();
const reload = () =>
get<JobInfo[]>("/jobs")
.then(setJobs)
.catch((e) => toast(e.message));
useEffect(() => {
void reload();
const h = setInterval(reload, 15000);
return () => clearInterval(h);
}, []);
const act = async (id: string, f: () => Promise<unknown>) => {
setBusy(id);
try {
await f();
await reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(undefined);
}
};
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.jobs")}</h1>
</div>
</div>
<div className="page">
<div className="page-inner" style={{ maxWidth: 980 }}>
<div className="notice">{t("jobs.intro")}</div>
{!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => (
<div key={j.id} className={`panel job ${j.enabled ? "" : "off"}`}>
<div className="job-head">
<Icon name="clock" className="job-icon" />
<div style={{ flex: 1, minWidth: 0 }}>
<h3>{j.spec?.name ?? j.path.split("/").pop()}</h3>
<div className="hint mono">{j.path}</div>
</div>
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
<input type="checkbox" checked={j.enabled} onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { enabled: e.target.checked }))} />
<span>{t(j.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
</div>
{j.error ? (
<div className="notice err">{j.error}</div>
) : (
<div className="job-grid">
<span>{t("jobs.schedule")}</span>
<code>{j.spec?.schedule}</code>
<span>{t("jobs.next")}</span>
<span>{j.enabled ? fmtDate(j.next_run) : "–"}</span>
<span>{t("jobs.last")}</span>
<span>
{j.last_run ? `${fmtRelative(j.last_run)} · ` : "–"}
{j.last_status && <span className={`chip ${j.last_status === "done" ? "ok" : j.last_status === "running" ? "" : "warn"}`}>{t(`jobs.status.${j.last_status.startsWith("error") ? "error" : j.last_status}` as any)}</span>}
{j.last_session_id && (
<button className="link-btn" style={{ marginLeft: 8 }} onClick={() => navigate(`/s/${j.last_session_id}`)}>
{t("jobs.openRun")}
</button>
)}
</span>
<span>{t("jobs.mode")}</span>
<span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min
</span>
</div>
)}
{j.last_status?.startsWith("error") && <div className="notice err">{j.last_status}</div>}
{j.spec && (
<details className="job-prompt">
<summary>{t("jobs.prompt")}</summary>
<pre>{j.spec.prompt}</pre>
</details>
)}
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
navigate(`/s/${r.session_id}`);
})}>
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
</button>
<button
className="small ghost"
onClick={() => {
sessionStorage.setItem("codecraft.openFile", j.path);
navigate("/files");
}}
>
<Icon name="edit" /> {t("jobs.editFile")}
</button>
<span className="grow" />
<button
className="small ghost danger"
onClick={async () => {
if (await confirmDialog({ message: t("jobs.deleteConfirm", { name: j.spec?.name ?? j.path }), danger: true })) void act(j.id, () => del(`/jobs/${j.id}`));
}}
>
<Icon name="trash" /> {t("jobs.remove")}
</button>
</div>
</div>
))}
<div className="panel">
<h3>{t("jobs.register")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("jobs.registerHint")}</div>
<div className="row" style={{ gap: 8 }}>
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
<button disabled={!path.trim()} onClick={() => act("new", async () => {
await post("/jobs", { path: path.trim() });
setPath("");
})}>
{t("jobs.registerBtn")}
</button>
</div>
<pre className="job-example">{`---
name: "Nightly dependency check"
schedule: "0 3 * * *"
agent: build
permission_mode: auto
timeout_minutes: 60
---
Check the project for outdated dependencies …`}</pre>
</div>
</div>
</div>
</>
);
}
+15 -5
View File
@@ -85,6 +85,12 @@ const ICONS: Record<string, ReactNode> = {
<path d="m20 20-4.5-4.5M9 9l4 4M13 9l-4 4" />
</>,
),
cad: svg(
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
</>,
),
folder: svg(<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />),
git: svg(
<>
@@ -190,12 +196,12 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
}, [target]);
// Working on existing code usually happens in an existing folder.
useEffect(() => {
setFolderMode((m) => (m === "git" ? m : task === "craft" ? "new" : "existing"));
setFolderMode((m) => (m === "git" ? m : task === "craft" || task === "cad" ? "new" : "existing"));
}, [task]);
const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
const autoName = useMemo(
() => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : "project"),
() => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : task === "cad" ? "3d-model" : "project"),
[text, url, target, task, folderMode, repoName],
);
useEffect(() => {
@@ -203,7 +209,9 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
}, [autoName, nameTouched]);
const ideas: Key[] =
task !== "craft"
task === "cad"
? ["wizard.idea.cadBox", "wizard.idea.cadBracket"]
: task !== "craft"
? task === "plan"
? ["wizard.idea.planFeature", "wizard.idea.planRefactor"]
: ["wizard.idea.buildTests", "wizard.idea.buildBug"]
@@ -238,6 +246,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const prompts = (uploaded: string[]): string[] => {
const desc = text.trim();
const fileList = uploaded.length ? `\n\nFiles: ${uploaded.join(", ")}` : "";
if (task === "cad") return [`/cad ${desc}${images.length ? " (sketch attached)" : ""}${fileList}`];
if (task !== "craft") return [desc + fileList];
switch (target) {
case "website":
@@ -311,7 +320,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const s = await post<{ id: string }>("/sessions", {
cwd,
model: model || undefined,
agent: task,
agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined,
});
const msgs = prompts(uploaded).filter((m) => m.trim());
@@ -343,6 +352,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<Step n={n++} title={t("wizard.whatToDo")}>
<div className="wiz-cards">
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
<Card icon="plan" title={t("wizard.task.plan")} desc={t("wizard.task.planDesc")} active={task === "plan"} onClick={() => setTask("plan")} />
{custom.map((a) => (
@@ -436,7 +446,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
>
<textarea
value={text}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : "wizard.placeholder.task")}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
onChange={(e) => setText(e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length) {
+8
View File
@@ -152,6 +152,14 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<Icon name="image" className="nav-icon" />
{t("nav.images")}
</button>
<button className={`ghost ${section === "terminal" ? "active" : ""}`} onClick={() => navigate("/terminal")}>
<Icon name="terminal" className="nav-icon" />
{t("nav.terminal")}
</button>
<button className={`ghost ${section === "jobs" ? "active" : ""}`} onClick={() => navigate("/jobs")}>
<Icon name="clock" className="nav-icon" />
{t("nav.jobs")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<Icon name="chart" className="nav-icon" />
{t("nav.stats")}
+267
View File
@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { Terminal as XTerm, type ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { del, get, post } from "../api";
import { useT } from "../i18n";
import { navigate } from "../router";
import { onThemeChange } from "../theme";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
interface TermInfo {
id: string;
cwd: string;
created_at: number;
alive: boolean;
attached: boolean;
}
/** Where the next new terminal opens (set by "open terminal here" buttons). */
export const TERM_CWD = "codecraft.termCwd";
/** xterm colours from the active theme's CSS variables. */
function themeColors(): ITheme {
const css = getComputedStyle(document.documentElement);
const v = (name: string) => css.getPropertyValue(name).trim();
return {
background: v("--bg0"),
foreground: v("--fg1"),
cursor: v("--accent"),
cursorAccent: v("--bg0"),
selectionBackground: `${v("--blue")}55`,
black: v("--bg2"),
red: v("--red"),
green: v("--green"),
yellow: v("--accent"),
blue: v("--blue"),
magenta: v("--purple"),
cyan: v("--aqua"),
white: v("--fg2"),
brightBlack: v("--muted"),
brightRed: v("--red"),
brightGreen: v("--green"),
brightYellow: v("--accent2"),
brightBlue: v("--blue"),
brightMagenta: v("--purple"),
brightCyan: v("--aqua"),
brightWhite: v("--fg0"),
};
}
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }) {
const t = useT();
const [list, setList] = useState<TermInfo[]>([]);
const [error, setError] = useState<string>();
const reload = () =>
get<TermInfo[]>("/terminals")
.then(setList)
.catch((e) => setError(e.message));
useEffect(() => {
void reload();
}, [id]);
const open = async () => {
try {
const cwd = sessionStorage.getItem(TERM_CWD) ?? localStorage.getItem("codecraft.lastDir") ?? undefined;
sessionStorage.removeItem(TERM_CWD);
const info = await post<TermInfo>("/terminals", { cwd, cols: 100, rows: 30 });
await reload();
navigate(`/terminal/${info.id}`);
} catch (e: any) {
toast(e.message);
}
};
// /terminal/new opens a fresh one; /terminal shows the newest.
useEffect(() => {
if (id === "new") void open();
else if (!id && list.length) navigate(`/terminal/${list[list.length - 1].id}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, list.length]);
const close = async (tid: string) => {
await del(`/terminals/${tid}`).catch(() => undefined);
const rest = list.filter((x) => x.id !== tid);
setList(rest);
navigate(rest.length ? `/terminal/${rest[rest.length - 1].id}` : "/terminal");
};
const active = list.find((x) => x.id === id);
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow term-tabs">
{list.map((x) => (
<span key={x.id} className={`term-tab ${x.id === id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
<button className="ghost small" onClick={() => navigate(`/terminal/${x.id}`)} title={x.cwd}>
<Icon name="terminal" /> {basename(x.cwd)}
</button>
<button className="ghost small" onClick={() => close(x.id)} aria-label={t("terminal.close")} title={t("terminal.close")}>
<Icon name="close" />
</button>
</span>
))}
<button className="ghost small" onClick={open} title={t("terminal.new")} aria-label={t("terminal.new")}>
<Icon name="plus" />
</button>
</div>
</div>
{error && <div className="notice err">{error}</div>}
{active ? (
<TermView key={active.id} info={active} />
) : (
<div className="empty">
{list.length === 0 && id !== "new" && (
<button className="primary" onClick={open}>
<Icon name="terminal" /> {t("terminal.new")}
</button>
)}
</div>
)}
</>
);
}
function TermView({ info }: { info: TermInfo }) {
const t = useT();
const host = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const termRef = useRef<XTerm | null>(null);
const [ctrl, setCtrl] = useState(false);
const ctrlRef = useRef(false);
const [state, setState] = useState<"connecting" | "open" | "closed">("connecting");
useEffect(() => {
const term = new XTerm({
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--mono").trim() || "monospace",
fontSize: matchMedia("(max-width: 700px)").matches ? 12 : 14,
cursorBlink: true,
scrollback: 5000,
theme: themeColors(),
allowProposedApi: false,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(host.current!);
termRef.current = term;
const offTheme = onThemeChange(() => {
term.options.theme = themeColors();
});
let ws: WebSocket;
let stopped = false;
let retry: ReturnType<typeof setTimeout> | undefined;
const enc = new TextEncoder();
const sendResize = () => {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
const connect = () => {
const proto = location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${proto}://${location.host}/api/terminals/${info.id}/ws`);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
let first = true;
ws.onopen = () => {
setState("open");
fit.fit();
sendResize();
};
ws.onmessage = (m) => {
if (typeof m.data === "string") return; // hello / control
if (first) {
// Re-attached: the server replays recent output.
term.reset();
first = false;
}
term.write(new Uint8Array(m.data));
};
ws.onclose = () => {
setState("closed");
if (!stopped) retry = setTimeout(connect, 1500);
};
};
connect();
term.onData((d) => {
let data = d;
// On-screen Ctrl toggle (mobile): turn the next letter into a control code.
if (ctrlRef.current && d.length === 1) {
const c = d.toUpperCase().charCodeAt(0);
if (c >= 64 && c <= 95) data = String.fromCharCode(c - 64);
ctrlRef.current = false;
setCtrl(false);
}
if (ws.readyState === WebSocket.OPEN) ws.send(enc.encode(data));
});
const ro = new ResizeObserver(() => {
try {
fit.fit();
sendResize();
} catch {
/* not visible */
}
});
ro.observe(host.current!);
term.focus();
return () => {
stopped = true;
clearTimeout(retry);
ro.disconnect();
offTheme();
ws?.close();
term.dispose();
};
}, [info.id]);
const key = (seq: string) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(seq));
termRef.current?.focus();
};
return (
<div className="term-wrap">
<div className="term-host" ref={host} />
{state !== "open" && <div className="term-state">{t(state === "connecting" ? "terminal.connecting" : "terminal.reconnecting")}</div>}
<div className="term-keys" aria-label={t("terminal.keys")}>
<button onClick={() => key("\x1b")}>Esc</button>
<button onClick={() => key("\t")}>Tab</button>
<button
className={ctrl ? "active" : ""}
onClick={() => {
ctrlRef.current = !ctrlRef.current;
setCtrl(ctrlRef.current);
termRef.current?.focus();
}}
>
Ctrl
</button>
<button onClick={() => key("\x03")}>^C</button>
<button onClick={() => key("\x1b[A")} aria-label="Up">
<Icon name="arrow-up" />
</button>
<button onClick={() => key("\x1b[B")} aria-label="Down">
<Icon name="arrow-down" />
</button>
<button onClick={() => key("\x1b[D")} aria-label="Left">
<Icon name="arrow-left" />
</button>
<button onClick={() => key("\x1b[C")} aria-label="Right">
<Icon name="arrow-right" />
</button>
</div>
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { post } from "../../api";
import { useT } from "../../i18n";
import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
export function Account() {
const t = useT();
@@ -21,6 +22,7 @@ export function Account() {
};
return (
<>
<div className="panel">
<h3>
{t("account.title")}: {me?.username}
@@ -43,5 +45,7 @@ export function Account() {
</button>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div>
<GitAccount />
</>
);
}
+41 -3
View File
@@ -1,5 +1,6 @@
import { Icon } from "../Icon";
import { useState } from "react";
import { patch } from "../../api";
import { del, patch } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { AgentInfo } from "../../types";
@@ -22,6 +23,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
tools: (raw?.tools ?? []).join(", "),
prompt: raw?.prompt ?? "",
mode: raw?.mode ?? a.mode,
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "",
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
@@ -31,6 +35,13 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
{ path: [...base, "model"], value: form.model || null },
{ path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null },
];
if (a.mode === "primary") {
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
patches.push({
path: [...base, "refine"],
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null,
});
}
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode });
@@ -45,6 +56,16 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
}
};
const removeFile = async () => {
if (!(await confirmDialog({ message: t("agents.deleteFile", { path: a.path ?? a.name }), danger: true }))) return;
try {
await del(`/agents/${encodeURIComponent(a.name)}`);
onSaved(true, t("settings.saved"));
} catch (e: any) {
onSaved(false, e.message);
}
};
const reset = async () => {
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
try {
@@ -63,6 +84,11 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<span className="chip">{t(`agents.source.${a.source}` as any)}</span>
{a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
<span className="spacer" />
{!builtin && (a.source === "config" || a.source === "file") && (
<button className="small ghost danger" onClick={a.source === "file" ? removeFile : reset} title={t("common.delete")} aria-label={t("common.delete")}>
<Icon name="trash" />
</button>
)}
<button className="small" onClick={() => setOpen(!open)}>
{open ? t("common.collapse") : t("common.edit")}
</button>
@@ -72,6 +98,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span>
<span className="chip">{a.model || t("agents.sessionModel")}</span>
{a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null}
{a.refine?.rounds ? <span className="chip">{t("agents.refineShort", { n: a.refine.rounds })}</span> : null}
</div>
{open && (
<>
@@ -85,6 +112,17 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} />
</>
)}
{a.mode === "primary" && (
<>
<label>{t("agents.refineRounds")}</label>
<input type="number" min={0} max={10} value={form.refine_rounds} placeholder={t("agents.refineOff")} onChange={(e) => setForm({ ...form, refine_rounds: e.target.value })} />
<label>{t("agents.refineModel")}</label>
<ModelSelect value={form.refine_model} onChange={(v) => setForm({ ...form, refine_model: v })} emptyLabel={t("agents.sessionModel")} />
<label>{t("agents.refineCriteria")}</label>
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
<div className="hint">{t("agents.refineCriteriaHint")}</div>
</>
)}
<label>{t("agents.description")}</label>
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
@@ -107,9 +145,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
{t("common.save")}
</button>
<span className="spacer" />
{a.source === "config" && (
{a.source === "config" && builtin && (
<button className="danger" onClick={reset}>
{builtin ? t("agents.reset") : t("common.delete")}
{t("agents.reset")}
</button>
)}
</div>
@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { useT } from "../../i18n";
import { toast } from "../../dialogs";
import { Icon } from "../Icon";
interface Cred {
host: string;
username: string;
token: string;
protocol?: string;
}
interface GitSettings {
name: string;
email: string;
credentials: Cred[];
}
/** Git identity and credentials for agents, terminal and git client (per user). */
export function GitAccount() {
const t = useT();
const [s, setS] = useState<GitSettings>();
const [saving, setSaving] = useState(false);
useEffect(() => {
get<GitSettings>("/account/git")
.then((v) => setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] }))
.catch((e) => toast(e.message));
}, []);
if (!s) return null;
const setCred = (i: number, patch: Partial<Cred>) => setS({ ...s, credentials: s.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
const save = async () => {
setSaving(true);
try {
const v = await put<GitSettings>("/account/git", s);
setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] });
toast(t("gitcred.saved"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setSaving(false);
}
};
return (
<div className="panel">
<h3>
<Icon name="fork" /> {t("gitcred.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("gitcred.hint")}
</div>
<div className="form">
<label>{t("gitcred.name")}</label>
<input value={s.name} onChange={(e) => setS({ ...s, name: e.target.value })} placeholder="Jane Doe" autoComplete="name" />
<label>{t("gitcred.email")}</label>
<input value={s.email} onChange={(e) => setS({ ...s, email: e.target.value })} placeholder="jane@example.com" autoComplete="email" />
</div>
<h4 style={{ margin: "16px 0 8px" }}>{t("gitcred.credentials")}</h4>
<div className="gitcred-list">
{s.credentials.length === 0 && <div className="empty" style={{ padding: 8 }}>{t("gitcred.none")}</div>}
{s.credentials.map((c, i) => (
<div key={i} className="gitcred-row">
<input value={c.host} placeholder="git.example.com" onChange={(e) => setCred(i, { host: e.target.value })} aria-label={t("gitcred.host")} />
<input value={c.username} placeholder={t("gitcred.user")} onChange={(e) => setCred(i, { username: e.target.value })} aria-label={t("gitcred.user")} autoComplete="off" />
<input
type="password"
value={c.token}
placeholder={t("gitcred.token")}
onFocus={(e) => c.token.startsWith("•") && e.target.select()}
onChange={(e) => setCred(i, { token: e.target.value })}
aria-label={t("gitcred.token")}
autoComplete="new-password"
/>
<button className="ghost small" onClick={() => setS({ ...s, credentials: s.credentials.filter((_, j) => j !== i) })} aria-label={t("common.remove")}>
<Icon name="trash" />
</button>
</div>
))}
</div>
<div className="row" style={{ marginTop: 10, gap: 8 }}>
<button className="small" onClick={() => setS({ ...s, credentials: [...s.credentials, { host: "", username: "", token: "" }] })}>
<Icon name="plus" /> {t("gitcred.add")}
</button>
<span className="grow" />
<button className="primary" onClick={save} disabled={saving}>
{t("common.save")}
</button>
</div>
</div>
);
}