From 84bd7cac8c0c15e3ef6cd99af8bff199e4af66bf Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 08:33:34 -0400 Subject: [PATCH] repl: preserve streamed output on cancel + mid-stream tier errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attachStreaming.finalize() used to nuke every streamed line and rebuild from the eval's final text whenever the streamed buffer didn't match — which always happened on cancel (no final text) and on tier errors fired mid-stream (asm 'index out of bounds' 5000 lines into a sieve, say). The user watched output scroll for 30 seconds, hit cancel, and saw it all disappear replaced by 'error: cancelled'. New finalize logic: * success + streamed matches full output → keep DOM * success + no streaming happened → rebuild from text * error + prior streaming → KEEP streamed divs, append the error as a single trailing line * error + no streaming → show error only Trailing error line gets an .err-line class so just the error is tinted with --err, the preserved output stays the original colour. --- wasm/dist-repl/repl.css | 3 +++ wasm/dist-repl/repl.js | 45 ++++++++++++++++++++++++++++++----------- wasm/repl/repl.css | 3 +++ wasm/repl/repl.js | 45 ++++++++++++++++++++++++++++++----------- www/repl/repl.css | 3 +++ www/repl/repl.js | 45 ++++++++++++++++++++++++++++++----------- 6 files changed, 108 insertions(+), 36 deletions(-) diff --git a/wasm/dist-repl/repl.css b/wasm/dist-repl/repl.css index 244bf2f..8935273 100644 --- a/wasm/dist-repl/repl.css +++ b/wasm/dist-repl/repl.css @@ -299,6 +299,9 @@ body.repl { opacity: 0.6; } .transcript .entry.error .tier-result { color: var(--err); } +/* Error line appended after partial streamed output — keeps the + * lines that already showed and tints just the trailing error. */ +.transcript .tier-result .err-line { color: var(--err); } .transcript .entry + .entry { margin-top: 0.4rem; } /* ─── Prompt bar ───────────────────────────────────────────────── */ diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index 0cf89d4..b7214d2 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -256,19 +256,40 @@ function attachStreaming(resultSpan, metaSpan, tier) { .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); + // 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`; }, diff --git a/wasm/repl/repl.css b/wasm/repl/repl.css index 244bf2f..8935273 100644 --- a/wasm/repl/repl.css +++ b/wasm/repl/repl.css @@ -299,6 +299,9 @@ body.repl { opacity: 0.6; } .transcript .entry.error .tier-result { color: var(--err); } +/* Error line appended after partial streamed output — keeps the + * lines that already showed and tints just the trailing error. */ +.transcript .tier-result .err-line { color: var(--err); } .transcript .entry + .entry { margin-top: 0.4rem; } /* ─── Prompt bar ───────────────────────────────────────────────── */ diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 0cf89d4..b7214d2 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -256,19 +256,40 @@ function attachStreaming(resultSpan, metaSpan, tier) { .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); + // 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`; }, diff --git a/www/repl/repl.css b/www/repl/repl.css index 244bf2f..8935273 100644 --- a/www/repl/repl.css +++ b/www/repl/repl.css @@ -299,6 +299,9 @@ body.repl { opacity: 0.6; } .transcript .entry.error .tier-result { color: var(--err); } +/* Error line appended after partial streamed output — keeps the + * lines that already showed and tints just the trailing error. */ +.transcript .tier-result .err-line { color: var(--err); } .transcript .entry + .entry { margin-top: 0.4rem; } /* ─── Prompt bar ───────────────────────────────────────────────── */ diff --git a/www/repl/repl.js b/www/repl/repl.js index 0cf89d4..b7214d2 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -256,19 +256,40 @@ function attachStreaming(resultSpan, metaSpan, tier) { .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); + // 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`; },