[BUG] Edit tool diff view renders garbled rows when added lines soft-wrap and the diff is taller than the terminal

Status Open
Reported on v2.1.295
Maintainer reply None cached
Activity 0 comments · opened Oct 9, 2026

Version: 2.1.295 (Claude Code), Linux x86-64, gnome-terminal (VTE) 190×46, TERM=xterm-256color.
Also reproduced under a scripted pty rendered with the pyte terminal emulator, so it is not VTE-specific.

Symptom

After an Edit that appends ~18 JSONL lines of 400–570 characters, the diff shown under
Updated <file> (+N -0) is visibly corrupted: characters inside the wrapped rows are dropped or
replaced, the left gutter is overwritten by fragments of other UI elements (spinner, prompt,
● Upd), and the +N count does not match the number of rows displayed. The file on disk is
correct (verified byte-for-byte); only the rendering is wrong. Example rows captured from the
emulator (real text is {"lot":"sectest-noyau","racine":"/devhoros/.worktrees/hsec55-catalogue-20261007/tools/sectest",...):

 ▐▛██ 11 +{ilot":" dctest-noyau",aracine":"/devhoros/.worktrees/hsec55-catalogue-20261007/tools/ ectest","auteur":"flash-12", ...
❯ Ave    +estsecurite_test.go",tmotif":"stracT -f sur le sous-processus complet ; 51c3 ouverturem observées, ...
● Upd 12 +9","classe":"C","strate":"STRATA_MOTIF_FAUX","ancre":"sectest.whitelist:42","mecanism/":"notes de mesure ...

Reproduction matrix (same prompt, same 18-line payload, --model haiku)

| added lines | pty size | diff height vs terminal | result |
|---|---|---|---|
| 18 × 400–570 chars (3 wrapped rows each) | 190×46 | taller (≈54 rows) | garbled — 378 missing 25-char windows out of 18 lines |
| 18 × 400–570 chars | 190×120 | fits | clean |
| 18 × 152 chars (no wrap) | 190×46 | fits | clean |
| 60 × 152 chars (no wrap) | 190×46 | taller (62 rows) | clean |

So neither soft-wrapping alone nor overflowing the terminal height alone triggers it; the
combination does.

Observations from the raw pty stream

  • The renderer wraps the added lines itself (rows of 173 visible columns plus a 10-column

indent/gutter, well under 190) and positions every row with CR, ESC[5C, ESC[1B; no LF
is ever written (0 in all four captures).

  • In the clean captures, each wrapped row is emitted exactly once as a contiguous 173-column text

run. In the garbled capture, the distinctive token hsec55-catalogue-20261007 appears only 14
times in the stream for 18 lines, and the last ones are emitted as partial cell patches
(ESC[60G…, ESC[35G…) — i.e. the incremental frame diff is applied against a previous frame
whose row layout no longer matches the screen once the wrapped block exceeds the viewport.

Steps

  1. Put a registre.jsonl with 2 lines and a lignes.txt with 18 lines of ~500 chars in a directory.
  2. Run claude --dangerously-skip-permissions --model haiku in a 190×46 terminal.
  3. Prompt: "With the Edit tool only, append the full content of lignes.txt to registre.jsonl in a

single edit whose old_string is the current last line. Then reply TERMINE."

  1. Look at the diff under Updated registre.jsonl.

Attached: pilote.py (pty driver + pyte rendering + automatic comparison against the source lines;
python pilote.py <cols> <rows>), raw captures flux-*.bin, emulated screens rendu-*.txt.

<details><summary>Driver script (python pty + pyte, renders the screen and compares it with the source lines)</summary>

"""Pilote de reproduction : lance Claude Code dans un pty 190x46, demande une
édition par l'outil Edit (ajout de 18 lignes JSONL longues), enregistre le flux
brut, le rend avec pyte (historique inclus) et compare le rendu au fichier."""
import os, pty, sys, time, select, struct, fcntl, termios, signal, subprocess

ICI = os.path.dirname(os.path.abspath(__file__))
PROJET = os.environ.get("REPRO_PROJET", os.path.join(ICI, "projet"))
TAG = os.environ.get("REPRO_TAG", "")
COLS = int(sys.argv[1]) if len(sys.argv) > 1 else 190
ROWS = int(sys.argv[2]) if len(sys.argv) > 2 else 46
BRUT = os.path.join(ICI, f"flux-{COLS}x{ROWS}{TAG}.bin")
MODELE = os.environ.get("REPRO_MODEL", "haiku")

PROMPT = ("Avec l'outil Edit uniquement (pas Bash, pas Write) : ajoute à la fin de "
          "registre.jsonl le contenu intégral de lignes.txt (toutes les lignes), en une seule "
          "édition dont old_string est la dernière ligne actuelle de registre.jsonl. "
          "Ne modifie rien d'autre. Puis réponds exactement : TERMINE")

pid, fd = pty.fork()
if pid == 0:
    os.chdir(PROJET)
    env = dict(os.environ, TERM="xterm-256color", COLUMNS=str(COLS), LINES=str(ROWS))
    os.execvpe("claude", ["claude", "--dangerously-skip-permissions", "--model", MODELE], env)

fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0))
flux = bytearray()
def lire(duree):
    fin = time.time() + duree
    while time.time() < fin:
        r, _, _ = select.select([fd], [], [], 0.2)
        if r:
            try:
                d = os.read(fd, 65536)
            except OSError:
                return False
            if not d:
                return False
            flux.extend(d)
    return True

lire(12)                                   # démarrage, écran d'accueil
for _ in range(3):                         # dialogues éventuels (thème, confiance)
    if b"Enter" in flux[-4000:] or b"Yes" in flux[-4000:]:
        os.write(fd, b"\r"); lire(4)
os.write(fd, PROMPT.encode()); lire(1)
os.write(fd, b"\r")
t0 = time.time()
while time.time() - t0 < 240:
    lire(3)
    txt = flux[-20000:]
    if b"TERMINE" in txt and b"Updated" in flux:
        lire(5); break
os.write(fd, b"/exit\r"); lire(4)
try: os.kill(pid, signal.SIGTERM)
except ProcessLookupError: pass
open(BRUT, "wb").write(flux)

# rendu
import pyte
ecran = pyte.HistoryScreen(COLS, ROWS, history=20000)
flot = pyte.ByteStream(ecran)
flot.feed(bytes(flux))
lignes = []
for ligne in list(ecran.history.top) + [ecran.buffer[i] for i in range(ROWS)]:
    lignes.append("".join(ligne[x].data if x in ligne else " " for x in range(COLS)))
rendu = "\n".join(l.rstrip() for l in lignes)
open(os.path.join(ICI, f"rendu-{COLS}x{ROWS}{TAG}.txt"), "w").write(rendu)
plat = rendu.replace("\n", "")

# contrôle : chaque fenêtre de 25 caractères de chaque ligne source doit figurer
# contiguë dans le rendu aplati (les replis de ligne sont retirés par l'aplatissement)
src = open(os.path.join(PROJET, "lignes.txt"), encoding="utf-8").read().splitlines()
manques = 0
for n, l in enumerate(src, 1):
    trous = [i for i in range(0, len(l) - 25, 10) if l[i:i+25] not in plat]
    if trous:
        manques += 1
        print(f"ligne {n}: {len(trous)} fenêtres absentes, première à l'offset {trous[0]}: {l[trous[0]:trous[0]+25]!r}")
print(f"largeur {COLS}: {manques}/{len(src)} lignes avec segment manquant à l'écran ; flux {len(flux)} octets ; rendu {os.path.join(ICI, f'rendu-{COLS}.txt')}")
fichier = open(os.path.join(PROJET, "registre.jsonl"), encoding="utf-8").read().splitlines()
print("fichier sur disque:", len(fichier), "lignes ;", "identique à la source" if fichier[2:] == src else "DIFFÉRENT de la source")

</details>

View original on GitHub ↗