lumbda/www/repl/repl.js
russell@unturf.com 2b46c1b79e
repl portal: overwrite-guard, rename, export/import, cross-tab restore
Three UX gaps on the portal-bar closed in one pass plus a defensive
fix on the C tier's heap probe:

* Overwrite guard — saving with an existing name asks 'overwrite?'
  with the existing entry's tier + savedAt. Rename via dbl-click on
  the chip label; same overwrite guard applies on rename.

* Export / import — a ⇣ icon on each chip downloads it as
  <name>.portal.json (opaque blob for c/python, replay-inputs for
  asm). A 📁 import button on the portal-bar accepts a .portal.json
  file via hidden <input type="file">; collisions prompt overwrite,
  decline auto-suffixes (baseName-2, -3, …) so importing a 2nd copy
  always lands somewhere.

* Cross-tab restore — a 📂 this tab / 🌐 all tabs toggle switches
  the chip strip between the active tab's checkpoints and every
  tab's. Global chips render as 'name · tabName' with a dashed
  border; click restores the snapshot into the active tab (the
  saved cp is passed through restoreCheckpoint's new sourceCp
  argument so the chip doesn't need a checkpoints[name] match on
  the active tab). Edit/delete are hidden in global mode — the
  user switches to the owning tab to manage chips.

* heapStats defensive — wasm/c/lumbda-c.loader.js now returns null
  when module.HEAPU8 isn't live yet (caught by the heap poll firing
  during the tiny window between tier reboot and Module init), so a
  restore no longer surfaces 'Cannot read properties of undefined
  (reading byteLength)' as a TypeError.

Smoke-tested headlessly: overwrite confirm fires with the expected
message; rename via dblclick swaps the label; ⇣ produces a download
named '<name>.portal.json'; toggle shows both tabs' chips with the
'· tabName' annotation; import round-trips back into the receiving
tab. Zero page errors across the full flow.
2026-06-15 08:14:53 -04:00

