Skip to content

fix(renderer): clear stale wide-char continuation cells safely#876

Closed
aarcamp wants to merge 1 commit intoanomalyco:mainfrom
aarcamp:fix-wide-char-synccell-diff-poison
Closed

fix(renderer): clear stale wide-char continuation cells safely#876
aarcamp wants to merge 1 commit intoanomalyco:mainfrom
aarcamp:fix-wide-char-synccell-diff-poison

Conversation

@aarcamp
Copy link
Copy Markdown
Contributor

@aarcamp aarcamp commented Mar 26, 2026

When a wide grapheme replaces narrow text, syncing the grapheme start cell propagates continuation markers into currentRenderBuffer before the diff loop reaches the continuation cells. The diff then skips those cells and stale terminal content remains visible.

Clear changed continuation cells before syncing the grapheme start, but only on terminals that rely on cursor positioning rather than OSC 66 explicit-width output. Also only reset the active style run when a continuation cell was actually cleared.

Add regression tests for both the stale-continuation case and the explicit-width path so wide graphemes do not get clobbered on terminals with OSC 66 support.

Demo script:

#!/usr/bin/env bun

/**
 * Visual demo of the wide-character bleed-through bug in OpenTUI's
 * CliRenderer diff pass.
 *
 * Uses the real CliRenderer: renders narrow chars, then overwrites
 * with a 2-cell-wide grapheme via the actual diff path.
 *
 * Without the fix → bear nose "ᴥ" bleeds through at cell 2
 * With the fix    → clean overwrite
 *
 * Usage:  bun patches/repro-wide-char-bleed.tsx
 */

import { createCliRenderer } from "@opentui/core"
import { RGBA } from "@opentui/core"

const WHITE  = RGBA.fromHex("#ffffff")
const GRAY   = RGBA.fromHex("#888888")
const DIM    = RGBA.fromHex("#555555")
const YELLOW = RGBA.fromHex("#ffb300")
const RED    = RGBA.fromHex("#ef5350")
const BLACK  = RGBA.fromHex("#000000")

const oldContent = "\u0295\u00b7\u1d25\u00b7\u0294"  // ʕ·ᴥ·ʔ  (5 cells, each 1-wide)
const newContent = " \u26a0\u00b9 "                    // " ⚠¹ "  (⚠ = U+26A0, 2 cells wide)

const renderer = await createCliRenderer({
  testing: false,
  useAlternateScreen: true,
  useMouse: false,
  exitOnCtrlC: false,
  useKittyKeyboard: {
    disambiguate: false, alternateKeys: false, events: false,
    allKeysAsEscapes: false, reportText: false,
  },
})

const buf = renderer.nextRenderBuffer
function render(force = false) {
  // @ts-expect-error — private
  renderer.lib.render(renderer.rendererPtr, force)
}
function t(s: string, x: number, y: number, fg = GRAY) {
  buf.drawText(s, x, y, fg, BLACK)
}

const X = 16
const Y = 5

// ── Render 1: draw narrow chars at the overwrite position ───────────

buf.drawText(oldContent, X, Y, WHITE, BLACK)
render(true)

// ── Render 2: overwrite with wide-char content + draw all labels ────
// Labels are drawn in the SAME render as the overwrite so they survive
// the diff (they're new content -> always output).

t("syncCell wide-char bleed-through demo", 2, 1, WHITE)

t("old:", 2, 3);           buf.drawText(oldContent, X, 3, WHITE, BLACK)
t("5 chars, all 1 cell wide", X + 6, 3, DIM)
t("new:", 2, 4);            buf.drawText(newContent, X, 4, YELLOW, BLACK)
t("\u26a0 (U+26A0) is 2 cells wide", X + 6, 4, DIM)

t("overwrite:", 2, Y)
buf.drawText(newContent, X, Y, YELLOW, BLACK)
t("\u2190 cell 2: without the fix, bear nose \u1d25 bleeds through", X + 6, Y, RED)

t("Before the fix, the renderer outputs \u26a0 at cell 1.  The terminal", 2, Y + 3, DIM)
t("cursor jumps to cell 3 (\u26a0 is 2 cells wide).  syncCell() then", 2, Y + 4, DIM)
t("propagates a continuation marker into currentRenderBuffer[2],", 2, Y + 5, DIM)
t("making it match nextRenderBuffer[2].  The diff skips cell 2 \u2014", 2, Y + 6, DIM)
t("the old character at that position is never cleared.", 2, Y + 7, DIM)

t("Press Ctrl-C to exit.", 2, Y + 9, DIM)

render()

// ── Wait for exit ───────────────────────────────────────────────────

process.stdin.setRawMode?.(true)
process.stdin.resume()
process.stdin.on("data", (data: Buffer) => {
  if (data[0] === 3) { renderer.destroy(); process.exit(0) }
})
await new Promise(() => {})

Output before fix:

  syncCell wide-char bleed-through demo

  old:          ʕ·ᴥ·ʔ 5 chars, all 1 cell wide
  new:           ⚠ ¹  ⚠  (U+26A0) is 2 cells wide
  overwrite:     ⚠ᴥ¹  ← cell 2: without the fix, bear nose ᴥ bleeds through


  Before the fix, the renderer outputs ⚠  at cell 1.  The terminal
  cursor jumps to cell 3 (⚠  is 2 cells wide).  syncCell() then
  propagates a continuation marker into currentRenderBuffer[2],
  making it match nextRenderBuffer[2].  The diff skips cell 2 —
  the old character at that position is never cleared.

  Press Ctrl-C to exit.

Output after fix:

  syncCell wide-char bleed-through demo

  old:          ʕ·ᴥ·ʔ 5 chars, all 1 cell wide
  new:           ⚠ ¹  ⚠  (U+26A0) is 2 cells wide
  overwrite:     ⚠ ¹  ← cell 2: without the fix, bear nose ᴥ bleeds through


  Before the fix, the renderer outputs ⚠  at cell 1.  The terminal
  cursor jumps to cell 3 (⚠  is 2 cells wide).  syncCell() then
  propagates a continuation marker into currentRenderBuffer[2],
  making it match nextRenderBuffer[2].  The diff skips cell 2 —
  the old character at that position is never cleared.

  Press Ctrl-C to exit.

When a wide grapheme replaces narrow text, syncing the grapheme start
cell propagates continuation markers into currentRenderBuffer before the
diff loop reaches the continuation cells. The diff then skips those
cells and stale terminal content remains visible.

Clear changed continuation cells before syncing the grapheme start, but
only on terminals that rely on cursor positioning rather than OSC 66
explicit-width output. Also only reset the active style run when a
continuation cell was actually cleared.

Add regression tests for both the stale-continuation case and the
explicit-width path so wide graphemes do not get clobbered on terminals
with OSC 66 support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@aarcamp aarcamp force-pushed the fix-wide-char-synccell-diff-poison branch from bad5518 to bec70f7 Compare March 26, 2026 11:41
@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 27, 2026

This PR is subsumed by #791 which addresses a broader array of wide-char handling issues.

@aarcamp aarcamp closed this Mar 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant