repl: stream tier output line-by-line (same pattern as playground)

evalInTier picks up onChunkText + onChunkEol callback parameters
and routes the new typed-event chunks from worker.mjs (already
posting chunk-text + chunk-eol per 25d2765 / 89d4877) to per-tier
streaming sinks. The REPL's own worker.mjs was already the same
build as the playground's (md5-matched) so no worker changes.

sendInput pre-populates entry.results with one placeholder per tier
(output:"", streaming:true), runs the initial renderAll, then walks
the freshly-rendered DOM rows and attaches streaming refs (one per
tier) directly to each tier-result span. Chunks land in per-line
<div display:block> children inside that span — same shape the
playground uses. metaSpan flips to "<tier> · running…" until done.

On each tier's promise resolve, the placeholder gets mutated in
place (NOT push'd a second time) and the corresponding liveRow's
finalize() reconciles the streamed divs against the full output
(rebuilds from canonical text only if they diverge or there was
an error) and stamps the elapsed-ms meta. renderAll is NOT called
between tier completions any more — that was clobbering sibling
tiers still streaming in 'all three' mode.

Net effect: paste (let loop ((i 0)) (display i) (newline) (loop
(+ i 1))) in the REPL, hit run on 'all three', and you see each
tier's count tick up in its own row in real time — not a frozen
panel followed by a wall of output at the end.
This commit is contained in:
russell@unturf.com 2026-06-15 07:09:33 -04:00
parent cdb4fc9715
commit f528d6df43
No known key found for this signature in database
2 changed files with 536 additions and 20 deletions

View file

@ -28,6 +28,9 @@ 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 portalChipsEl = document.getElementById("portal-chips");
// ─── State ──────────────────────────────────────────────────────────
const state = {
@ -174,7 +177,7 @@ function ensureWorker(tabId, tier) {
return state.workers[k];
}
function evalInTier(tabId, tier, src) {
function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
@ -182,7 +185,11 @@ function evalInTier(tabId, tier, src) {
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "done") {
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);
@ -197,6 +204,185 @@ function evalInTier(tabId, 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;
},
appendNewline() {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
},
finalize(r) {
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)
.map((d) => d.textContent === " " ? "" : d.textContent)
.join("\n");
const expected = text.replace(/\n$/, "");
// If the streamed text doesn't match the full eval output
// (e.g. an error fired without intermediate chunks), nuke
// and rebuild from the full text. Otherwise leave the
// streamed divs as-is — they already match.
if (streamedText !== expected || errText) {
resultSpan.textContent = "";
const combined = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, "");
const lines = combined.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},
};
}
// ─── Portal save / resume ───────────────────────────────────────────
// Cooperative checkpoints: the tier exposes (portal-snapshot! NAME) and
// (portal-load! NAME) which write/read /tmp/NAME.portal inside MEMFS.
// JS reaches into MEMFS via the worker bridge, encrypts the blob into
// the vault, and on restore reverses the flow. Names are alphanumeric;
// the timestamp format we generate ourselves never embeds slashes.
function safePortalName(s) {
return (s || "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40) || "snap";
}
function bridgeWorker(w, msg, matchKind) {
return new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== matchKind || e.data.runId !== msg.runId) return;
w.removeEventListener("message", handler);
resolve(e.data);
};
w.addEventListener("message", handler);
w.postMessage(msg);
});
}
// Tier-specific save strategies:
// * c, python — call the tier's (portal-snapshot! NAME) so portal.c /
// lumbda.py's portal_save dumps env+RNG to /tmp/<name>.portal JSON,
// then JS reads the blob out of MEMFS. Faithful reproduction.
// * asm-wat — no portal subsystem in the tier yet (it'd need a
// Cheney-aware serializer). Instead snapshot the transcript and
// replay on restore. Works for the REPL's actual workflow of
// successive defines because every closure rebuilds from source.
async function saveCheckpoint() {
const tab = activeTab();
if (!tab) return;
const tier = tab.tier === "all" ? "c" : tab.tier;
const ts = new Date();
const defaultName = `snap-${ts.getHours().toString().padStart(2, "0")}` +
`${ts.getMinutes().toString().padStart(2, "0")}` +
`${ts.getSeconds().toString().padStart(2, "0")}`;
const name = safePortalName(prompt("checkpoint name:", defaultName) || "");
if (!name) return;
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();
}
async function restoreCheckpoint(name) {
const tab = activeTab();
if (!tab) return;
const cp = 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 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]) {
@ -287,6 +473,28 @@ function renderAll() {
}
transcriptEl.appendChild(block);
}
// Portal chips — one per saved checkpoint in the active tab.
portalChipsEl.innerHTML = "";
const checkpoints = tab.checkpoints || {};
const names = Object.keys(checkpoints).sort();
for (const name of names) {
const chip = document.createElement("span");
chip.className = "chip";
chip.title = `${name} · ${checkpoints[name].tier} · saved ${new Date(checkpoints[name].savedAt).toLocaleString()} — click to restore`;
const label = document.createElement("span");
label.textContent = name;
label.addEventListener("click", () => restoreCheckpoint(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(close);
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
@ -355,7 +563,18 @@ async function sendInput() {
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
const entry = { input: src, results: [], kind: "ok" };
// 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 = "";
resetHistory();
@ -364,20 +583,58 @@ async function sendInput() {
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) =>
evalInTier(tab.id, t, src)
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] })));
// Append results as they arrive so user sees them in race order.
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] }));
});
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
entry.results.push(r);
if (r.error) entry.kind = "error";
renderAll();
// 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) {
@ -448,6 +705,7 @@ clearLogBtn.addEventListener("click", () => {
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
portalSaveBtn.addEventListener("click", saveCheckpoint);
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);

View file

@ -28,6 +28,9 @@ 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 portalChipsEl = document.getElementById("portal-chips");
// ─── State ──────────────────────────────────────────────────────────
const state = {
@ -174,7 +177,7 @@ function ensureWorker(tabId, tier) {
return state.workers[k];
}
function evalInTier(tabId, tier, src) {
function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
@ -182,7 +185,11 @@ function evalInTier(tabId, tier, src) {
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "done") {
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);
@ -197,6 +204,185 @@ function evalInTier(tabId, 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;
},
appendNewline() {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
},
finalize(r) {
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)
.map((d) => d.textContent === " " ? "" : d.textContent)
.join("\n");
const expected = text.replace(/\n$/, "");
// If the streamed text doesn't match the full eval output
// (e.g. an error fired without intermediate chunks), nuke
// and rebuild from the full text. Otherwise leave the
// streamed divs as-is — they already match.
if (streamedText !== expected || errText) {
resultSpan.textContent = "";
const combined = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, "");
const lines = combined.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},
};
}
// ─── Portal save / resume ───────────────────────────────────────────
// Cooperative checkpoints: the tier exposes (portal-snapshot! NAME) and
// (portal-load! NAME) which write/read /tmp/NAME.portal inside MEMFS.
// JS reaches into MEMFS via the worker bridge, encrypts the blob into
// the vault, and on restore reverses the flow. Names are alphanumeric;
// the timestamp format we generate ourselves never embeds slashes.
function safePortalName(s) {
return (s || "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40) || "snap";
}
function bridgeWorker(w, msg, matchKind) {
return new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== matchKind || e.data.runId !== msg.runId) return;
w.removeEventListener("message", handler);
resolve(e.data);
};
w.addEventListener("message", handler);
w.postMessage(msg);
});
}
// Tier-specific save strategies:
// * c, python — call the tier's (portal-snapshot! NAME) so portal.c /
// lumbda.py's portal_save dumps env+RNG to /tmp/<name>.portal JSON,
// then JS reads the blob out of MEMFS. Faithful reproduction.
// * asm-wat — no portal subsystem in the tier yet (it'd need a
// Cheney-aware serializer). Instead snapshot the transcript and
// replay on restore. Works for the REPL's actual workflow of
// successive defines because every closure rebuilds from source.
async function saveCheckpoint() {
const tab = activeTab();
if (!tab) return;
const tier = tab.tier === "all" ? "c" : tab.tier;
const ts = new Date();
const defaultName = `snap-${ts.getHours().toString().padStart(2, "0")}` +
`${ts.getMinutes().toString().padStart(2, "0")}` +
`${ts.getSeconds().toString().padStart(2, "0")}`;
const name = safePortalName(prompt("checkpoint name:", defaultName) || "");
if (!name) return;
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();
}
async function restoreCheckpoint(name) {
const tab = activeTab();
if (!tab) return;
const cp = 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 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]) {
@ -287,6 +473,28 @@ function renderAll() {
}
transcriptEl.appendChild(block);
}
// Portal chips — one per saved checkpoint in the active tab.
portalChipsEl.innerHTML = "";
const checkpoints = tab.checkpoints || {};
const names = Object.keys(checkpoints).sort();
for (const name of names) {
const chip = document.createElement("span");
chip.className = "chip";
chip.title = `${name} · ${checkpoints[name].tier} · saved ${new Date(checkpoints[name].savedAt).toLocaleString()} — click to restore`;
const label = document.createElement("span");
label.textContent = name;
label.addEventListener("click", () => restoreCheckpoint(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(close);
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
@ -355,7 +563,18 @@ async function sendInput() {
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
const entry = { input: src, results: [], kind: "ok" };
// 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 = "";
resetHistory();
@ -364,20 +583,58 @@ async function sendInput() {
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) =>
evalInTier(tab.id, t, src)
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] })));
// Append results as they arrive so user sees them in race order.
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] }));
});
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
entry.results.push(r);
if (r.error) entry.kind = "error";
renderAll();
// 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) {
@ -448,6 +705,7 @@ clearLogBtn.addEventListener("click", () => {
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
portalSaveBtn.addEventListener("click", saveCheckpoint);
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);