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:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { PermissionRequest, QuestionRequest } from "../types";
|
||||
import { Diff } from "./Diff";
|
||||
|
||||
function SubagentHint({ sessionId, currentId }: { sessionId: string; currentId: string }) {
|
||||
const t = useT();
|
||||
const title = useStore((s) => s.sessionById[sessionId]?.title);
|
||||
if (sessionId === currentId) return null;
|
||||
return (
|
||||
<div className="from">
|
||||
{t("interaction.fromSubagent")}{" "}
|
||||
<a href={`#/s/${sessionId}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${sessionId}`))}>
|
||||
{title || sessionId.slice(-6)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PermissionCard({ req, currentId }: { req: PermissionRequest; currentId: string }) {
|
||||
const t = useT();
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reply = async (r: "once" | "always" | "reject") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await post(`/permissions/${req.id}`, { reply: r, message: r === "reject" ? feedback : undefined });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const command: string | undefined = req.tool === "bash" ? req.input?.command : undefined;
|
||||
const alwaysLabel =
|
||||
req.tool === "bash"
|
||||
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
|
||||
: t("permission.alwaysTool", { tool: req.tool });
|
||||
|
||||
return (
|
||||
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
||||
<h3>
|
||||
🔐 {t("permission.title")} <span className="chip mono">{req.tool}</span>
|
||||
</h3>
|
||||
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||
<div style={{ margin: "6px 0" }}>{req.title}</div>
|
||||
{command && <pre className="mono" style={{ whiteSpace: "pre-wrap", margin: "6px 0" }}>$ {command}</pre>}
|
||||
{req.diff ? (
|
||||
<Diff diff={req.diff} />
|
||||
) : (
|
||||
!command && (
|
||||
<details>
|
||||
<summary style={{ cursor: "pointer", color: "var(--muted)" }}>{t("tool.input")}</summary>
|
||||
<pre className="mono" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(req.input, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)
|
||||
)}
|
||||
<input
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
placeholder={t("permission.feedback")}
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && reply("reject")}
|
||||
/>
|
||||
<div className="actions">
|
||||
<button className="primary" disabled={busy} onClick={() => reply("once")} autoFocus>
|
||||
{t("permission.allowOnce")}
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => reply("always")}>
|
||||
{alwaysLabel}
|
||||
</button>
|
||||
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
||||
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OTHER = "__other__";
|
||||
|
||||
export function QuestionCard({ req, currentId }: { req: QuestionRequest; currentId: string }) {
|
||||
const t = useT();
|
||||
const [selected, setSelected] = useState<Record<number, string[]>>({});
|
||||
const [other, setOther] = useState<Record<number, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggle = (qi: number, label: string, multi: boolean) => {
|
||||
setSelected((s) => {
|
||||
const cur = s[qi] ?? [];
|
||||
if (!multi) return { ...s, [qi]: [label] };
|
||||
return { ...s, [qi]: cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label] };
|
||||
});
|
||||
};
|
||||
|
||||
const answerFor = (qi: number): string | string[] | undefined => {
|
||||
const q = req.questions[qi];
|
||||
const sel = (selected[qi] ?? []).map((l) => (l === OTHER ? other[qi]?.trim() ?? "" : l)).filter(Boolean);
|
||||
if (q.options.length === 0) return other[qi]?.trim() || undefined;
|
||||
if (sel.length === 0) return undefined;
|
||||
return q.multi_select ? sel : sel[0];
|
||||
};
|
||||
|
||||
const complete = req.questions.every((_, i) => answerFor(i) !== undefined);
|
||||
|
||||
const submit = async (dismiss = false) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const answers = dismiss ? null : Object.fromEntries(req.questions.map((q, i) => [q.question, answerFor(i)]));
|
||||
await post(`/questions/${req.id}`, { answers });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const source = req.source.startsWith("mcp:") ? t("question.fromMcp", { server: req.source.slice(4) }) : t("question.fromAgent");
|
||||
|
||||
return (
|
||||
<div className="card" role="dialog" aria-label={t("question.title")}>
|
||||
<h3>❓ {t("question.title")}</h3>
|
||||
<div className="from">{source}</div>
|
||||
<SubagentHint sessionId={req.session_id} currentId={currentId} />
|
||||
{req.questions.map((q, qi) => (
|
||||
<div key={qi} style={{ marginTop: 10 }}>
|
||||
{q.header && <span className="q-header">{q.header}</span>}
|
||||
<div style={{ whiteSpace: "pre-wrap", color: "var(--fg0)" }}>{q.question}</div>
|
||||
{q.options.length > 0 ? (
|
||||
<div className="q-options" role={q.multi_select ? "group" : "radiogroup"}>
|
||||
{[...q.options, { label: OTHER, description: "" }].map((o) => {
|
||||
const isSel = (selected[qi] ?? []).includes(o.label);
|
||||
return (
|
||||
<div
|
||||
key={o.label}
|
||||
className={`q-option ${isSel ? "selected" : ""}`}
|
||||
role={q.multi_select ? "checkbox" : "radio"}
|
||||
aria-checked={isSel}
|
||||
tabIndex={0}
|
||||
onClick={() => toggle(qi, o.label, !!q.multi_select)}
|
||||
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && toggle(qi, o.label, !!q.multi_select)}
|
||||
>
|
||||
<span>{q.multi_select ? (isSel ? "☑" : "☐") : isSel ? "◉" : "○"}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
{o.label === OTHER ? (
|
||||
<input
|
||||
style={{ width: "100%" }}
|
||||
placeholder={t("question.other")}
|
||||
value={other[qi] ?? ""}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => !isSel && toggle(qi, OTHER, !!q.multi_select)}
|
||||
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div>{o.label}</div>
|
||||
{o.description && <div className="desc">{o.description}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
style={{ width: "100%", marginTop: 6 }}
|
||||
placeholder={t("question.answer")}
|
||||
value={other[qi] ?? ""}
|
||||
onChange={(e) => setOther((s) => ({ ...s, [qi]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="actions">
|
||||
<button className="primary" disabled={busy || !complete} onClick={() => submit()}>
|
||||
{t("question.submit")}
|
||||
</button>
|
||||
<button disabled={busy} onClick={() => submit(true)}>
|
||||
{t("question.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user