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

+336
View File
@@ -0,0 +1,336 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api";
import { useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT } from "../i18n";
import { navigate } from "../router";
import { dismissError, loadSession, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
import { Composer } from "./Composer";
import { PermissionCard, QuestionCard } from "./Interaction";
import { Markdown } from "./Markdown";
import { TodoList, ToolCard } from "./ToolCard";
type ToolResult = Extract<Part, { type: "tool_result" }>;
const EMPTY: Message[] = [];
interface SessionStats {
total: Aggregate;
by_model: (Aggregate & { model: string })[];
currency: string;
}
function Reasoning({ text, live }: { text: string; live: boolean }) {
const t = useT();
if (!text.trim()) return null;
return (
<details className="reasoning" open={live}>
<summary>{t("chat.reasoning")}</summary>
<div className="body">{text}</div>
</details>
);
}
function AssistantParts({ parts, results, running, live }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean }) {
return (
<>
{parts.map((p, i) => {
switch (p.type) {
case "text":
return <Markdown key={i} text={p.text} />;
case "reasoning":
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
case "tool_call":
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} />;
default:
return null;
}
})}
</>
);
}
function StatsPanel({ stats }: { stats: SessionStats }) {
const t = useT();
const a = stats.total;
return (
<div className="panel" style={{ margin: "8px 0" }}>
<table className="grid">
<thead>
<tr>
<th>{t("stats.model")}</th>
<th className="num">{t("stats.requests")}</th>
<th className="num">{t("stats.input")}</th>
<th className="num">{t("stats.cacheRead")}</th>
<th className="num">{t("stats.output")}</th>
<th className="num">{t("stats.ttft")}</th>
<th className="num">{t("stats.outputSpeed")}</th>
<th className="num">{t("stats.cost")}</th>
</tr>
</thead>
<tbody>
{[...stats.by_model, { ...a, model: t("stats.total") }].map((r) => (
<tr key={r.model}>
<td>{r.model}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)} tok/s</td>
<td className="num">{fmtCost(r.cost, stats.currency)}</td>
</tr>
))}
</tbody>
</table>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
{t("stats.prefillSpeed")}: {fmtNumber(a.prefill_tps)} tok/s · {t("stats.cacheHit")}: {fmtPercent(a.cache_hit_rate)}
</div>
</div>
);
}
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
const draft = useStore((s) => s.drafts[sessionId]);
const busy = useStore((s) => !!s.busy[sessionId]);
const errors = useStore((s) => s.errors[sessionId]);
const allPermissions = useStore((s) => s.permissions);
const allQuestions = useStore((s) => s.questions);
const models = useModels();
const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState("");
const [stats, setStats] = useState<SessionStats>();
const [showStats, setShowStats] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const stick = useRef(true);
useEffect(() => {
loadSession(sessionId).catch((e) => setLoadError(e.message));
}, [sessionId]);
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
const questions = allQuestions.filter((q) => q.session_id === sessionId || q.root_session_id === sessionId);
// Session cost/tokens; refreshed when messages arrive.
useEffect(() => {
get<SessionStats>(`/sessions/${sessionId}/stats`).then(setStats).catch(() => undefined);
}, [sessionId, messages.length]);
const results = useMemo(() => {
const m = new Map<string, ToolResult>();
for (const msg of messages) for (const p of msg.parts) if (p.type === "tool_result") m.set(p.call_id, p);
return m;
}, [messages]);
// Keep scrolled to the bottom while the user hasn't scrolled up.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el && stick.current) el.scrollTop = el.scrollHeight;
});
if (loadError) return <div className="empty">{loadError}</div>;
if (!session) return <div className="empty">{t("common.loading")}</div>;
const update = async (p: Partial<Session>) => {
try {
await patch(`/sessions/${sessionId}`, p);
} catch (e: any) {
alert(e.message);
}
};
const send = async (raw: string, images: ImageData[]) => {
const txt = raw.trim();
if (txt === "/new") {
setText("");
return navigate("/new");
}
try {
await post(`/sessions/${sessionId}/prompt`, { text: txt, images });
setText("");
stick.current = true;
} catch (e: any) {
alert(e.message);
}
};
const rewind = async (m: Message) => {
if (!confirm(t("chat.rewindConfirm"))) return;
const txt = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("");
await post(`/sessions/${sessionId}/rewind`, { message_id: m.id });
await loadSession(sessionId);
setText(txt);
};
const remove = async () => {
if (!confirm(t("chat.deleteConfirm"))) return;
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
};
// Context usage of the last request relative to the model's window.
const [prov, ...rest] = session.model.split("/");
const modelInfo = models.find((p) => p.provider === prov)?.models.find((m) => m.id === rest.join("/"));
const u = session.last_usage;
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
const ctxShare = modelInfo?.context && ctxTokens ? ctxTokens / modelInfo.context : undefined;
const todos = Array.isArray(session.todos) ? session.todos : [];
const openTodos = todos.filter((x) => x.status !== "completed");
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
</button>
<div className="grow">
{editingTitle ? (
<input
autoFocus
defaultValue={session.title}
onBlur={(e) => (setEditingTitle(false), update({ title: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
style={{ width: "100%" }}
/>
) : (
<h1 onDoubleClick={() => setEditingTitle(true)} title={t("chat.renameHint")}>
{session.parent_id && (
<a href={`#/s/${session.parent_id}`} style={{ marginRight: 8 }}>
↩
</a>
)}
{session.title || t("session.untitled")}
</h1>
)}
<div className="path">{session.cwd}</div>
</div>
{ctxShare !== undefined && (
<span className={`chip ${ctxShare > 0.7 ? "warn" : ""}`} title={t("chat.contextHint", { tokens: fmtNumber(ctxTokens) })}>
{t("chat.context")} {fmtPercent(ctxShare)}
</span>
)}
{stats && (
<button className="chip" style={{ border: "none" }} onClick={() => setShowStats((x) => !x)} title={t("chat.statsHint")}>
{fmtCompact(stats.total.input_tokens + stats.total.cache_read_tokens + stats.total.cache_write_tokens)}↑{" "}
{fmtCompact(stats.total.output_tokens)}↓ · {fmtCost(stats.total.cost, stats.currency)}
</button>
)}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
⇊
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
🗑
</button>
</div>
<div
className="chat-scroll"
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}}
>
<div className="chat">
{showStats && stats && <StatsPanel stats={stats} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m) => {
if (m.role === "assistant") {
return (
<div key={m.id} className="msg">
<AssistantParts parts={m.parts} results={results} running={busy} live={false} />
</div>
);
}
const summary = m.parts.find((p) => p.type === "summary");
if (summary && summary.type === "summary") {
return (
<details key={m.id} className="summary-divider">
<summary>{t("chat.compacted")}</summary>
<Markdown text={summary.text} />
</details>
);
}
const texts = m.parts.filter((p) => p.type === "text") as { text: string }[];
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
if (texts.length === 0 && imgs.length === 0) return null;
return (
<div key={m.id} className="msg">
<div className="msg-user">
{!busy && (
<div className="msg-actions">
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
✎
</button>
</div>
)}
{texts.map((p) => p.text).join("\n\n")}
{imgs.length > 0 && (
<div>
{imgs.map((p, i) => (
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
))}
</div>
)}
</div>
</div>
);
})}
{draft && (
<div className="msg">
<AssistantParts parts={draft.parts} results={results} running={busy} live />
</div>
)}
{busy && !draft && <div className="msg-meta">{t("chat.thinking")}</div>}
{openTodos.length > 0 && (
<div className="todos">
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
<TodoList todos={todos} />
</div>
)}
{permissions.map((p) => (
<PermissionCard key={p.id} req={p} currentId={sessionId} />
))}
{questions.map((q) => (
<QuestionCard key={q.id} req={q} currentId={sessionId} />
))}
{errors?.map((e) => (
<div key={e.id} className="error-box" role="alert">
<span>{e.message}</span>
<button className="small ghost" onClick={() => dismissError(sessionId, e.id)} aria-label={t("common.close")}>
×
</button>
</div>
))}
{session.agent === "plan" && !busy && lastIsAssistant && (
<div className="row" style={{ justifyContent: "center", margin: "12px 0" }}>
<button
className="primary"
onClick={async () => {
await update({ agent: "build" });
await send(t("plan.implementPrompt"), []);
}}
>
{t("plan.implement")}
</button>
</div>
)}
</div>
</div>
<Composer
session={session}
busy={busy}
text={text}
setText={setText}
onSend={send}
onAbort={() => post(`/sessions/${sessionId}/abort`)}
onUpdate={update}
/>
</>
);
}