Summary
Any inline image in the transcript causes every row below it to be painted twice into the terminal's native scrollback when the session is re-rendered (omp --resume).
The renderer first paints the whole transcript with a text placeholder ( [Image: image/webp]) and commits the tail to native scrollback. Only afterwards does it transmit the real image over the Kitty graphics protocol and repaint everything from the image position downward. The first copy is already in native scrollback and cannot be erased, so the user sees the tool blocks, the todo card and the entire final assistant message a second time.
Environment
- omp
17.1.5 (installed via bun global, dist/cli.js)
- macOS 25.5.0, arm64 (Apple M3 Pro)
- Ghostty 1.3.2 — but the repro below runs in a raw
pty.fork() with TERM=xterm-256color, replayed through pyte, so it is not Ghostty-specific; it needs only the Kitty graphics path to be taken.
terminal.showImages at its default (true).
Reproduction
Fully self-contained, no private data. ~30 s, one cheap model call.
mkdir -p /tmp/omp-imgdup && cd /tmp/omp-imgdup
# 1. an image big enough that encoding is deferred (a 320x200 PNG is NOT enough)
python3 - <<'PY'
import random, struct, zlib
random.seed(1); w, h = 1080, 1920
raw = b"".join(b"\x00" + bytes(random.getrandbits(8) for _ in range(w*3)) for _ in range(h))
def chunk(t, d):
c = t + d
return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)
open("shot.png", "wb").write(
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6))
+ chunk(b"IEND", b""))
PY
# 2. build a session: read the image, then emit 40 lines of text below it
omp -p --model anthropic/claude-haiku-4.5 --no-title --no-skills --no-rules \
--auto-approve --session-dir ./sess \
"Use the read tool on ./shot.png, then in your final reply print a numbered list from 1 to 40, each line exactly 'line NN: alpha bravo charlie delta echo'. Use no other tool."
# 3. re-render it in a terminal shorter than the transcript
omp --resume ./sess/*.jsonl --no-title --no-tools
Scroll up: line 01 … line 40 each appear twice.
Scripted check (raw PTY 100x30, replayed through pyte.HistoryScreen):
total rendered rows: 58619
'line NN: alpha bravo charlie delta echo' rows: 40 distinct, every one counted exactly 2x
Observed vs expected
|
rendered rows |
line 40 occurrences |
| default |
58619 |
2 |
--config with terminal: {showImages: false} |
1087 |
1 |
Expected: one copy.
Root cause evidence
Byte offsets in the raw PTY stream of a real --resume (176x58) of a session whose only image sits 148 rows before the end:
214866 ' [Image: image/webp]' placeholder painted
245493 last paragraph of the final assistant message — 1st copy
(already committed to native scrollback)
247812 first Kitty graphics escape \x1b_G (195 chunks follow)
1076889 last paragraph of the final assistant message — 2nd copy
The duplicated span is exactly the 148 rows that follow the image placeholder: the Read <file> tool block, a bash tool block, the Todo card, and the whole final assistant message. Deterministic — reproduced on every run, at 100x30 and 176x58.
The session .jsonl itself is clean (each paragraph stored once), and the same transcript with the image record removed renders once at 80x24 / 120x30 / 160x45 / 176x58 / 200x50. The image is the only trigger.
Related but distinct: #5766 (stale Kitty graphics when disabling inline images).
Workaround
# ~/.omp/agent/config.yml
terminal:
showImages: false
Suggested fix direction
The transcript is committed to native scrollback before the image payload is resolved, so the post-image repaint has nothing it can legally rewrite. Either hold the commit boundary above the first unresolved image until its payload is ready, or reserve the placeholder's final row count up front so the later paint is an in-place update rather than a re-emit.
Not verified
- Whether the same duplication occurs during a live turn (image tool result arriving mid-stream, text streaming below it). My live repro attempts hit provider 403s; only the
--resume path is confirmed.
- Non-Kitty image protocols (iTerm2, sixel) were not tested.
Summary
Any inline image in the transcript causes every row below it to be painted twice into the terminal's native scrollback when the session is re-rendered (
omp --resume).The renderer first paints the whole transcript with a text placeholder (
[Image: image/webp]) and commits the tail to native scrollback. Only afterwards does it transmit the real image over the Kitty graphics protocol and repaint everything from the image position downward. The first copy is already in native scrollback and cannot be erased, so the user sees the tool blocks, the todo card and the entire final assistant message a second time.Environment
17.1.5(installed via bun global,dist/cli.js)pty.fork()withTERM=xterm-256color, replayed throughpyte, so it is not Ghostty-specific; it needs only the Kitty graphics path to be taken.terminal.showImagesat its default (true).Reproduction
Fully self-contained, no private data. ~30 s, one cheap model call.
Scroll up:
line 01…line 40each appear twice.Scripted check (raw PTY 100x30, replayed through
pyte.HistoryScreen):Observed vs expected
line 40occurrences--configwithterminal: {showImages: false}Expected: one copy.
Root cause evidence
Byte offsets in the raw PTY stream of a real
--resume(176x58) of a session whose only image sits 148 rows before the end:The duplicated span is exactly the 148 rows that follow the image placeholder: the
Read <file>tool block, abashtool block, theTodocard, and the whole final assistant message. Deterministic — reproduced on every run, at 100x30 and 176x58.The session
.jsonlitself is clean (each paragraph stored once), and the same transcript with the image record removed renders once at 80x24 / 120x30 / 160x45 / 176x58 / 200x50. The image is the only trigger.Related but distinct: #5766 (stale Kitty graphics when disabling inline images).
Workaround
Suggested fix direction
The transcript is committed to native scrollback before the image payload is resolved, so the post-image repaint has nothing it can legally rewrite. Either hold the commit boundary above the first unresolved image until its payload is ready, or reserve the placeholder's final row count up front so the later paint is an in-place update rather than a re-emit.
Not verified
--resumepath is confirmed.