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

+207
View File
@@ -0,0 +1,207 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
interface Suggestion {
name: string;
description: string;
}
const BUILTIN: { name: string; key: Key }[] = [
{ name: "compact", key: "command.compact" },
{ name: "new", key: "command.new" },
];
function readImage(file: File): Promise<ImageData> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
const url = String(r.result);
resolve({ media_type: file.type || "image/png", data: url.slice(url.indexOf(",") + 1) });
};
r.onerror = reject;
r.readAsDataURL(file);
});
}
export function Composer({
session,
busy,
text,
setText,
onSend,
onAbort,
onUpdate,
}: {
session: Session;
busy: boolean;
text: string;
setText: (s: string) => void;
onSend: (text: string, images: ImageData[]) => void;
onAbort: () => void;
onUpdate: (patch: Partial<Session>) => void;
}) {
const t = useT();
const ref = useRef<HTMLTextAreaElement>(null);
const [images, setImages] = useState<ImageData[]>([]);
const [active, setActive] = useState(0);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
// Auto-grow.
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, window.innerHeight * 0.4)}px`;
}, [text]);
const suggestions = useMemo(() => {
const m = text.match(/^\/(\S*)$/);
if (!m) return [];
const all: Suggestion[] = [
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
...(cmdData?.commands ?? []),
];
return all.filter((c) => c.name.startsWith(m[1])).slice(0, 12);
}, [text, cmdData, t]);
const send = () => {
if (!text.trim() && images.length === 0) return;
onSend(text, images);
setImages([]);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a + (e.key === "ArrowDown" ? 1 : suggestions.length - 1)) % suggestions.length);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
setText(`/${suggestions[Math.min(active, suggestions.length - 1)].name} `);
return;
}
}
// Enter sends on desktop; on touch devices Enter inserts a newline.
const touch = matchMedia("(pointer: coarse)").matches;
if (e.key === "Enter" && !e.shiftKey && !touch && !e.nativeEvent.isComposing) {
e.preventDefault();
send();
}
if (e.key === "Escape" && busy) onAbort();
};
const addFiles = async (files: FileList | File[]) => {
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
setImages((cur) => [...cur, ...imgs]);
};
return (
<div className="composer-wrap">
<div
className="composer"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
void addFiles(e.dataTransfer.files);
}}
>
{suggestions.length > 0 && (
<div className="suggest" role="listbox">
{suggestions.map((s, i) => (
<div
key={s.name}
role="option"
aria-selected={i === active}
className={i === active ? "active" : ""}
onMouseDown={(e) => {
e.preventDefault();
setText(`/${s.name} `);
ref.current?.focus();
}}
>
<span className="name">/{s.name}</span>
<span className="desc">{s.description}</span>
</div>
))}
</div>
)}
{images.length > 0 && (
<div className="attachments">
{images.map((img, i) => (
<div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
×
</button>
</div>
))}
</div>
)}
<textarea
ref={ref}
rows={1}
value={text}
placeholder={busy ? t("composer.placeholderBusy") : t("composer.placeholder")}
onChange={(e) => {
setText(e.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
onPaste={(e) => {
const files = [...e.clipboardData.files];
if (files.some((f) => f.type.startsWith("image/"))) {
e.preventDefault();
void addFiles(files);
}
}}
aria-label={t("composer.placeholder")}
/>
<div className="composer-bar">
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
{agents.map((a) => (
<button
key={a.name}
role="tab"
aria-selected={session.agent === a.name}
className={session.agent === a.name ? "active" : ""}
title={a.description}
onClick={() => onUpdate({ agent: a.name })}
>
{a.name}
</button>
))}
</div>
<select
value={session.permission_mode}
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
title={t("composer.permissionMode")}
>
<option value="default">{t("permissionMode.default")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
📎
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
</label>
<span className="grow" />
{busy && (
<button className="danger" onClick={onAbort} title="Esc">
■ {t("composer.stop")}
</button>
)}
<button className="primary" onClick={send} disabled={!text.trim() && images.length === 0}>
{busy ? t("composer.queue") : t("composer.send")}
</button>
</div>
</div>
</div>
);
}