981 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// wasm/repl/repl.js
// Lumbda interactive REPL with multi-tab sessions + encrypted persistence.
import { openVault } from "./crypto.js";
const TIER_LABEL = {
python: "python (pyodide)",
c: "c (emcc)",
asm: "asm (wat)",
};
// ─── DOM ────────────────────────────────────────────────────────────
const lockScreen = document.getElementById("lock-screen");
const passwordEl = document.getElementById("password");
const unlockBtn = document.getElementById("unlock");
const freshBtn = document.getElementById("fresh");
const lockNote = document.getElementById("lock-note");
const tabsEl = document.getElementById("tabs");
const newTabBtn = document.getElementById("new-tab");
const resetTierBtn = document.getElementById("reset-tier");
const clearLogBtn = document.getElementById("clear-log");
const cancelBtn = document.getElementById("cancel");
const lockBtn = document.getElementById("lock");
const replStream = document.getElementById("repl-stream");
const transcriptEl = document.getElementById("transcript");
const inputEl = document.getElementById("input");
const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
const portalBarEl = document.getElementById("portal-bar");
const portalSaveBtn = document.getElementById("portal-save");
const portalImportBtn = document.getElementById("portal-import");
const portalImportFileEl = document.getElementById("portal-import-file");
const portalScopeBtn = document.getElementById("portal-scope");
const portalChipsEl = document.getElementById("portal-chips");
// Whether the chip strip shows every tab's checkpoints (true) or just
// the active tab's (false). UI-only — never persisted, so a fresh
// session always starts in the focused per-tab view.
let showGlobalCheckpoints = false;
// ─── State ──────────────────────────────────────────────────────────
const state = {
vault: null, // { read, write, destroy, vaultId } or null (ephemeral)
tabs: [], // [{ id, name, tier, transcript: [{kind, text, results?}] }]
activeTabId: null,
workers: {}, // { [tabId+":"+tier]: Worker }
pending: {}, // same key → { runId, reject }
nextRunId: 1,
nextTabId: 1,
saveTimer: null,
};
// ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() {
if (!state.vault) return;
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
await state.vault.write({
tabs: state.tabs,
activeTabId: state.activeTabId,
savedAt: Date.now(),
});
} catch (e) {
console.error("vault write failed:", e);
}
}, 400);
}
async function tryUnlock() {
const pw = passwordEl.value;
if (!pw) { lockNote.textContent = "password required (or click ephemeral)"; return; }
lockNote.textContent = "";
try {
state.vault = await openVault(pw);
const data = await state.vault.read();
if (data && data.__decryptionFailed) {
// Wrong password for an existing vault under this id.
lockNote.textContent = "vault exists but password is wrong";
state.vault = null;
return;
}
if (data && Array.isArray(data.tabs) && data.tabs.length > 0) {
state.tabs = data.tabs;
state.activeTabId = data.activeTabId || data.tabs[0].id;
state.nextTabId = Math.max(...state.tabs.map((t) => t.id)) + 1;
} else {
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
}
enterRepl();
} catch (e) {
lockNote.textContent = "unlock failed: " + e.message;
}
}
function enterEphemeral() {
state.vault = null;
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
enterRepl();
}
function enterRepl() {
lockScreen.hidden = true;
replStream.hidden = false;
if (state.tabs.length === 0) newTab();
else renderAll();
inputEl.focus();
}
function relock() {
// Terminate all workers, clear in-memory state, return to lock screen.
for (const k of Object.keys(state.workers)) {
try { state.workers[k].terminate(); } catch {}
}
state.workers = {};
state.pending = {};
state.vault = null;
state.tabs = [];
state.activeTabId = null;
transcriptEl.innerHTML = "";
tabsEl.innerHTML = "";
replStream.hidden = true;
lockScreen.hidden = false;
passwordEl.value = "";
passwordEl.focus();
}
// ─── Tabs ───────────────────────────────────────────────────────────
function newTab(name) {
const id = state.nextTabId++;
name = name || `session ${id}`;
state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" });
state.activeTabId = id;
renderAll();
saveSoon();
}
function closeTab(id) {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
// Terminate this tab's workers.
for (const k of Object.keys(state.workers)) {
if (k.startsWith(id + ":")) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
state.tabs.splice(idx, 1);
if (state.activeTabId === id) {
state.activeTabId = state.tabs.length ? state.tabs[Math.max(0, idx - 1)].id : null;
}
if (state.tabs.length === 0) newTab();
else renderAll();
saveSoon();
}
function setActiveTab(id) {
// Capture the outgoing tab's current input as its draft before we
// swap tabs — otherwise switching away from a half-written form and
// back loses everything between the last input event and the next
// saveSoon flush.
const out = activeTab();
if (out) out.inputDraft = inputEl.value;
state.activeTabId = id;
renderAll();
saveSoon();
}
function activeTab() {
return state.tabs.find((t) => t.id === state.activeTabId);
}
// ─── Workers ────────────────────────────────────────────────────────
function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w;
return w;
}
function ensureWorker(tabId, tier) {
const k = workerKey(tabId, tier);
if (!state.workers[k]) spawnWorker(tabId, tier);
return state.workers[k];
}
function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
const pendingKey = workerKey(tabId, tier);
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "chunk-text") {
onChunkText && onChunkText(e.data.text);
} else if (e.data.kind === "chunk-eol") {
onChunkEol && onChunkEol();
} else if (e.data.kind === "done") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
resolve(e.data.output);
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
reject(new Error(e.data.message));
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId, tier, src });
});
}
// Streaming live block — attached to a tier-result span after the
// initial renderAll so chunks arrive directly into the DOM without
// re-rendering the whole transcript (which would clobber sibling
// tiers still streaming). Same per-line-div pattern the playground
// uses: each chunk-eol closes a block-level div, sibling lines
// stack vertically regardless of <span>'s inline default.
function attachStreaming(resultSpan, metaSpan, tier) {
const makeLine = () => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
return d;
};
let pendingLine = makeLine();
let pendingText = "";
resultSpan.textContent = "";
resultSpan.appendChild(pendingLine);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
return {
appendText(t) {
if (!t) return;
pendingText += t;
pendingLine.textContent = pendingText;
maybeAutoscroll();
},
appendNewline() {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
maybeAutoscroll();
},
finalize(r) {
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)
.map((d) => d.textContent === " " ? "" : d.textContent)
.join("\n");
const expected = text.replace(/\n$/, "");
// If the streamed text doesn't match the full eval output
// (e.g. an error fired without intermediate chunks), nuke
// and rebuild from the full text. Otherwise leave the
// streamed divs as-is — they already match.
if (streamedText !== expected || errText) {
resultSpan.textContent = "";
const combined = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, "");
const lines = combined.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},
};
}
// ─── Portal save / resume ───────────────────────────────────────────
// Cooperative checkpoints: the tier exposes (portal-snapshot! NAME) and
// (portal-load! NAME) which write/read /tmp/NAME.portal inside MEMFS.
// JS reaches into MEMFS via the worker bridge, encrypts the blob into
// the vault, and on restore reverses the flow. Names are alphanumeric;
// the timestamp format we generate ourselves never embeds slashes.
function safePortalName(s) {
return (s || "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40) || "snap";
}
function bridgeWorker(w, msg, matchKind) {
return new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== matchKind || e.data.runId !== msg.runId) return;
w.removeEventListener("message", handler);
resolve(e.data);
};
w.addEventListener("message", handler);
w.postMessage(msg);
});
}
// Tier-specific save strategies:
// * c, python — call the tier's (portal-snapshot! NAME) so portal.c /
// lumbda.py's portal_save dumps env+RNG to /tmp/<name>.portal JSON,
// then JS reads the blob out of MEMFS. Faithful reproduction.
// * asm-wat — no portal subsystem in the tier yet (it'd need a
// Cheney-aware serializer). Instead snapshot the transcript and
// replay on restore. Works for the REPL's actual workflow of
// successive defines because every closure rebuilds from source.
async function saveCheckpoint() {
const tab = activeTab();
if (!tab) return;
const tier = tab.tier === "all" ? "c" : tab.tier;
const ts = new Date();
const defaultName = `snap-${ts.getHours().toString().padStart(2, "0")}` +
`${ts.getMinutes().toString().padStart(2, "0")}` +
`${ts.getSeconds().toString().padStart(2, "0")}`;
const name = safePortalName(prompt("checkpoint name:", defaultName) || "");
if (!name) return;
// Overwrite guard — saving with an existing name silently nuked
// the old blob, which has cost more than one "where did my snapshot
// go?" moment. Ask first.
if (tab.checkpoints && tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
let entry;
if (tier === "asm") {
// Replay mode — capture every successful prior input. Errors
// get dropped because re-running them on restore would just
// crash the tier mid-replay.
const inputs = tab.transcript
.filter((e) => e.kind !== "error")
.map((e) => e.input);
entry = { tier: "asm", mode: "replay", inputs, savedAt: Date.now() };
} else {
// C / Python — portal-snapshot! lands a JSON blob in MEMFS.
try {
await evalInTier(tab.id, tier, `(portal-snapshot! "${name}")`);
} catch (e) {
alert("portal-snapshot! failed: " + e.message);
return;
}
const w = state.workers[workerKey(tab.id, tier)];
if (!w) { alert("worker missing"); return; }
const runId = state.nextRunId++;
const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name }, "portal-save");
if (!reply.blob) { alert("portal blob empty — snapshot didn't land in MEMFS"); return; }
entry = { tier, mode: "blob", blob: reply.blob, savedAt: Date.now() };
}
tab.checkpoints = tab.checkpoints || {};
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
// Restore a checkpoint into the active tab. The optional sourceCp
// override lets the global-checkpoints view pass in a cp from a
// different tab — the restore still operates on activeTab() so the
// user lands on the tab they were working in, with that tab's tier
// state reset to the snapshot.
async function restoreCheckpoint(name, sourceCp) {
const tab = activeTab();
if (!tab) return;
const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]);
if (!cp) return;
if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return;
if (tab.tier !== cp.tier) {
tab.tier = cp.tier;
tierSelectEl.value = cp.tier;
renderAll();
}
// Reboot the tier so we start from a clean global env — either
// replay needs it, or portal_resume's "merge into _env" semantics
// would otherwise compound on top of whatever's already defined.
rebootTier(tab.id, cp.tier);
if (cp.mode === "replay") {
// Re-eval every saved input on the fresh worker, in order.
for (const src of (cp.inputs || [])) {
try { await evalInTier(tab.id, cp.tier, src); } catch (e) { /* keep going */ }
}
tab.transcript.push({ input: `; replayed ${cp.inputs.length} inputs from '${name}'`,
results: [{ tier: cp.tier, output: "#t" }], kind: "ok" });
} else {
// Blob mode — hydrate MEMFS, then ask the tier to load it.
try { await evalInTier(tab.id, cp.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, cp.tier)];
if (!w) { alert("worker missing"); return; }
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: cp.tier, name, blob: cp.blob }, "portal-load");
try {
await evalInTier(tab.id, cp.tier, `(portal-load! "${name}")`);
tab.transcript.push({ input: `(portal-load! "${name}")`,
results: [{ tier: cp.tier, output: "#t" }], kind: "ok" });
} catch (e) {
alert("portal-load! failed: " + e.message);
return;
}
}
renderAll();
saveSoon();
}
function renameCheckpoint(oldName) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return;
const raw = prompt(`rename '${oldName}' to:`, oldName);
if (raw === null) return;
const newName = safePortalName(raw);
if (!newName || newName === oldName) return;
if (tab.checkpoints[newName]) {
const old = tab.checkpoints[newName];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
tab.checkpoints[newName] = tab.checkpoints[oldName];
delete tab.checkpoints[oldName];
renderAll();
saveSoon();
}
// Export a checkpoint to a downloadable .portal.json file. The blob
// itself is opaque JSON for c/python (portal_save's wire format) and a
// {tier, mode: "replay", inputs} object for asm. Either way the file
// holds everything importCheckpoint needs to reconstruct the entry; no
// vault-side metadata bleeds out.
function exportCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return;
const entry = tab.checkpoints[name];
const payload = { __portal: 1, name, ...entry };
const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.portal.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Import a checkpoint File (from <input type="file">). Validates the
// shape, prompts on name collision, lands the entry in the active
// tab's checkpoints map. The imported entry lives on whatever tier it
// was saved under — restoring it will switch the active tab to that
// tier automatically (restoreCheckpoint already does the swap).
async function importCheckpoint(file) {
const tab = activeTab();
if (!tab) return;
let payload;
try {
const text = await file.text();
payload = JSON.parse(text);
} catch (e) {
alert("import failed — not valid JSON: " + e.message);
return;
}
if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) {
alert("import failed — file isn't a portal snapshot (missing __portal/tier/savedAt)");
return;
}
const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported";
let name = baseName;
tab.checkpoints = tab.checkpoints || {};
if (tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) {
// Auto-suffix on decline so the user gets to keep both.
let i = 2;
while (tab.checkpoints[`${baseName}-${i}`]) i++;
name = `${baseName}-${i}`;
}
}
const entry = { tier: payload.tier, savedAt: payload.savedAt };
if (payload.mode === "replay") {
entry.mode = "replay";
entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : [];
} else {
entry.mode = "blob";
entry.blob = payload.blob || "";
}
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
function deleteCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints) return;
delete tab.checkpoints[name];
renderAll();
saveSoon();
}
function rebootTier(tabId, tier) {
const k = workerKey(tabId, tier);
if (state.workers[k]) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
if (state.pending[k]) {
try { state.pending[k].reject(new Error("rebooted")); } catch {}
delete state.pending[k];
}
}
function cancelAllPendingInActiveTab() {
const tab = activeTab();
if (!tab) return;
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) {
try { state.pending[k].reject(new Error("cancelled")); } catch {}
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
delete state.pending[k];
}
}
}
// ─── Render ─────────────────────────────────────────────────────────
function renderAll() {
// Tabs
tabsEl.innerHTML = "";
for (const t of state.tabs) {
const el = document.createElement("div");
el.className = "tab" + (t.id === state.activeTabId ? " active" : "");
const name = document.createElement("span");
name.textContent = t.name;
name.addEventListener("click", () => setActiveTab(t.id));
name.addEventListener("dblclick", (e) => {
e.stopPropagation();
const newName = prompt("rename tab:", t.name);
if (newName) { t.name = newName.slice(0, 40); renderAll(); saveSoon(); }
});
const close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.title = "close tab";
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(t.id); });
el.appendChild(name);
el.appendChild(close);
tabsEl.appendChild(el);
}
// Transcript — terminal-style: prompt line, then output lines underneath
// indented under the prompt with a tier-tag suffix as a Lisp comment.
transcriptEl.innerHTML = "";
const tab = activeTab();
if (!tab) return;
tierSelectEl.value = tab.tier;
// Restore the active tab's saved draft into the textarea so a page
// refresh (or tab switch) lands the user back on the half-written
// form they had before. The guard avoids fighting an active input
// event mid-stream — if the textarea already matches the draft
// there's nothing to do.
const draft = tab.inputDraft || "";
if (inputEl.value !== draft) {
inputEl.value = draft;
autosizeInput();
}
for (const entry of tab.transcript) {
const block = document.createElement("div");
block.className = "entry" + (entry.kind === "error" ? " error" : "");
// Render the input across one or more lines (preserve newlines).
const inputLines = entry.input.split(/\n/);
for (let i = 0; i < inputLines.length; i++) {
const line = document.createElement("div");
line.className = "prompt-line";
const sigil = document.createElement("span");
sigil.className = "sigil";
sigil.textContent = i === 0 ? "λ>" : "..";
line.appendChild(sigil);
line.appendChild(document.createTextNode(inputLines[i]));
block.appendChild(line);
}
for (const r of (entry.results || [])) {
const row = document.createElement("div");
row.className = "tier-output";
const result = document.createElement("span");
result.className = "tier-result";
const text = (r.output != null ? r.output : "");
const errText = r.error ? "error: " + r.error : "";
result.textContent = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, "");
const meta = document.createElement("span");
meta.className = "tier-meta";
meta.textContent = `${TIER_LABEL[r.tier] || r.tier} · ${(r.elapsed | 0)}ms`;
row.appendChild(result);
row.appendChild(meta);
block.appendChild(row);
}
transcriptEl.appendChild(block);
}
// Portal chips — when showGlobalCheckpoints is off, one per saved
// checkpoint in the active tab. When on, gather every checkpoint
// across every tab so the user can restore a snapshot saved on
// tab A into tab B. The label gains a "· tabName" suffix in
// global mode so it's clear where each chip came from.
portalChipsEl.innerHTML = "";
portalScopeBtn.textContent = showGlobalCheckpoints ? "🌐 all tabs" : "📂 this tab";
portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click for this tab only"
: "showing only this tab's checkpoints — click for every tab";
const chipRows = [];
if (showGlobalCheckpoints) {
for (const t of state.tabs) {
const cps = t.checkpoints || {};
for (const name of Object.keys(cps)) {
chipRows.push({ tab: t, name, cp: cps[name] });
}
}
chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt);
} else {
const cps = tab.checkpoints || {};
for (const name of Object.keys(cps).sort()) {
chipRows.push({ tab, name, cp: cps[name] });
}
}
for (const { tab: cpTab, name, cp } of chipRows) {
const chip = document.createElement("span");
chip.className = "chip";
const when = new Date(cp.savedAt).toLocaleString();
chip.title = showGlobalCheckpoints
? `${name} · ${cp.tier} · saved ${when} on tab '${cpTab.name}' — click to restore into THIS tab`
: `${name} · ${cp.tier} · saved ${when} — click to restore, dbl-click to rename`;
const label = document.createElement("span");
label.textContent = showGlobalCheckpoints ? `${name} · ${cpTab.name}` : name;
// Restore always operates on the active tab; in global mode
// the snapshot's cp object is passed so we don't depend on
// the active tab having a checkpoints[name] match.
label.addEventListener("click", () => restoreCheckpoint(name, cp));
if (!showGlobalCheckpoints) {
label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); });
const exportBtn = document.createElement("span");
exportBtn.className = "chip-export";
exportBtn.textContent = "⇣";
exportBtn.title = "download checkpoint as .portal.json";
exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); });
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(exportBtn);
chip.appendChild(close);
} else {
chip.classList.add("chip-global");
chip.appendChild(label);
}
portalChipsEl.appendChild(chip);
}
// Body owns the scroll now; jump to the bottom after layout settles.
// requestAnimationFrame lets the just-mounted DOM contribute to
// scrollHeight before we measure — otherwise on first load the page
// sticks at the top because the transcript hasn't been laid out yet.
// renderAll fires on user-initiated actions (send, tab switch, new
// entry) where they expect the most-recent content, so the jump
// here is unconditional and resets scrollPinned to true so streamed
// follow-ups continue to autoscroll.
requestAnimationFrame(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
scrollPinned = true;
});
}
// ─── Sticky autoscroll ──────────────────────────────────────────────
// During a long streaming eval the page should track the bottom UNLESS
// the user has deliberately scrolled up to read older output. We sample
// "is the viewport at the bottom" on every user scroll, then streamed
// appendText/appendNewline only force the scroll when scrollPinned is
// still true. A 64px tolerance covers fractional-pixel scroll positions
// and short header offsets — anything farther than that is treated as
// "user wanted to look back" and autoscroll stops until they return.
let scrollPinned = true;
const SCROLL_BOTTOM_TOLERANCE = 64;
function isViewportAtBottom() {
return (window.innerHeight + window.scrollY) >=
(document.documentElement.scrollHeight - SCROLL_BOTTOM_TOLERANCE);
}
function maybeAutoscroll() {
if (scrollPinned) {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
}
}
window.addEventListener("scroll", () => {
scrollPinned = isViewportAtBottom();
}, { passive: true });
// ─── History navigation (readline-style up/down) ────────────────────
// Each tab owns its own history; the active tab's draft is preserved so
// that walking back into history doesn't eat what the user was typing.
const history = { idx: null, draft: "" };
function historyEntries() {
// Transcript's `input` fields, deduplicated against the immediate
// predecessor — pressing up shouldn't make you hit the same line
// twice in a row when you just submitted it.
const tab = activeTab();
if (!tab) return [];
const out = [];
for (const e of tab.transcript) {
if (out.length && out[out.length - 1] === e.input) continue;
out.push(e.input);
}
return out;
}
function historyPrev() {
const entries = historyEntries();
if (entries.length === 0) return;
if (history.idx === null) {
history.draft = inputEl.value;
history.idx = entries.length - 1;
} else if (history.idx > 0) {
history.idx--;
}
inputEl.value = entries[history.idx];
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
autosizeInput();
}
function historyNext() {
const entries = historyEntries();
if (history.idx === null) return;
if (history.idx >= entries.length - 1) {
history.idx = null;
inputEl.value = history.draft;
history.draft = "";
} else {
history.idx++;
inputEl.value = entries[history.idx];
}
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
autosizeInput();
}
function resetHistory() {
history.idx = null;
history.draft = "";
}
// Auto-grow the textarea to fit its content (up to the max-height the
// CSS pins it at, after which the textarea scrolls internally). Paste
// a 40-line program and the prompt-bar expands instead of leaving you
// editing the program through a one-line keyhole. Fires on every input
// event and every programmatic value set (sendInput, history, etc.).
function autosizeInput() {
inputEl.style.height = "auto";
inputEl.style.height = inputEl.scrollHeight + "px";
}
// ─── Input handling ─────────────────────────────────────────────────
async function sendInput() {
const src = inputEl.value.trim();
if (!src) return;
const tab = activeTab();
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
// Pre-populate placeholder results so the initial renderAll lays
// out the tier-output rows we'll stream into. Each placeholder
// carries output="" and a streaming:true flag so renderAll
// (if invoked later for any reason) renders an empty row rather
// than missing the tier. Real values land via the streaming
// attach below, finalized when each tier's promise resolves.
const entry = {
input: src,
results: tiers.map((t) => ({ tier: t, output: "", streaming: true })),
kind: "ok",
};
tab.transcript.push(entry);
inputEl.value = "";
tab.inputDraft = "";
autosizeInput();
resetHistory();
sendBtn.disabled = true;
cancelBtn.disabled = false;
renderAll();
saveSoon();
// Grab the DOM rows we just rendered for this entry so streaming
// chunks land directly into them. transcriptEl.lastElementChild
// is the block we just added (entries render in order).
const entryEl = transcriptEl.lastElementChild;
const rowEls = entryEl ? Array.from(entryEl.querySelectorAll(".tier-output")) : [];
const liveRows = {};
for (let i = 0; i < tiers.length && i < rowEls.length; i++) {
const row = rowEls[i];
const resultSpan = row.querySelector(".tier-result");
const metaSpan = row.querySelector(".tier-meta");
if (resultSpan && metaSpan) {
liveRows[tiers[i]] = attachStreaming(resultSpan, metaSpan, tiers[i]);
}
}
const startTimes = {};
for (const t of tiers) startTimes[t] = performance.now();
const promises = tiers.map((t) => {
const live = liveRows[t];
return evalInTier(
tab.id, t, src,
live ? (text) => live.appendText(text) : null,
live ? () => live.appendNewline() : null,
)
.then((output) => ({ tier: t, output, elapsed: performance.now() - startTimes[t] }))
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] }));
});
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
// Mutate the placeholder result in place — never push a
// second entry per tier or we'd render twice. Mark
// streaming=false so a later tab switch redraws via the
// normal (full text) path instead of leaving it blank.
const idx = entry.results.findIndex((x) => x.tier === r.tier && x.streaming);
if (idx >= 0) {
entry.results[idx] = {
tier: r.tier,
output: r.output != null ? r.output : "",
error: r.error,
elapsed: r.elapsed,
};
if (r.error) entry.kind = "error";
}
// Update the DOM in place (don't call renderAll — it'd
// clobber sibling tiers still streaming). finalize swaps
// the streaming divs for the canonical text if they
// diverge and writes the elapsed-ms meta.
const live = liveRows[r.tier];
if (live) live.finalize(r);
saveSoon();
remaining--;
if (remaining === 0) {
sendBtn.disabled = false;
cancelBtn.disabled = true;
inputEl.focus();
}
});
}
}
// ─── Heap pressure indicator ───────────────────────────────────────
// Polls each loaded tier in the active tab and prints a compact
// "py 12M · c 32M · asm 4M" string next to the tabbar buttons.
const heapEl = document.createElement("span");
heapEl.className = "heap-pressure";
heapEl.title = "tier worker memory — \"reboot tier\" reclaims on demand";
function formatBytes(n) {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`;
return `${(n / (1024 * 1024)).toFixed(1)}M`;
}
async function pollHeap() {
const tab = activeTab();
if (!tab) return;
const tiers = ["python", "c", "asm"];
const parts = [];
for (const t of tiers) {
const k = workerKey(tab.id, t);
const w = state.workers[k];
if (!w) continue;
const runId = ++state.nextRunId;
const stats = await new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== "heap" || e.data.runId !== runId) return;
w.removeEventListener("message", handler);
resolve(e.data.stats);
};
w.addEventListener("message", handler);
w.postMessage({ kind: "heap", runId, tier: t });
// Timeout safety — eval-busy workers won't reply.
setTimeout(() => { w.removeEventListener("message", handler); resolve(null); }, 200);
});
if (stats && stats.used != null) {
parts.push(`${t.slice(0, 3)} ${formatBytes(stats.used)}`);
}
}
heapEl.textContent = parts.length ? parts.join(" · ") : "";
}
setInterval(pollHeap, 2000);
// ─── Wire up ────────────────────────────────────────────────────────
unlockBtn.addEventListener("click", tryUnlock);
freshBtn.addEventListener("click", enterEphemeral);
passwordEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") tryUnlock();
});
newTabBtn.addEventListener("click", () => newTab());
resetTierBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) rebootTier(tab.id, tab.tier);
});
clearLogBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
portalSaveBtn.addEventListener("click", saveCheckpoint);
portalImportBtn.addEventListener("click", () => portalImportFileEl.click());
portalImportFileEl.addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
await importCheckpoint(file);
e.target.value = "";
});
portalScopeBtn.addEventListener("click", () => {
showGlobalCheckpoints = !showGlobalCheckpoints;
renderAll();
});
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendInput();
return;
}
// Up/Down navigate per-tab history when the caret is in the only
// (or first/last) row — otherwise they belong to the textarea's
// natural multi-line navigation.
if (e.key === "ArrowUp") {
const before = inputEl.value.substring(0, inputEl.selectionStart);
if (!before.includes("\n")) {
e.preventDefault();
historyPrev();
}
return;
}
if (e.key === "ArrowDown") {
const after = inputEl.value.substring(inputEl.selectionStart);
if (!after.includes("\n")) {
e.preventDefault();
historyNext();
}
return;
}
});
inputEl.addEventListener("input", () => {
autosizeInput();
// Persist the in-progress input so a refresh, lock/unlock, or tab
// switch lands the user back where they were. saveSoon debounces
// to 400ms so paste storms don't thrash the vault writer.
const tab = activeTab();
if (tab) {
tab.inputDraft = inputEl.value;
saveSoon();
}
// Any keystroke that isn't an arrow drops history navigation —
// edits are now the user's own draft, not the historical entry.
if (history.idx !== null && document.activeElement === inputEl) {
// We can't reliably distinguish arrow-induced updates here, so
// we only invalidate when the buffer has actually diverged from
// the historical entry the cursor was on.
const entries = historyEntries();
if (entries[history.idx] !== inputEl.value) {
history.draft = inputEl.value;
history.idx = null;
}
}
});
sendBtn.addEventListener("click", sendInput);
tierSelectEl.addEventListener("change", () => {
const tab = activeTab();
if (tab) { tab.tier = tierSelectEl.value; saveSoon(); }
});