/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let _lid = 0;
const lid = () => ++_lid;
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: `console % claude` },
{ id: lid(), kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
{ id: lid(), kind: "t", html: `> add currency + name to the user payload and gate the fillable fields` },
{ id: lid(), kind: "t", html: `● Read UserController.php, edited the response array and onlyFillable().` },
{
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: `● Done — updated UserController.php. Anything else?` },
],
};
}
function shellSeed() {
return {
mode: "shell",
lines: [
{ id: lid(), kind: "t", html: `console % git status -s` },
...PROJECT.changes.map((c) => ({
id: lid(), kind: "t",
html: `${c.status} ${c.path}`,
})),
],
};
}
const Terminal = {
props: ["kind", "seed"],
setup(props) {
const lines = ref(props.seed.lines);
const mode = ref(props.seed.mode);
const input = ref("");
const busy = ref(false);
const bodyRef = ref(null);
const inputRef = ref(null);
const hist = ref([]);
const histIx = ref(-1);
// Auto-scroll on lines/busy change
watch([lines, busy], () => nextTick(() => {
const el = bodyRef.value;
if (el) el.scrollTop = el.scrollHeight;
}));
const push = (line) => { lines.value = [...lines.value, { id: lid(), ...line }]; };
const pushMany = (arr) => { lines.value = [...lines.value, ...arr.map((l) => ({ id: lid(), ...l }))]; };
async function runAgent(prompt) {
busy.value = true;
push({ kind: "agent-think", html: `● thinking…` });
await wait(520);
lines.value = lines.value.slice(0, -1); // drop the thinking line
push({ kind: "t", cls: "", html: `● I'll take a look at the relevant files first.` });
await wait(380);
push({ kind: "t", cls: "dim", html: ` read src/Http/Controller/UserController.php` });
await wait(300);
push({ kind: "t", cls: "dim", html: ` grep "balanceFor" → 2 matches` });
await wait(420);
push({ kind: "t", cls: "", html: `● 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: `● Done. Updated UserController.php. Run tests with composer test.` });
busy.value = false;
requestAnimationFrame(() => inputRef.value && inputRef.value.focus());
}
function shell(cmd) {
const [c, ...rest] = cmd.split(/\s+/);
const arg = rest.join(" ");
switch (c) {
case "": return;
case "claude":
pushMany(bootAgent());
mode.value = "agent";
return;
case "clear": lines.value = []; return;
case "help":
pushMany([
{ kind: "t", cls: "dim", html: "commands: claude 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: "config public scripts src templates 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 ${PROJECT.branch}` },
{ kind: "t", cls: "dim", html: "Changes to be committed:" },
...PROJECT.changes.map((ch) => ({
kind: "t",
html: ` ${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'} ${ch.path}`,
})),
]);
} 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.value.trim();
if (busy.value) return;
if (cmd) { hist.value.unshift(cmd); }
histIx.value = -1;
if (mode.value === "agent") {
if (cmd === "/exit" || cmd === "exit") {
push({ kind: "t", cls: "dim", html: "● Session ended." });
mode.value = "shell"; input.value = ""; return;
}
if (cmd === "/clear" || cmd === "clear") { lines.value = []; input.value = ""; return; }
push({ kind: "t", html: `> ${HL.escapeHtml(cmd).replace(/\n/g, "
") || " "}` });
input.value = "";
if (cmd) runAgent(cmd);
return;
}
push({ kind: "t", html: `console % ${HL.escapeHtml(cmd) || " "}` });
input.value = "";
shell(cmd);
}
function onKey(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.value.includes("\n")) {
e.preventDefault();
if (e.key === "ArrowUp") {
if (hist.value.length) {
histIx.value = Math.min(histIx.value + 1, hist.value.length - 1);
input.value = hist.value[histIx.value];
}
} else {
if (histIx.value > 0) { histIx.value--; input.value = hist.value[histIx.value]; }
else { histIx.value = -1; input.value = ""; }
}
}
}
// agentPaste CustomEvent listener — agent kind only
let agentPasteHandler = null;
onMounted(() => {
if (props.kind !== "agent") return;
agentPasteHandler = (e) => {
if (mode.value !== "agent") { pushMany(bootAgent()); mode.value = "agent"; }
// bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
const base = input.value.replace(/\n+$/, "");
input.value = (base ? base + "\n" : "") + e.detail + "\n";
requestAnimationFrame(() => {
const el = inputRef.value;
if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
});
};
window.addEventListener("agentPaste", agentPasteHandler);
});
onUnmounted(() => {
if (agentPasteHandler) window.removeEventListener("agentPaste", agentPasteHandler);
});
const live = computed(() => mode.value === "agent");
const inputRows = computed(() => Math.min(8, Math.max(1, input.value.split("\n").length)));
const inputPlaceholder = computed(() =>
live.value ? "Ask the agent to change something…" : props.kind === "agent" ? "type 'claude' to start a session" : ""
);
return { lines, mode, input, busy, bodyRef, inputRef, live, inputRows, inputPlaceholder, onKey, submit };
},
template: `