lumbda/wasm/repl/repl.js
russell@unturf.com 1f2ef730a2
python tier: SAB-backed pause poll — auto-pause now works on python too
leval() in lumbda.py grows a counter-gated check (every 1024th
iteration) that calls a module-level _lumbda_pause_hook. Native
Python users leave the hook None and the check short-circuits to a
single bitwise AND. The pyodide loader installs a hook that reads
_lumbdaPyPauseRequested (a JS callback over Atomics.load on the
SAB) so the REPL's auto-pause-on-tab-switch flow drops into the
same path on python that it already uses on C.

Also adds setPauseFlag to the python tier's returned object so
runner.setPauseFlag propagates the SAB through worker config.

repl.js's autoPauseTab no longer falls back to hard-cancel when
the active tier is python — the SAB-poll path covers both. Asm
remains on hard-cancel since the WAT tier has no in-eval poll
site yet.

Test suite (tests.py, 571 tests) passes — verified the no-op
hook path doesn't change native eval semantics.
2026-06-15 11:16:50 -04:00

1154 lines
47 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,
// SharedArrayBuffer the tier's eval loop polls for a cooperative
// pause signal. C tier reads via Atomics in the JS-library import;
// Python via globals + sys.settrace. Browsers refuse the SAB
// constructor without COOP/COEP cross-origin-isolation headers,
// so this stays null on a plain http.server and the ⏸ pause
// button surfaces a helpful tooltip instead of crashing.
pauseFlag: (() => {
try { return new SharedArrayBuffer(4); } catch (e) { return null; }
})(),
pausing: false, // true while a pause-and-snapshot flow is mid-air
};
// Eager view over the pause flag so the button handler doesn't have to
// re-wrap on every click. Null when SAB isn't available.
const pauseFlagView = state.pauseFlag ? new Int32Array(state.pauseFlag) : 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();
}
async function setActiveTab(id) {
if (id === state.activeTabId) return;
// 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;
// Auto-pause + portal-save when the user leaves a tab whose
// tier is mid-eval. Without this, switching tabs during a
// long search abandons the state silently. autoPauseTab snapshots
// the tier's env (when SAB is available — C tier only today),
// stashes the input that started the eval, and terminates the
// worker so the heap is released. autoResumeTab on the way
// back hydrates and re-fires the input.
if (isEvalInFlight(out)) {
await autoPauseTab(out);
}
}
state.activeTabId = id;
renderAll();
saveSoon();
const incoming = activeTab();
if (incoming && incoming.autoPause) {
await autoResumeTab(incoming);
}
}
function isEvalInFlight(tab) {
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) return true;
}
return false;
}
async function waitForPending(tabId, tier, timeoutMs) {
const key = workerKey(tabId, tier);
const start = performance.now();
while (state.pending[key] && (performance.now() - start) < timeoutMs) {
await new Promise((r) => setTimeout(r, 30));
}
}
// Auto-pause hook for tab-switch-away. The tier may be deep inside a
// (let loop ...) — set the SAB pause atomic, wait for the eval-loop
// poll to raise lisp_error("paused"), then snapshot env via the
// regular portal-save path so the state survives until the user
// switches back. Without SAB we still capture the in-flight input
// so the resume can re-eval it cold.
async function autoPauseTab(tab) {
const tier = tab.tier === "all" ? "c" : tab.tier;
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && (tier === "c" || tier === "python")) {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested,
// Python tier's lumbda.py polls _lumbdaPyPauseRequested. Both
// raise their own "paused" error (lisp_error / LispErr) so the
// module-global env survives intact. Asm tier has no in-eval
// poll site — falls through to hard cancel.
// Signal the eval loop and wait for it to settle.
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 5000);
Atomics.store(pauseFlagView, 0, 0);
// Snapshot the (now-idle) tier's env to /tmp and pull the
// blob across the worker bridge. portal-snapshot! is the
// same lisp the manual save flow uses.
const snapName = `_autopause_t${tab.id}`;
try {
await evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`);
const w = state.workers[workerKey(tab.id, tier)];
if (w) {
const runId = state.nextRunId++;
const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save");
if (reply && reply.blob) blob = reply.blob;
}
} catch (e) { /* no blob — resume will re-eval from scratch */ }
} else if (isEvalInFlight(tab)) {
// No SAB or asm tier — hard-cancel the in-flight eval(s).
for (const k of Object.keys(state.pending)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.pending[k].reject(new Error("tab paused")); } catch {}
delete state.pending[k];
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
if (activeInput) {
tab.autoPause = { tier, blob, inputSrc: activeInput, savedAt: Date.now() };
}
// Terminate the worker(s) so the heap is reclaimed while the tab
// is dormant. autoResumeTab spawns a fresh one.
for (const k of Object.keys(state.workers)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
async function autoResumeTab(tab) {
const ap = tab.autoPause;
if (!ap) return;
tab.autoPause = null; // consume up-front so a re-pause picks fresh
tab.tier = ap.tier;
tierSelectEl.value = ap.tier;
if (ap.blob) {
rebootTier(tab.id, ap.tier);
// Round-trip through the tier so portal-load! has the blob
// to read. The bare 'init eval forces the tier to bootstrap
// before we push the blob into MEMFS.
try { await evalInTier(tab.id, ap.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, ap.tier)];
if (w) {
const snapName = `_autopause_t${tab.id}`;
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: ap.tier, name: snapName, blob: ap.blob }, "portal-load");
try { await evalInTier(tab.id, ap.tier, `(portal-load! "${snapName}")`); }
catch (e) { /* surface as resume failure but keep going */ }
}
}
// Re-run the original input. The transcript already shows the
// prior partial run; this appears as a new entry so the user
// sees the resumption explicitly.
inputEl.value = ap.inputSrc;
autosizeInput();
await sendInput();
}
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;
// Hand the worker its pause flag. Worker stashes the SAB so the
// tier loader can install an Int32Array view when it boots. We
// skip bendUrl here so the worker's previous bend setting (if
// any) isn't clobbered to null.
w.postMessage({ kind: "config", pauseFlag: state.pauseFlag });
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$/, "");
// Preserving partial output on cancel/error: a long-running
// (display ...) loop that fox cancels half-way through, or
// an asm tier that hits "index out of bounds" 5000 lines
// into a sieve, used to lose every line that already
// streamed. Now:
// * success, streamed matches full output → keep DOM.
// * success, no streaming happened → rebuild from
// the final text.
// * error with prior streaming → KEEP the
// streamed divs, append a final divider + error line.
// * error with no streaming → show error.
if (!errText) {
if (streamedText !== expected) {
resultSpan.textContent = "";
const lines = expected.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
}
} else if (streamedText) {
// Append the error AFTER what's already on screen so
// the user keeps every line they were watching scroll.
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
} else {
resultSpan.textContent = "";
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
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 = "";
// Always lead with "scope:" so the visible text reads as a state
// label rather than a target ("this tab" alone was ambiguous —
// is it telling me the current scope, or what I'd switch to?).
portalScopeBtn.textContent = showGlobalCheckpoints ? "scope: all tabs 🌐" : "scope: this tab 📂";
portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click to show only this tab"
: "showing only this tab's checkpoints — click to show 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 = "";
// Remember the input string while it's evaluating so that auto-
// pause-on-tab-switch can re-fire it on resume. Cleared on completion.
tab.activeInput = src;
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();
// Eval finished cleanly — drop the activeInput marker so
// a subsequent tab switch doesn't try to resume something
// that already completed.
tab.activeInput = null;
}
});
}
}
// ─── 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(); }
});