repl: preserve streamed output on cancel + mid-stream tier errors

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.
This commit is contained in:
russell@unturf.com 2026-06-15 08:33:34 -04:00
parent 2b46c1b79e
commit 84bd7cac8c
No known key found for this signature in database
6 changed files with 108 additions and 36 deletions

View file

@ -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 ───────────────────────────────────────────────── */

View file

@ -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`;
},

View file

@ -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 ───────────────────────────────────────────────── */

View file

@ -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`;
},

View file

@ -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 ───────────────────────────────────────────────── */

View file

@ -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`;
},