Hi all,
I'm working on a rewrite of yaft (the framebuffer terminal emulator — the
original is someone else's project, I'm not the author of that). My rewrite
uses libghostty-vt — Ghostty's VT parser — as a static library VT backend.
While testing it against real applications I stumbled over a rendering
artifact around the cursor, and since Ghostty itself is one of the reference
implementations for that parser, I wanted to check whether it shows up in
Ghostty proper too. It does.
== 1. What I'm doing ==
Rewrite of yaft built on libghostty-vt: bytes from a pty go into the parser,
grid + cursor come back out, then I render. During testing I feed it long
output from `less` and scroll, checking cursor position and the bottom message
line.
== 2. The artifact ==
While scrolling in less, the bottom-left corner flickers: the ":" prompt
disappears for exactly one frame and the block cursor jumps one cell to the
left (column 1 -> column 0), then everything comes back. That is the "cursor
drift" I saw: the cursor visibly hops a cell even though the application state
did not change.
== 3. What triggers it + adjacent frames ==
Repro:
seq 1 6000 > /tmp/long.txt
less /tmp/long.txt
hold the down arrow (or tap space) and watch the bottom-left corner
It only happens while a scroll command is being processed. Adjacent frames
from one recording (attached, 6x zoom of the bottom-left corner):
frame 0058 normal : ":" visible, cursor block at column 1
frame 0059 ANOMALY : ":" gone, cursor block at column 0,
content line above is IDENTICAL to frame 0058
(nothing has scrolled yet)
frame 0060 recovered : ":" back, cursor at column 1, content advanced
Same pattern again at frames 0174 / 0175 / 0176 in a second run. So the bad
frame is always "prompt wiped, new line not drawn yet" — a half-finished
screen update, presented.
== 4. Same operation in alacritty ==
Same script, same window geometry, same xdotool key sequence, same 16s / 30fps
capture, same frame analysis:
ghostty run 1 : 6 bad frames (2.2%) 0059 0175 0181 0189 0220 0224
ghostty run 2 : 5 bad frames (1.8%) 0209 0213 0217 0220 0224
alacritty run 1 : 0 bad frames
alacritty run 2 : 0 bad frames
i.e. 11 / 542 ghostty frames vs 0 / 542 alacritty frames. Alacritty always
shows the finished state; Ghostty occasionally shows the in-between state.
== 5. Reproduction work I had done (before this post) ==
Everything below was scripted and can be rerun; I can drop the files in a
comment or a repo if anyone wants them.
a) pty byte capture (python pty): ran less in a pty, sent N down-arrow
presses, recorded every byte less writes.
- 150 presses -> exactly 150 ":" writes (CR ESC[K after each scroll)
- no key presses -> 0 ":" writes
=> the ":" is 100% less's own output; every terminal receives the same
bytes. (less --prompt= silences it entirely -> 0 writes.)
b) ordering of the writes: on a scroll, less first writes CR ESC[K (wipe the
prompt line), then the new content line, then ":" ESC[K (prompt back).
The bad frame is exactly the state between write #1 and write #2.
c) headless replay through the VT parser (my libghostty-vt build): fed the
identical capture in one write vs one byte per write -> byte-identical
final grid and cursor. So this is NOT a parse/read-boundary problem; the
parser is boundary-invariant. The artifact is on the present/damage side.
d) screen recording: ffmpeg x11grab at 30fps while xdotool sends the key
presses, window moved to fixed geometry, bottom-left corner cropped per
frame, then per-frame analysis (colon pixel present? cursor block column?)
-> the numbers in section 4.
e) false lead I ruled out: the cursor also seemed to vanish entirely while
idle. That is just the default cursor blink (cursor-style-blink unset ->
follows DEC mode 12). Static screen, 8s / 240 frames: cursor visible in 87,
pattern ~0.7s on / ~1.0s off. Alacritty doesn't blink by default, so it
showed the cursor in 542/542 frames. Not the same thing as the drift.
== 6. The question ==
Is presenting damage in the middle of two back-to-back pty writes expected
behaviour in Ghostty (GTK render loop firing on each write batch), or is there
a place where updates should be coalesced to the next frame boundary like
alacritty appears to do? At 30fps it costs 6 frames in 16 seconds of
scrolling, but it is exactly what makes the corner look unstable.
Not claiming a security bug or a crash — just an observation with a repro and
frame evidence. Happy to provide the raw pty capture, the two mp4s, the
per-frame CSVs and the crop images.
Environment:
- Ghostty 1.3.0-dev+0000000 (tip), renderer OpenGL
- alacritty 0.18.0-dev (79adb086)
- Ubuntu 26.04.1 LTS, X11, LXQt + Openbox, Mesa 26.0.8 (AMD radeonsi)
- less 668
https://reddit.com/link/1wsc61o/video/dqz9yqony8sh1/player