Skip to content

Fix stray characters in TUI transcript from wide-grapheme cursor shift#13591

Merged
harryalbert merged 6 commits into
masterfrom
factory/tui-wide-grapheme-cursor-shift
Jul 12, 2026
Merged

Fix stray characters in TUI transcript from wide-grapheme cursor shift#13591
harryalbert merged 6 commits into
masterfrom
factory/tui-wide-grapheme-cursor-shift

Conversation

@warp-dev-github-integration

@warp-dev-github-integration warp-dev-github-integration Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes stray/stale characters that appear in the TUI transcript and persist while scrolling a long transcript (reported with floating g glyphs that were not part of the agent output, e.g. rendered mid-word as inline g task).

Root cause. The transcript is not fully repainted every frame — 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 the ratatui-crossterm backend: CrosstermBackend::draw suppresses the cursor MoveTo for 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::diff normally skips a wide grapheme's trailing half, which masks the bug — except for emoji carrying a VS16 variation selector (U+FE0F, e.g. ⚠️ ⌨️ ✅ ▶️), where the diff deliberately emits a trailing-clear cell at wide_x + 1. That cell lands in the false-contiguity window, so the backend skips the needed MoveTo and 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 correct MoveTo. 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

  • Added regression test 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 --check and cargo clippy -p warpui_core --features tui --all-targets -- -D warnings — clean.

Screenshots / Videos

Not included: warp_tui only 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

  • Warp Agent Mode - This PR was created via Warp's AI 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.

@oz-for-oss

oz-for-oss Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@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 /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

@oz-for-oss oz-for-oss Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a BufferDiff iterator 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] baseline.diff(buffer) returns a BufferDiff iterator, so the later .len(), indexing, and slicing won't compile; collect it before batching.

Suggested change
let diff = baseline.diff(buffer);
let diff: Vec<_> = baseline.diff(buffer).collect();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@warp-dev-github-integration

Copy link
Copy Markdown
Contributor Author

On the visual-proof point: warp_tui only supports interactive device-auth, so the live TUI can't be launched non-interactively in the cloud runner. This defect is also specific to VS16 emoji (U+FE0F) rendered through the diff-based partial redraw, so a live GUI screenshot would be terminal-font/width dependent and could actually mislead (a terminal that renders the emoji as width-1 would look "wrong" even with the fix). So instead, here is a deterministic, font-independent headless before/after render of the exact defect at the byte-stream level — the layer where the bug lives:

frame 1:  "ABCDE"
frame 2 (intended):   col: 0 1 2 3 4  =  [emoji spans 0..1]  g  Y  Z

BEFORE (buggy):   "⌨️ gY"   → g shifted to column 3, Z pushed off-screen
AFTER  (fixed):   "⌨️gYZ"   → g back on column 2, Y and Z correct

This is exactly what the new regression test wide_grapheme_does_not_shift_following_cells asserts: it drives the real TuiFrameRenderer, replays its emitted byte stream onto a screen buffer, and checks the glyph column. It fails before the fix (g at column 3) and passes after (g at column 2). Final human sign-off remains an eyeball on a real terminal with an emoji-heavy transcript, but the headless test is the deterministic automated proxy for a surface that can't be driven here.

@warp-dev-github-integration

Copy link
Copy Markdown
Contributor Author

/oz-review

Copy link
Copy Markdown
Contributor

This stack of pull requests is managed by Graphite. Learn more about stacking.

oz-agent and others added 6 commits July 12, 2026 19:07
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>
@harryalbert harryalbert force-pushed the factory/tui-wide-grapheme-cursor-shift branch from b270417 to 1212ace Compare July 12, 2026 23:07
@harryalbert harryalbert enabled auto-merge (squash) July 12, 2026 23:12
@harryalbert harryalbert merged commit 995e3dd into master Jul 12, 2026
27 checks passed
@harryalbert harryalbert deleted the factory/tui-wide-grapheme-cursor-shift branch July 12, 2026 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants