Ink UI leaves stale placeholder text when TERM_PROGRAM=ghostty and new content is shorter
Bug description
When the input box transitions from a dimmed placeholder/hint (e.g. a resumed session's auto-generated title) to actual typed text, and the typed text is shorter than the placeholder it replaces, the trailing remainder of the placeholder is never erased and stays visible, glued onto the end of the real input.
Example: placeholder Autostart windows at startup (29 chars), typed You're right, that's normal (28 chars) → rendered as:
❯ You're right, that's normalup
(up is the tail of startup bleeding through.)
Earlier in the same session this also showed up mid-conversation, not just on the placeholder→text transition — e.g. typing Reproducing it now over a stale, longer previous render showed as Reproducing it now at startup (at startup again being a stale tail).
Root cause (as far as I could trace it)
Captured the raw PTY bytes for the redraw. Every one of these bottom-row redraws looks like:
\x1b[?2026h\x1b[?25l\x1b[H\r\x1b[2C\x1b[35B\x1b[2mReproducing it now\x1b[22m\x1b[38;1H\x1b[36;3H\x1b[?25h\x1b[?2026l
i.e. begin synchronized-update → hide cursor → home → move to the target row/column → write the new content → move cursor to its resting position → show cursor → end synchronized-update. There is no CSI K (erase-in-line) anywhere in this sequence, or in any other bottom-row redraw I captured across a ~2000-line trace. The renderer appears to assume the terminal's actual cell content for that row already matches what it believes should be there, and only writes the columns that changed — with no defensive clear when the new content is shorter than whatever was previously in that row.
Why TERM_PROGRAM seems to be the trigger
This reproduces reliably under TERM_PROGRAM=ghostty, both:
- in real Ghostty (confirmed independently), and
- under a terminal multiplexer that spoofs
TERM_PROGRAM=ghosttyfor kitty-graphics-capable hosts (unrelated third-party project; not itself running Ghostty).
It does not reproduce in a plain kitty terminal in the same environment (kitty terminal, not spoofed as Ghostty). That suggests the CLI's renderer takes a different code path based on TERM_PROGRAM=ghostty — likely trusting a more aggressive incremental-diff strategy without a defensive erase — and something about that path's assumptions doesn't hold consistently even in real Ghostty.
Environment
- Claude Code: 2.1.235
- OS: Linux (Debian 13/testing, kernel 7.1.7)
TERM_PROGRAM=ghostty,TERM=xterm-256color,COLORTERM=truecolor
Suggested fix direction
Before writing shorter replacement content into a row that previously held longer content (placeholder→text transition, or any diff where the new string is shorter than the old one at that row), emit an erase-in-line (CSI K) rather than relying on the terminal's prior state matching the renderer's internal model exactly.
3 Comments
Two more observations from continued use, both under the same
TERM_PROGRAM=ghosttyenvironment as the original report:1. The corrupted fragment is cached/memoized, not recomputed per render.
A completely separate multi-choice menu (a numbered "select an option" prompt), rendered much later in the same session, showed option
5.asuChataaboutnthis at startup— byte-for-byte the same corrupted fragment (Chat about this at startup) that first appeared in the original corrupted redraw earlier in the session. Since this is a different UI component rendered at a different point in the conversation, this suggests the corrupted output isn't a fresh diffing mistake each time — it's a stale/cached render that never gets invalidated once corrupted, and resurfaces verbatim wherever that cached content is reused.2. The session title also renders as a stray badge overlaid on a horizontal separator line.
While the CLI was in its "thinking" state, a small badge reading
Autostart windows at startup(the session's own auto-generated title — legible and complete, not garbled this time) appeared overlaid on the right end of a horizontal separator/rule between the transcript and the input box. This isn't a UI element we've seen intentionally rendered there in any other (uncorrupted) part of the session — it looks like the same title string leaking into a second, unrelated spot in the layout.Both are consistent with the original theory: once the terminal's actual cell state and the renderer's internal model diverge (never resynced due to the missing defensive erase), stale content can resurface in multiple, seemingly unrelated places for the rest of the session — not just the original input row.
I also have added 2 screenshots, maybe they help
<img width="1295" height="397" alt="Image" src="https://github.com/user-attachments/assets/448ef039-2e62-4d4a-9564-cb343a4113ec" />
Here the commandline is mangled
<img width="1907" height="171" alt="Image" src="https://github.com/user-attachments/assets/503e8017-cfe7-48b8-8011-b12c231aec3e" />
The blue box on the right shouldn't be there
The trace you captured is the fullscreen renderer, which by design sends only the cells that changed between frames and relies on the terminal (or anything sitting between it and Claude Code, such as a multiplexer) applying those positioned writes exactly. When a host doesn't, fragments of earlier output stay on screen, which is what your screenshots show. This is covered in the troubleshooting section, with the switch that repaints every cell on every frame:
https://code.claude.com/docs/en/fullscreen#stale-or-misplaced-text-on-screen
/tui default(classic main-screen renderer) is the other option. If full repaint still leaves stale text in stock Ghostty with nothing in between, that detail would be useful here.🤖 Generated with Claude Code
---
_Generated by Claude Code_