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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user