Streamed assistant text re-lays-out on terminal redraws, producing overlapping/duplicated fragments
Summary
When Claude streams a long assistant response in the terminal, the rendered text appears to re-layout multiple times as the terminal repaints, producing overlapping, duplicated, and progressively corrupted fragments. The user has to "slog through" the scrambled output to find what was actually said. The underlying response content from the model is fine — only the on-screen rendering is corrupted.
Environment
- macOS 26.3.1
- Apple Terminal 466
TERM=xterm-256color- Shell: zsh
- Claude Code session running model
claude-opus-4-7(1M context)
Reproduction
Triggers most consistently when:
- The streamed assistant message contains multi-line prose paragraphs (vs. short bullet-style lines).
- The terminal window's width changes mid-stream OR the terminal does its normal repaints during a fast stream.
The user pasted the following as their lived evidence — what they saw on screen while trying to read a single assistant response. The actual response was one coherent block; this is the on-screen result:
On virtual-text + offset-map — answering
since you asked, then confirming we no
longer need them.
The idea was: to support multi-line search
across block boundaries, build an in-memory
string representing the document's text
where each <p> block's text contents are
concatenated, separated by a synthetic \n at
block boundaries. Alongside it, keep a
parallel array recording "character N of
this virtual string corresponds to (DOM text
node X, offset Y)." Then run the regex
against the flat virtual string; for each
match, translate the match's start/end
character indices back to DOM positions via
the parallel array; construct a Range that
may span multiple DOM text nodes; pass to
CSS Custom Highlight for rendering.
Ephemeral construction — built per search,
never written to DOM, no parallel state.
It's no longer needed. With the
paste-handler approach you proposed: the
Find textarea is guaranteed \n-free at all
times → the regex string has no \n → no need
to match across block boundaries → the
existing per-text-node TreeWalker scan
(which is already in the code and was the
s82 design) just works against the new
drawer UI. The search engine code is reused
verbatim. The drawer is purely a UI change.
no longer need them.
The idea was: to support multi-line search across block boundaries, build an
in-memory string representing the document's text where each <p> block's
text contents are concatenated, separated by a synthetic \n at block
boundaries. Alongside it, keep a parallel array recording "character N of
this virtual string corresponds to (DOM text node X, offset Y)." Then run
the regex against the flat virtual string; for each match, translate the
match's start/end character indices back to DOM positions via the parallel
corresponds to (DOM text node X, offset Y)." Then run the regex against the flat
virtual string; for each match, translate the match's start/end character indices
back to DOM positions via the parallel array; construct a Range that may span
multiple DOM text nodes; pass to CSS Custom Highlight for rendering. Ephemeral
construction — built per search, never written to DOM, no parallel state.
It's no longer needed. With the paste-handler approach you proposed: the Find
textarea is guaranteed \n-free at all times → the regex string has no \n → no need
to match across block boundaries → the existing per-text-node TreeWalker scan
(which is already in the code and was the s82 design) just works against the new
drawer UI. The search engine code is reused verbatim. The drawer is purely a UI
change.
---
So the corrected and simpler design:
1. Pipeline tightening (cleanup stage): unify _trim_lines +
_collapse_interior_whitespace into one readable function; add _tabs_to_spaces;
consider _parse_blocks rename for _build_blocks; add fail-loud assertion at
render_transcript_html (no \t, \n, or double-space in output) node X, offset Y)." Then run the regex against the flat
virtual string; for each match, translate the match's
start/end character indices back to DOM positions via the
parallel array; construct a Range that may span multiple DOM
text nodes; pass to CSS Custom Highlight for rendering.
Ephemeral construction — built per search, never written to
DOM, no parallel state.
It's no longer needed. With the paste-handler approach you
proposed: the Find textarea is guaranteed \n-free at all
times → the regex string has no \n → no need to match across
block boundaries → the existing per-text-node TreeWalker scan
(which is already in the code and was the s82 design) just
works against the new drawer UI. The search engine code is
reused verbatim. The drawer is purely a UI change.
---
So the corrected and simpler design:
character indices back to DOM positions via the parallel array; construct a
Range that may span multiple DOM text nodes; pass to CSS Custom Highlight for
rendering. Ephemeral construction — built per search, never written to DOM, no
parallel state.
It's no longer needed. With the paste-handler approach you proposed: the Find
textarea is guaranteed \n-free at all times → the regex string has no \n → no
need to match across block boundaries → the existing per-text-node TreeWalker
textarea is guaranteed \n-free at all times → the regex string has no \n → no need
to match across block boundaries → the existing per-text-node TreeWalker scan (which
is already in the code and was the s82 design) just works against the new drawer
UI. The search engine code is reused verbatim. The drawer is purely a UI change.
---
So the corrected and simpler design:
1. Pipeline tightening (cleanup stage): unify _trim_lines +
_collapse_interior_whitespace into one readable function; add _tabs_to_spaces;
consider _parse_blocks rename for _build_blocks; add fail-loud assertion at
render_transcript_html (no \t, \n, or double-space in output).
1. Pipeline tightening (cleanup stage): unify _trim_lines +
_collapse_interior_whitespace into one readable function; add _tabs_to_spaces;
consider _parse_blocks rename for _build_blocks; add fail-loud assertion at
render_transcript_html (no \t, \n, or double-space in output).
2. Find/Replace drawer: BBEdit-style UI; existing per-text-node scan unchanged; Find
textarea has a paste handler that strips \n from clipboard content (THE fix for the
cross-block-paste UX problem).
3. Stash cleanup: delete _legacy_grid_era_editor.js and _legacy_js_undo.js; remove
the dangling comment reference in edit.html.
No virtual-text, no offset-map, no cross-block search engine. Existing search code
reused.
The same paragraph block appears multiple times at different indent widths — each apparently re-flowed for a different terminal width — overlapping with the previous render rather than replacing it.
Expected behavior
Streamed text should render cleanly with one final layout per logical paragraph. Mid-stream re-layouts (if needed for word-wrap on width changes) should clear the previous render before painting the new one.
Workaround
User has asked me to keep responses shorter and more bulleted to reduce the surface area for the rendering bug. This works, but it imposes a content-shape constraint on the model that shouldn't be needed.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