Fix stray characters in TUI transcript from wide-grapheme cursor shift#13591
Conversation
|
@warp-dev-github-integration[bot] I'm starting a first review of this pull request. You can view the conversation on Warp. I completed the review and no human review was requested for this pull request. Comment Powered by Oz |
There was a problem hiding this comment.
Overview
This PR changes the TUI frame renderer to split ratatui/crossterm draw batches after wide graphemes and adds a headless regression test for the cursor-shift artifact.
Concerns
- The renderer batching code does not compile because
baseline.diff(buffer)returns aBufferDiffiterator in ratatui 0.30, but the new code calls.len(), indexes it, and slices it. - This is a user-facing rendering change, but the PR description explicitly says no screenshot or recording is attached. For this user-facing change, please include screenshots or a screen recording demonstrating it working end to end.
Verdict
Found: 0 critical, 2 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
| // stray glyphs behind that later partial diffs never repair. Ending the | ||
| // current `draw` batch after each wide grapheme resets the backend's | ||
| // cursor tracking, so the following cell gets a fresh, correct `MoveTo`. | ||
| let diff = baseline.diff(buffer); |
There was a problem hiding this comment.
baseline.diff(buffer) returns a BufferDiff iterator, so the later .len(), indexing, and slicing won't compile; collect it before batching.
| let diff = baseline.diff(buffer); | |
| let diff: Vec<_> = baseline.diff(buffer).collect(); |
There was a problem hiding this comment.
Thanks — this one is actually a false positive. Buffer::diff returns a Vec<(u16, u16, &Cell)> (the lazy BufferDiff iterator is the separate diff_iter method: pub fn diff<'a>(&self, other: &'a Self) -> Vec<(u16, u16, &'a Cell)> in ratatui-core 0.1.1), so .len(), indexing, and slicing all compile — cargo test -p warpui_core --features tui and cargo clippy ... -D warnings are both green. The suggested .collect() would in fact not compile, since diff() already returns a Vec. I've added an explicit let diff: Vec<_> = baseline.diff(buffer); annotation (plus a comment) at the call site to make the type obvious and prevent this confusion.
|
On the visual-proof point: This is exactly what the new regression test |
|
/oz-review |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
The crossterm backend suppresses the cursor MoveTo for a cell one column past the previously written cell, assuming every printed cell advances the cursor by a single column. Width-2 graphemes advance it by two, so the trailing-clear cell the diff emits after a VS16 emoji (and everything after it on the line) was printed one column too far right, leaving stray glyphs that scrolling's partial redraws never repaired. Emit the per-cell diff in batches that never span a wide grapheme so a fresh draw call (and thus a correct MoveTo) starts after each wide grapheme. Adds a headless regression test. Co-Authored-By: Oz <oz-agent@warp.dev>
Address review confusion between Buffer::diff (returns a Vec) and diff_iter (returns the BufferDiff iterator): make the Vec type explicit at the call site. No behavior change. Co-Authored-By: Oz <oz-agent@warp.dev>
b270417 to
1212ace
Compare

Description
Fixes stray/stale characters that appear in the TUI transcript and persist while scrolling a long transcript (reported with floating
gglyphs that were not part of the agent output, e.g. rendered mid-word asinlinegtask).Root cause. The transcript is not fully repainted every frame —⚠️ ⌨️ ✅ ▶️ ), where the diff deliberately emits a trailing-clear cell at
TuiFrameRenderer::draw(crates/warpui_core/src/runtime/renderer.rs) only clears + repaints on the first frame or on a resize; scrolling within a fixed viewport is an incremental cell diff. The proximate defect is in theratatui-crosstermbackend:CrosstermBackend::drawsuppresses the cursorMoveTofor any cell whose column is exactly one past the previously written cell, assuming every printed cell advances the cursor by a single column. A width-2 grapheme advances it by two.Buffer::diffnormally skips a wide grapheme's trailing half, which masks the bug — except for emoji carrying a VS16 variation selector (U+FE0F, e.g.wide_x + 1. That cell lands in the false-contiguity window, so the backend skips the neededMoveToand prints it — and everything after it on the line — one column too far right. Because scrolling only redraws the per-frame diff, the misplaced glyph is never repaired and sticks around. This is why the bug is flaky and content-dependent: it only triggers when the transcript contains VS16 emoji, which are common in agent output.Fix. Emit the per-cell diff in batches that never span a wide grapheme: after a width-2 grapheme, a fresh
backend.draw()call resets the backend's cursor tracking so the following cell gets a correctMoveTo. The change is contained to the renderer, preserves the synchronized-update wrapper and ratatui's style handling, and leaves the width-1 contiguous-run optimization intact.Linked Issue
Reported via the factory-client bug-triage Slack thread (linked below). No GitHub issue.
Testing
runtime::renderer::tests::wide_grapheme_does_not_shift_following_cells, which replays the renderer's byte stream onto a screen buffer and asserts the glyph after a VS16 emoji stays on its intended column. Confirmed it fails before the fix (glyph lands on column 3, next glyph pushed off-screen — the reported symptom) and passes after.cargo test -p warpui_core --features tui --lib runtime::renderer::tests— all green (8 passed), including existing wide-grapheme / diff-optimization tests.cargo fmt -p warpui_core --checkandcargo clippy -p warpui_core --features tui --all-targets -- -D warnings— clean.Screenshots / Videos
Not included:
warp_tuionly supports interactive device-auth and cannot be launched non-interactively in the cloud runner, so the flaky live symptom cannot be driven here. The deterministic headless renderer test (which reproduces the exact column shift at the byte-stream level) is the automated proxy. Final manual sign-off: on a real terminal, a long transcript containing VS16 emoji, scrolled repeatedly, shows no stray/persistent glyphs.Agent Mode
CHANGELOG-BUG-FIX: Fixed stray characters that could appear and linger in the TUI transcript while scrolling.
Reported and triaged from Slack: factory-client thread.
Conversation: https://staging.warp.dev/conversation/844f00e2-a398-4a38-87f3-9607585375db
Run: https://oz.staging.warp.dev/runs/019f4e23-2a19-715d-ae9d-b0f1164be78e
This PR was generated with Oz.