"use client";
// Shared anatomy-card primitives — extracted so the agent command center
// (ClawCommandCenter) reuses the exact cards/styling. Behavior copied verbatim
// from Dashboard.tsx's in-file helpers.
import { useState, type CSSProperties } from "react";
import { Check, Pencil, ScrollText, X } from "lucide-react";
export const mono = "'JetBrains Mono', ui-monospace, monospace";
/** A brain section: rendered read-only with a pencil; click to edit as raw text
* and PATCH it back to `/api/claws/{id}/brain`. `children` is the display;
* `value` is the editable source. `onSaved` should re-fetch the brain. */
export function EditableSection({ clawId, field, value, onSaved, children, placeholder }: {
clawId: string;
field: "system_prompt" | "agent_md" | "persona" | "skills_md";
value: string;
onSaved?: () => void;
children: React.ReactNode;
placeholder?: string;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
try {
await fetch(`/api/claws/${clawId}/brain`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [field]: draft }) });
setEditing(false);
onSaved?.();
} catch {
/* keep the editor open on failure */
} finally {
setSaving(false);
}
};
if (editing) {
return (
);
}
return (
{ setDraft(value); setEditing(true); }} aria-label="Edit" title="Edit" style={{ position: "absolute", top: -2, right: -2, zIndex: 2, display: "inline-flex", alignItems: "center", justifyContent: "center", width: 24, height: 24, borderRadius: 6, border: "1px solid rgba(255,255,255,.1)", background: "rgba(8,8,10,.55)", color: "#8a8a92", cursor: "pointer" }}>
{children}
);
}
// `GET /api/claws/{id}/brain` — the claw's .brain (cm-brain / ClawhDF5).
export type RawBrain = {
exists: boolean;
system_prompt: string | null;
agent_md: string | null;
personality: string | null;
skills_md: string | null;
skills: { name: string; body: string }[];
tools: { name: string; state: string }[];
memory: string[];
stats: { skills: number; tools: number; memories: number };
} | null;
// Inline markdown: **bold** and `code` → styled spans (everything else verbatim).
function renderInline(text: string, kp: string): React.ReactNode[] {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter(Boolean).map((p, i) => {
if (p.startsWith("**") && p.endsWith("**")) return {p.slice(2, -2)} ;
if (p.startsWith("`") && p.endsWith("`")) return {p.slice(1, -1)};
return {p} ;
});
}
// A tiny, dependency-free Markdown renderer: headings, bullet lists, bold/code,
// paragraphs. Turns the raw prompt into readable prose instead of literal `##`.
export function MarkdownText({ text, baseSize = 14 }: { text: string; baseSize?: number }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: React.ReactNode[] = [];
let para: string[] = [];
let list: string[] = [];
const flushPara = () => {
if (para.length) { const k = `p${blocks.length}`; blocks.push({renderInline(para.join(" "), k)}
); para = []; }
};
const flushList = () => {
if (list.length) { const k = `u${blocks.length}`; blocks.push({list.map((li, i) => {renderInline(li, `${k}-${i}`)} )} ); list = []; }
};
for (const raw of lines) {
const line = raw.replace(/\s+$/, "");
const h = line.match(/^(#{1,6})\s+(.*)$/);
const li = line.match(/^\s*[-*]\s+(.*)$/);
if (h) { flushPara(); flushList(); const lvl = h[1].length; const size = lvl <= 1 ? baseSize + 5 : lvl === 2 ? baseSize + 2 : baseSize + 1; const k = `h${blocks.length}`; blocks.push({renderInline(h[2], k)}
); }
else if (li) { flushPara(); list.push(li[1]); }
else if (line.trim() === "") { flushPara(); flushList(); }
else { flushList(); para.push(line.replace(/^\s*>\s?/, "")); }
}
flushPara(); flushList();
return {blocks}
;
}
// A same-colored triangle toggle: points down when open, left when closed.
export function CollapseTick({ open, color, onClick }: { open: boolean; color: string; onClick: () => void }) {
return (
▼
);
}
export function SystemPromptCard({ prompt, clawId, onSaved }: { prompt: string; clawId?: string; onSaved?: () => void }) {
const [open, setOpen] = useState(true);
const display = prompt?.trim() ? : No system prompt set.
;
return (
SYSTEM PROMPT
setOpen((o) => !o)} />
{open ? (
clawId ? (
{display}
) : display
) : null}
);
}
// Personality is often stored as JSON (traits/tone/…). Render it as labeled
// sections instead of dumping raw JSON; fall back to tags / text otherwise.
export function PersonalityBody({ raw, fallback }: { raw: string | null | undefined; fallback: string[] }) {
const tags = (arr: string[]) => {arr.map((p, i) => {p} )}
;
const trimmed = (raw ?? "").trim();
let parsed: unknown;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; }
}
if (Array.isArray(parsed)) return tags(parsed.map((x) => String(x)));
if (parsed && typeof parsed === "object") {
return (
{Object.entries(parsed as Record
).map(([k, v]) => (
{k.replace(/_/g, " ")}
{Array.isArray(v)
? tags(v.map((x) => String(x)))
: v && typeof v === "object"
?
{Object.entries(v as Record
).map(([kk, vv]) => {kk}: {String(vv)}
)}
:
{String(v)}
}
))}
);
}
if (fallback.length) return tags(fallback);
if (trimmed) return {trimmed}
;
return — ;
}
export function AnatomyCard({ tint, label, count, icon, collapsible, children }: { tint: string; label: string; count?: string; icon?: React.ReactNode; collapsible?: boolean; children: React.ReactNode }) {
const [open, setOpen] = useState(true);
const collapsed = !!collapsible && !open;
return (
{icon}
{label}
{count || collapsible ? : null}
{count ? {count} : null}
{collapsible ? setOpen((o) => !o)} /> : null}
{!collapsed ?
{children}
: null}
);
}
// A pill-shaped tag, tinted to its section's accent. `dim` is the muted "+N" variant.
export const tag = (color = "#9a9aa2", dim = false): CSSProperties => ({ fontFamily: mono, fontSize: 12, color: dim ? "#8a8a92" : color, padding: "4px 10px", borderRadius: 7, background: dim ? "rgba(255,255,255,.04)" : `${color}1f`, border: `1px solid ${dim ? "rgba(255,255,255,.08)" : `${color}45`}` });