Three converging bugs let a cancel-then-send sequence land in a broken state: 1) cancelAllPendingInActiveTab didn't touch the toolbar — the user had to wait for the cancelled promise's .catch.then to drain before send re-enabled. fox saw clicks register as noops because the button was still disabled. 2) Even after the rejection settled, the stale .then path re-enabled sendBtn from inside the FIRST run's closure — but by then the user had already submitted a SECOND run that set sendBtn disabled. The stale .then clobbered the in-flight state. 3) The OLD worker's last-queued "done" or "error" message could be delivered after a fresh run had already taken state.pending[k]; the OLD handler would then delete the NEW run's pending entry. Fixes: * cancel handler now re-enables send / disables cancel + pause synchronously and clears tab.activeInput. * The completion .then re-checks isEvalInFlight(tab) before resetting toolbar state, so a stale settle from a cancelled run can't override the fresh run's UI. * evalInTier's done/error handler only deletes state.pending[k] when the entry still has the runId we started with. Verified headlessly: long loop → cancel → submit (+ 1 2) immediately → first entry shows error: cancelled, second entry shows result 3, send re-enabled.
1326 lines
56 KiB
JavaScript
1326 lines
56 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 pauseBtn = document.getElementById("pause");
|
||
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 ───────────────────────────────────────────────────────────
|
||
async function newTab(name) {
|
||
const id = state.nextTabId++;
|
||
name = name || `session ${id}`;
|
||
state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" });
|
||
// Route through setActiveTab so the outgoing tab's auto-pause
|
||
// fires and the toolbar buttons sync to the freshly-created
|
||
// (idle) tab. Without this, opening a new tab during an eval
|
||
// leaves the send button disabled and the eval orphaned.
|
||
await setActiveTab(id);
|
||
}
|
||
|
||
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;
|
||
// Re-entry guard: rapid clicks on tab labels can fire setActiveTab
|
||
// before the previous autoPause/autoResume cycle finishes. Without
|
||
// this, two pause flows race on the same outgoing tab and the
|
||
// second one captures torn state. Drop the later click silently
|
||
// (cheaper than queueing — the user can click again once they
|
||
// see the active tab settle).
|
||
if (state.switching) return;
|
||
state.switching = true;
|
||
try {
|
||
// 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 env (SAB-poll path for C/python,
|
||
// transcript replay for asm), 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();
|
||
// Sync the global toolbar button states to the incoming tab —
|
||
// send/cancel/pause are not tab-scoped DOM elements, so a
|
||
// switch-away during one tab's eval would otherwise leave the
|
||
// newly-active (idle) tab with send disabled. The active tab
|
||
// is idle iff nothing in state.pending matches its id.
|
||
const incoming = activeTab();
|
||
if (incoming) {
|
||
const evalRunning = isEvalInFlight(incoming);
|
||
sendBtn.disabled = evalRunning;
|
||
cancelBtn.disabled = !evalRunning;
|
||
pauseBtn.disabled = !evalRunning || !pauseFlagView;
|
||
}
|
||
saveSoon();
|
||
if (incoming && incoming.autoPause) {
|
||
await autoResumeTab(incoming);
|
||
}
|
||
} finally {
|
||
state.switching = false;
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
|
||
// Race a promise against a wall-clock timeout. Returns null when the
|
||
// promise didn't settle in time so the caller can move on rather than
|
||
// hang. Used in autoPauseTab so a worker that won't yield to the
|
||
// pause flag (e.g. stuck in sync XHR, or eval-loop poll never fires
|
||
// because we're outside the C poll site) can still be terminated.
|
||
function withTimeout(p, ms) {
|
||
return Promise.race([
|
||
p.catch(() => null),
|
||
new Promise((r) => setTimeout(() => r(null), ms)),
|
||
]);
|
||
}
|
||
|
||
// 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.
|
||
Atomics.store(pauseFlagView, 0, 1);
|
||
await waitForPending(tab.id, tier, 2000);
|
||
Atomics.store(pauseFlagView, 0, 0);
|
||
|
||
// If the eval is still in flight after 2s, the worker isn't
|
||
// yielding to the SAB flag (stuck in sync XHR, native builtin
|
||
// without a yield point, etc). Skip the snapshot attempt and
|
||
// drop straight to the unconditional terminate below — the
|
||
// user gets the cancel-and-replay fallback instead of a hang.
|
||
if (!isEvalInFlight(tab)) {
|
||
const snapName = `_autopause_t${tab.id}`;
|
||
try {
|
||
const snapOk = await withTimeout(
|
||
evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`),
|
||
2000,
|
||
);
|
||
if (snapOk !== null) {
|
||
const w = state.workers[workerKey(tab.id, tier)];
|
||
if (w) {
|
||
const runId = state.nextRunId++;
|
||
const reply = await withTimeout(
|
||
bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"),
|
||
1500,
|
||
);
|
||
if (reply && reply.blob) blob = reply.blob;
|
||
}
|
||
}
|
||
} catch (e) { /* no blob — resume will re-eval from scratch */ }
|
||
}
|
||
}
|
||
if (isEvalInFlight(tab)) {
|
||
// SAB unavailable, asm tier, OR SAB pause timed out without
|
||
// the eval unwinding — hard-cancel the in-flight eval(s) so
|
||
// the unconditional terminate loop below actually kills them.
|
||
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];
|
||
}
|
||
}
|
||
|
||
if (activeInput) {
|
||
// Asm tier has no SAB poll site, so we can't snapshot env mid-eval.
|
||
// Fall back to replay mode: every successful prior input gets
|
||
// re-eval'd on resume to rebuild the env, then the active input
|
||
// is sent fresh. Same pattern the manual asm portal-save uses.
|
||
const replayInputs = (tier === "asm")
|
||
? tab.transcript.filter((e) => e.kind !== "error" && e.input !== activeInput).map((e) => e.input)
|
||
: null;
|
||
tab.autoPause = { tier, blob, replayInputs, 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;
|
||
|
||
rebootTier(tab.id, ap.tier);
|
||
|
||
if (ap.replayInputs && ap.replayInputs.length) {
|
||
// Asm tier replay path — re-eval every successful prior input
|
||
// in order so the env is reconstructed before sendInput fires
|
||
// the active one. Errors mid-replay are swallowed; restoring
|
||
// from a transcript that defined-on-error is the user's call.
|
||
for (const src of ap.replayInputs) {
|
||
try { await evalInTier(tab.id, ap.tier, src); } catch (e) { /* keep going */ }
|
||
}
|
||
tab.transcript.push({
|
||
input: `; resumed asm tier — replayed ${ap.replayInputs.length} prior inputs`,
|
||
results: [{ tier: ap.tier, output: "#t" }], kind: "ok",
|
||
});
|
||
} else if (ap.blob) {
|
||
// 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-batch") {
|
||
// Worker now batches chunks into 4KB/64-newline windows
|
||
// so a tight (display) loop doesn't drown the main
|
||
// thread in postMessage events. Split the batch text
|
||
// back into individual text + eol calls so the live
|
||
// streaming row sees the same shape as before.
|
||
if (!onChunkText && !onChunkEol) return;
|
||
const t = e.data.text;
|
||
let start = 0;
|
||
for (let i = 0; i < t.length; i++) {
|
||
if (t.charCodeAt(i) === 10) {
|
||
if (i > start && onChunkText) onChunkText(t.slice(start, i));
|
||
if (onChunkEol) onChunkEol();
|
||
start = i + 1;
|
||
}
|
||
}
|
||
if (start < t.length && onChunkText) onChunkText(t.slice(start));
|
||
} else 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);
|
||
// Only clear pending if it's still our runId — a
|
||
// cancel-then-resubmit cycle can leave state.pending[k]
|
||
// pointing at a fresh eval before our terminated worker
|
||
// gets around to having its last-queued "done" delivered
|
||
// on the main side.
|
||
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||
delete state.pending[pendingKey];
|
||
}
|
||
resolve(e.data.output);
|
||
} else if (e.data.kind === "error") {
|
||
w.removeEventListener("message", handler);
|
||
if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) {
|
||
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) {
|
||
// Single Text node accumulator — appending to one Text node's
|
||
// nodeValue and letting `white-space: pre` on the parent render
|
||
// newlines is roughly 1000x faster than creating one <div> per
|
||
// line for high-volume streams. 500K display lines used to choke
|
||
// the main thread for ~12s of DOM mutation alone; a single Text
|
||
// node renders the same content with near-zero per-line cost
|
||
// (browsers don't re-layout a pre-wrap text node line-by-line).
|
||
const makeErrLine = (txt) => {
|
||
const d = document.createElement("div");
|
||
d.style.display = "block";
|
||
d.style.whiteSpace = "pre";
|
||
d.className = "err-line";
|
||
d.textContent = txt;
|
||
return d;
|
||
};
|
||
resultSpan.textContent = "";
|
||
resultSpan.style.whiteSpace = "pre";
|
||
const textNode = document.createTextNode("");
|
||
resultSpan.appendChild(textNode);
|
||
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
|
||
let buffered = "";
|
||
let rafPending = false;
|
||
function flush() {
|
||
rafPending = false;
|
||
if (!buffered) return;
|
||
textNode.appendData(buffered);
|
||
buffered = "";
|
||
maybeAutoscroll();
|
||
}
|
||
function schedule() {
|
||
if (rafPending) return;
|
||
rafPending = true;
|
||
requestAnimationFrame(flush);
|
||
}
|
||
return {
|
||
flush, // exposed so finalize can drain synchronously
|
||
appendText(t) {
|
||
if (!t) return;
|
||
buffered += t;
|
||
schedule();
|
||
},
|
||
appendNewline() {
|
||
buffered += "\n";
|
||
schedule();
|
||
},
|
||
finalize(r) {
|
||
// Drain any buffered chunks that haven't been flushed yet
|
||
// so streamedText below sees the most recent content
|
||
// (otherwise the success-with-streaming match check is
|
||
// racy against the last RAF batch).
|
||
if (rafPending) flush();
|
||
const text = r.output != null ? r.output : "";
|
||
const errText = r.error ? "error: " + r.error : "";
|
||
// textContent here is the live Text node's accumulated
|
||
// stream (the per-line-div era used Array.from(children);
|
||
// switched to a single Text node for high-volume streaming
|
||
// perf, so children is empty now).
|
||
const streamedText = textNode.nodeValue.replace(/\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) {
|
||
// Streamed buffer doesn't match final output —
|
||
// refresh the text node with the canonical value.
|
||
textNode.nodeValue = expected;
|
||
}
|
||
} else if (streamedText) {
|
||
// Append the error AFTER what's already on screen so
|
||
// the user keeps every line they were watching scroll.
|
||
if (!textNode.nodeValue.endsWith("\n")) {
|
||
textNode.appendData("\n");
|
||
}
|
||
resultSpan.appendChild(makeErrLine(errText));
|
||
} else {
|
||
resultSpan.textContent = "";
|
||
resultSpan.appendChild(makeErrLine(errText));
|
||
}
|
||
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();
|
||
}
|
||
|
||
// Manual pause-and-snapshot — the user wants to inspect or save the
|
||
// state of a long-running eval without leaving the tab. Signals the
|
||
// SAB pause flag, waits for the in-flight eval to settle, then runs
|
||
// saveCheckpoint so the snapshot lands in the visible chip strip
|
||
// with a user-chosen name. Workers stay alive so the next input
|
||
// runs immediately.
|
||
async function pauseAndSnapshot() {
|
||
if (!pauseFlagView) {
|
||
alert("pause requires COOP/COEP headers — start with `make serve-repl`, or for production set Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp on the deploy");
|
||
return;
|
||
}
|
||
const tab = activeTab();
|
||
if (!tab) return;
|
||
const tier = tab.tier === "all" ? "c" : tab.tier;
|
||
if (tier === "asm") {
|
||
alert("manual pause not supported on asm tier yet — use cancel + portal-save when the eval is idle");
|
||
return;
|
||
}
|
||
pauseBtn.disabled = true;
|
||
Atomics.store(pauseFlagView, 0, 1);
|
||
await waitForPending(tab.id, tier, 2000);
|
||
Atomics.store(pauseFlagView, 0, 0);
|
||
if (isEvalInFlight(tab)) {
|
||
// The worker didn't yield (stuck in sync XHR, native builtin
|
||
// without a poll site, etc). Bail out cleanly rather than
|
||
// trying to snapshot on a still-busy worker — that would
|
||
// queue portal-snapshot! behind the running eval and lock the
|
||
// UI until the eval finishes on its own.
|
||
alert("pause request timed out — the eval is in code without a yield point. cancel + reboot if you need to stop it.");
|
||
return;
|
||
}
|
||
// Reset activeInput marker so a follow-up tab switch doesn't try
|
||
// to "auto-resume" what the user has now explicitly saved.
|
||
tab.activeInput = null;
|
||
await saveCheckpoint();
|
||
}
|
||
|
||
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];
|
||
}
|
||
}
|
||
// Synchronously re-enable send so the user can submit a new job
|
||
// immediately, without waiting for the cancelled promise chain's
|
||
// microtasks to drain. The .then below also rechecks
|
||
// isEvalInFlight so a follow-up sendInput's button state isn't
|
||
// clobbered when those microtasks eventually fire.
|
||
sendBtn.disabled = false;
|
||
cancelBtn.disabled = true;
|
||
pauseBtn.disabled = true;
|
||
tab.activeInput = null;
|
||
}
|
||
|
||
// ─── 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;
|
||
pauseBtn.disabled = !pauseFlagView;
|
||
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) {
|
||
// Don't clobber the toolbar / activeInput marker if
|
||
// another sendInput started while THIS one was still
|
||
// unwinding. The cancel-then-submit-immediately race
|
||
// used to leave send re-enabled mid-eval because the
|
||
// first run's .then fired after the second's sendInput
|
||
// had already disabled the buttons. Check the live
|
||
// pending state instead of trusting "remaining===0".
|
||
if (!isEvalInFlight(tab)) {
|
||
sendBtn.disabled = false;
|
||
cancelBtn.disabled = true;
|
||
pauseBtn.disabled = true;
|
||
inputEl.focus();
|
||
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);
|
||
pauseBtn.addEventListener("click", pauseAndSnapshot);
|
||
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(); }
|
||
});
|