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

+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>
);
}