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:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user