[BUG] Input line renders one column left: incremental render's leading backspace contradicts absolute cursor parking

Status Open
Reported on v2.1.219
Maintainer reply None cached
Activity 0 comments · opened Jul 25, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

The incremental input-line render begins with a backspace, which assumes the cursor sits one cell past the inverse-video caret. Both render paths park it on the caret. Every typed character lands one column left of where it belongs.

Input renders jammed against the with the block caret sitting inside the text rather than after it. Deletion does not clean up: the erase path repositions absolutely and clears only rightward, so a character stranded to the left survives. Erase everything and the line still reads ❯T. Typing NewText into that state produces NNewText.

In terminals that do not answer the XTVERSION query, the failure becomes silent data divergence. Keystrokes overwrite each other, so the buffer that gets submitted differs from the text on screen, with no indication in the UI.

The input buffer itself stays correct. Only the render is wrong, so a full repaint (Ctrl+L, resize, restart) redraws the line correctly until the next keystroke. Recovery therefore requires knowing one of those, and nothing about the symptom points to them.

What Should Happen?

Typed characters should land at the caret position.

With the prompt occupying columns 0 and 1 and the input area starting at column 2, typing D into an empty prompt should render ❯ D with D at column 2 and the caret at column 3. Typing E should render ❯ DE. Deleting all input should return the line to with no residue.

Observed instead: ❯D, then ❯ E with the D overwritten, and ❯T left behind after deleting everything.

Error Messages/Logs

Byte traces captured by hooking the terminal write and input paths, with per-cell attribute
dumps. "<<" is Claude Code to terminal, JSON-escaped. Columns are 0-based. fg0 bg7 marks the
inverse-video caret cell.

Idle after a full repaint, empty input:

  col    0      1      2
         ❯     ' '    ' '   <- fg0 bg7 (caret)
  cursor col 2

The erase path places the caret with an absolute CSI 102;3 H (0-based col 2), so col 2 is the
input start.

Typing D:

  << "\x0f"
  << "\bD\x1b[30m\x1b[47m \b"

Backspace col2 to col1, D written at col1, caret at col2, backspace back to col2:

  col    0      1      2
         ❯      D     ' '   <- caret
  cursor col 2

D belongs at col 2. That sequence is only correct starting from col 3.

Ctrl+L repaints correctly and ends with CSI 103;4 H (0-based col 3):

  col    0      1      2      3
         ❯     ' '     D    ' '   <- caret
  cursor col 3

The cursor parks on the caret, not past it.

Typing E from that verified state:

  << "\bE\x1b[30m\x1b[47m \b"

E overwrites D. A further Ctrl+L renders the line with both characters, so the buffer held DE
throughout.

Erasing back to empty:

  << "\x1b[m\x0f"
  << "\x1b[25l\x1b[30m\x1b[47m\x1b[102;3H \x1b[m\x1b[K\b\x1b[?25h"

CSI 102;3 H jumps to col 2 and CSI K clears rightward. The character stranded at col 1 is never
touched and survives every subsequent erase.

Steps to Reproduce

  1. Run claude in a terminal that does not answer the XTVERSION query (CSI > 0 q). Any xterm.js-based terminal before v7 qualifies; this was captured in Wave Terminal 0.14.5, which bundles xterm.js 6.0.0.
  2. Press Ctrl+L to force a full repaint. This guarantees a known-good starting state and rules out any prior drift.
  3. Type a single character, for example D.

Expected ❯ D. Actual ❯D.

  1. Type a second character, for example E.

Expected ❯ DE. Actual ❯ E. The first character is overwritten.

  1. Press backspace until the input is empty.

Expected . Actual leaves a stray character that cannot be deleted.

Does not reproduce in WezTerm, which answers XTVERSION and appears on the kitty terminal allow-list. Verified there with wezterm cli send-text and wezterm cli get-text.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.219 (Claude Code)

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

Other

Additional Information

Environment detail

  • Wave Terminal 0.14.5 (Electron 41.1.0 / Chrome 146 / xterm.js 6.0.0)
  • PowerShell 7.7.0-preview.1
  • Windows 10 Pro 19045
  • terminal size 225x106.

Root cause

| path | ends with | leaves cursor |
|:---|:---|:---|
| full repaint | CSI r;c H at the caret column | on the caret |
| incremental keystroke | trailing backspace after the caret | on the caret |
| incremental keystroke | leading backspace | assumes one past the caret |

Both render paths park on the caret, so the leading backspace is always one column too far.

XTVERSION dependency

XTVERSION is sent unconditionally at startup, paired with a response matcher (#34014):

{ request: pK(">0q"), match: (H) => H.type === "xtversion" }

With no reply, the second and later keystrokes also carry the leading backspace and overwrite:

| | 2nd keystroke | result |
|---|:---|:---|
| unanswered | \b E <caret> \b | ❯ E, D lost |
| answered | CSI m E <caret> \b | ❯DE, shifted but intact |

Answering removes the leading backspace from later keystrokes but not from the first. Answered runs also emit CSI ? 2026 h/l; unanswered runs never do, matching the gate in #75218.

Confirmed by registering a CSI > q handler in the terminal at runtime and restarting Claude: the character loss stops, the column shift remains.

xterm.js added XTVERSION in v7 (xtermjs/xterm.js#5502, merged 2025-12-26). Terminals on v6 or earlier do not answer it.

Terminal dependence

WezTerm renders correctly. Cursor parking uses absolute CSI r;c H, which any conformant terminal honours identically, so the emitted sequences must differ per terminal.

Ruled out as the gate:

  • XTVERSION identity string. Replying as WaveTerm(0.14.5) and as WezTerm 20240203-110809-5046fc22 behave identically, and neither fixes the column shift.
  • TERM_PROGRAM. Spoofing WezTerm adds kitty keyboard (CSI < u, CSI > 1 u), CSI > 4;2 m and CSI ? 2026 h/l, but the render is unchanged.
  • Focus. Identical focused and unfocused. Focus-in (CSI I) produces no output at all.

The likeliest remaining gate is the kitty allow-list from #71700 (["iTerm.app", "kitty", "WezTerm", "ghostty"], resolved from TERM_PROGRAM, the XTVERSION reply, KITTY_WINDOW_ID and TERM). WezTerm is listed, Wave is not. Spoofing TERM_PROGRAM puts Claude into kitty mode without xterm.js honouring the push, so that test does not settle it.

Related issues

  • #75218 (fixed): unanswered XTVERSION through ConPTY skipped DECRQM(2026), gated DECSTBM, and the status line never rendered. Regression in 2.1.202, last good 2.1.201. Same chain, different surface.
  • #71700 (open): kitty protocol gated on terminal name instead of the CSI ? u capability.
  • #34014 (not planned): VTE below 0.68 echoes CSI > 0 q as visible text.
  • #62423, #70435: related identity-gated and unechoed-input reports.

Separate minor issue

Cursor hide uses CSI 25 l with no ?; show uses CSI ? 25 h. DECTCEM is CSI ? 25 l, so the hide is silently ignored by conforming terminals. Filed here only as context; happy to split it out if preferred.

Trigger note

Originally observed as a minimize/restore bug in Wave Terminal. It does not reproduce that way: it fires on the first keystroke after any full repaint, focused, never minimized. The presentation makes users attribute it to their terminal rather than to Claude Code.

View original on GitHub ↗