first commit
This commit is contained in:
231
design_handoff_helder_workbench/design/src/terminals.jsx
Normal file
231
design_handoff_helder_workbench/design/src/terminals.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/* 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 <file> 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) || " " })));
|
||||
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">></span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br> ") || " "}` });
|
||||
setInput("");
|
||||
if (cmd) runAgent(cmd);
|
||||
return;
|
||||
}
|
||||
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || " "}` });
|
||||
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">></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 });
|
||||
Reference in New Issue
Block a user