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.
981 lines
40 KiB
JavaScript
981 lines
40 KiB
JavaScript
// 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(); }
|
||
});
|