Files
helder/design_handoff_helder_workbench/design/src/terminals.jsx
2026-06-15 09:43:01 +02:00

232 lines
11 KiB
JavaScript

/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let _lid = 0;
const lid = () => ++_lid;
function Terminal({ kind, seed }) {
const [lines, setLines] = useState(seed.lines);
const [mode, setMode] = useState(seed.mode);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const bodyRef = useRef(null);
const inputRef = useRef(null);
const hist = useRef([]);
const histIx = useRef(-1);
useEffect(() => {
const el = bodyRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines, busy]);
useEffect(() => {
if (kind !== "agent") return;
const h = (e) => {
if (mode !== "agent") { pushMany(bootAgent()); setMode("agent"); }
// bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
setInput((prev) => {
const base = prev.replace(/\n+$/, "");
return (base ? base + "\n" : "") + e.detail + "\n";
});
requestAnimationFrame(() => {
const el = inputRef.current;
if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
});
};
window.addEventListener("agentPaste", h);
return () => window.removeEventListener("agentPaste", h);
}, [kind, mode]);
const push = (line) => setLines((p) => [...p, { id: lid(), ...line }]);
const pushMany = (arr) => setLines((p) => [...p, ...arr.map((l) => ({ id: lid(), ...l }))]);
async function runAgent(prompt) {
setBusy(true);
push({ kind: "agent-think", html: `<span class="ag">●</span> <span class="dim">thinking…</span>` });
await wait(520);
setLines((p) => p.slice(0, -1)); // drop the thinking line
push({ kind: "t", cls: "", html: `<span class="ag">●</span> I'll take a look at the relevant files first.` });
await wait(380);
push({ kind: "t", cls: "dim", html: ` <span class="tool">read</span> <span class="fp">src/Http/Controller/UserController.php</span>` });
await wait(300);
push({ kind: "t", cls: "dim", html: ` <span class="tool">grep</span> <span class="dim">"balanceFor" → 2 matches</span>` });
await wait(420);
push({ kind: "t", cls: "", html: `<span class="ag">●</span> Adding the field and a fillable allow-list. Editing now.` });
await wait(360);
push({
kind: "card", ch: `edit · src/Http/Controller/UserController.php`,
rows: [
{ cls: "del", t: "- 'plan' => $user->plan," },
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
{ cls: "add", t: "+ 'currency' => $user->currency," },
],
});
await wait(450);
push({ kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done.</span> Updated <span class="fp">UserController.php</span>. Run tests with <span class="fp">composer test</span>.` });
setBusy(false);
requestAnimationFrame(() => inputRef.current && inputRef.current.focus());
}
function shell(cmd) {
const [c, ...rest] = cmd.split(/\s+/);
const arg = rest.join(" ");
switch (c) {
case "": return;
case "claude":
pushMany(bootAgent());
setMode("agent");
return;
case "clear": setLines([]); return;
case "help":
pushMany([
{ kind: "t", cls: "dim", html: "commands: <span style='color:var(--fg-1)'>claude</span> ls pwd cat &lt;file&gt; git status git diff echo clear" },
]); return;
case "pwd": push({ kind: "t", html: "/Users/dev/console" }); return;
case "ls":
push({ kind: "t", html: "<span class='fp'>config</span> <span class='fp'>public</span> <span class='fp'>scripts</span> <span class='fp'>src</span> <span class='fp'>templates</span> composer.json package.json README.md" });
return;
case "echo": push({ kind: "t", html: HL.escapeHtml(arg) }); return;
case "git":
if (rest[0] === "status") {
pushMany([
{ kind: "t", cls: "dim", html: `On branch <span style='color:var(--fg-1)'>${PROJECT.branch}</span>` },
{ kind: "t", cls: "dim", html: "Changes to be committed:" },
...PROJECT.changes.map((ch) => ({
kind: "t",
html: ` <span style="color:var(--${ch.status === 'A' ? 'add' : ch.status === 'D' ? 'del' : 'mod'})">${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'}</span> <span class="fp">${ch.path}</span>`,
})),
]);
} else if (rest[0] === "diff") {
pushMany([
{ kind: "t", cls: "dim", html: "diff --git a/public/assets/app.js b/public/assets/app.js" },
{ kind: "t", cls: "err", html: "- const res = await fetch('/api/session');" },
{ kind: "t", cls: "ok", html: "+ const res = await fetch('/api/session', { credentials: 'include' });" },
]);
} else push({ kind: "t", cls: "dim", html: `git: '${rest[0] || ""}' is not handled in this demo` });
return;
case "cat": {
const f = PROJECT.files[arg] || Object.entries(PROJECT.files).find(([p]) => p.endsWith(arg))?.[1];
if (!f) { push({ kind: "t", cls: "err", html: `cat: ${HL.escapeHtml(arg)}: No such file` }); return; }
pushMany(f.replace(/\n$/, "").split("\n").slice(0, 24).map((l) => ({ kind: "t", cls: "dim", html: HL.escapeHtml(l) || "&nbsp;" })));
return;
}
default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
}
}
function submit() {
const cmd = input.trim();
if (busy) return;
if (cmd) { hist.current.unshift(cmd); }
histIx.current = -1;
if (mode === "agent") {
if (cmd === "/exit" || cmd === "exit") {
push({ kind: "t", cls: "dim", html: "<span class='ag'>●</span> Session ended." });
setMode("shell"); setInput(""); return;
}
if (cmd === "/clear" || cmd === "clear") { setLines([]); setInput(""); return; }
push({ kind: "t", html: `<span class="ip ag">&gt;</span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br>&nbsp;&nbsp;") || "&nbsp;"}` });
setInput("");
if (cmd) runAgent(cmd);
return;
}
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || "&nbsp;"}` });
setInput("");
shell(cmd);
}
function onKey(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.includes("\n")) {
e.preventDefault();
if (e.key === "ArrowUp") { if (hist.current.length) { histIx.current = Math.min(histIx.current + 1, hist.current.length - 1); setInput(hist.current[histIx.current]); } }
else { if (histIx.current > 0) { histIx.current--; setInput(hist.current[histIx.current]); } else { histIx.current = -1; setInput(""); } }
}
}
const live = mode === "agent";
return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => inputRef.current && inputRef.current.focus()}>
<div className="term-head">
<span className={"dot" + (live ? " live" : "")}></span>
<span className="lbl">{live ? "claude" : kind === "agent" ? "claude" : "zsh"}</span>
<span className="tag">{live ? "agent session" : "— bash · ~/console"}</span>
</div>
<div className="term-body" ref={bodyRef}>
{lines.map((l) => {
if (l.kind === "card") {
return (
<div key={l.id} className="term-card">
<div className="ch">{Icon.diff({ width: 11, height: 11 })}<span>{l.ch}</span></div>
{l.rows.map((r, i) => (<div key={i} className={r.cls}>{r.t}</div>))}
</div>
);
}
if (l.kind === "welcome") {
return (
<div key={l.id} className="term-card" style={{ borderColor: "var(--accent-line)" }}>
<div style={{ color: "var(--accent)", fontWeight: 600 }}> agent session</div>
<div className="dim" style={{ color: "var(--fg-2)", marginTop: 3 }}>{l.text}</div>
</div>
);
}
return <div key={l.id} className={"tline " + (l.cls || "")} dangerouslySetInnerHTML={{ __html: l.html }} />;
})}
{busy && <div className="tline dim"><span className="ag" style={{ color: "#c98bdb" }}></span> working<span className="cursor-blink" /></div>}
{!busy && (
<div className="term-input">
<span className={"ip" + (live ? " ag" : "")}>{live ? ">" : <span className="pfx">console <span style={{ color: "var(--fg-3)" }}>%</span></span>}</span>
<textarea ref={inputRef} className="term-ta" value={input} spellCheck={false} autoComplete="off"
rows={Math.min(8, Math.max(1, input.split("\n").length))}
placeholder={live ? "Ask the agent to change something…" : kind === "agent" ? "type 'claude' to start a session" : ""}
onChange={(e) => setInput(e.target.value)} onKeyDown={onKey} />
</div>
)}
</div>
</div>
);
}
function bootAgent() {
return [
{ kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
{ kind: "t", cls: "dim", html: " I can read, search and edit files in this project. Describe a change to get started." },
];
}
function agentSeed() {
return {
mode: "agent",
lines: [
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> claude` },
{ id: lid(), kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
{ id: lid(), kind: "t", html: `<span class="ip ag">&gt;</span> add currency + name to the user payload and gate the fillable fields` },
{ id: lid(), kind: "t", html: `<span class="ag">●</span> Read <span class="fp">UserController.php</span>, edited the response array and <span class="fp">onlyFillable()</span>.` },
{
id: lid(), kind: "card", ch: "edit · src/Http/Controller/UserController.php",
rows: [
{ cls: "del", t: "- 'plan' => $user->plan," },
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
{ cls: "add", t: "+ 'name' => $user->name," },
],
},
{ id: lid(), kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done</span> — updated <span class="fp">UserController.php</span>. Anything else?` },
],
};
}
function shellSeed() {
return {
mode: "shell",
lines: [
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> git status -s` },
...PROJECT.changes.map((c) => ({
id: lid(), kind: "t",
html: `<span style="color:var(--${c.status === 'A' ? 'add' : c.status === 'D' ? 'del' : 'mod'})">${c.status} </span> <span class="fp">${c.path}</span>`,
})),
],
};
}
Object.assign(window, { Terminal, agentSeed, shellSeed });