Skip to content

fix: large Kitty direct image transfers over SSH.#360

Merged
3rd merged 3 commits into
3rd:masterfrom
Xyhlon:master
May 13, 2026
Merged

fix: large Kitty direct image transfers over SSH.#360
3rd merged 3 commits into
3rd:masterfrom
Xyhlon:master

Conversation

@Xyhlon

@Xyhlon Xyhlon commented May 8, 2026

Copy link
Copy Markdown
Contributor

First of all this fixes #95 #294 . When running over SSH, the Kitty backend uses direct transfer mode. Large images are split into many base64 chunks and written to the terminal stream. Without passing editor_tty, those chunks go through Neovim's stdout path, where Neovim redraw/control bytes can interleave with the Kitty graphics payload. This corrupts the base64 stream.

I confirmed this with kitty --dump-bytes: failed large transfers contained Neovim escape sequences inside the image payload. Passing editor_tty makes the existing tty write path handle direct transfers and prevents that corruption.

This also increases the direct transfer chunk size from 4096 to 65536 bytes. In my test case, this reduced a large image transfer from 1223 Kitty graphics commands to 77 while preserving a valid decoded payload.

To create the dump:

kitty --dump-bytes /tmp/kitty.bytes --dump-commands sh -lc 'ssh faepmac1'

then create a large image:

magick -size 1600x1000 plasma:fractal /tmp/image-large.png

then open a large image with Neovim

To analyze the dump:

#!/usr/bin/env python3
import base64
import hashlib
import re
import sys
from pathlib import Path

dump_path = Path(sys.argv[1])
candidate_root = Path(sys.argv[2]) if len(sys.argv) > 2 else None

b = dump_path.read_bytes()

seqs = re.findall(rb"\x1b_G(.*?)\x1b\\", b, flags=re.S)

print(f"dump bytes: {len(b)}")
print(f"kitty graphics sequences: {len(seqs)}")

unescaped_g = 0
for m in re.finditer(rb"_G", b):
    i = m.start()
    if i == 0 or b[i - 1] != 0x1B:
        unescaped_g += 1

print(f"unescaped literal _G occurrences: {unescaped_g}")

transfers = []
current = None

for seq in seqs:
    if b";" not in seq:
        continue

    header, payload = seq.split(b";", 1)
    if not payload:
        continue

    # Parse simple k=v header fields.
    params = {}
    for part in header.split(b","):
        if b"=" in part:
            k, v = part.split(b"=", 1)
            params[k] = v

    # First chunk has a full header; continuation chunks are usually just m=1/m=0.
    is_continuation = set(params.keys()).issubset({b"m"})

    if current is None or not is_continuation:
        current = {
            "first_header": header,
            "chunks": [],
        }

    current["chunks"].append(payload)

    if params.get(b"m") == b"0":
        transfers.append(current)
        current = None

if current is not None:
    transfers.append(current)

print(f"data transfers found: {len(transfers)}")

candidate_hashes = {}
if candidate_root and candidate_root.exists():
    for path in candidate_root.rglob("*.png"):
        try:
            data = path.read_bytes()
        except Exception:
            continue
        candidate_hashes[hashlib.sha256(data).hexdigest()] = path

for idx, tr in enumerate(transfers, 1):
    joined = b"".join(tr["chunks"])
    padded = joined + b"=" * ((4 - len(joined) % 4) % 4)

    print()
    print(f"transfer #{idx}")
    print(f"  first header: {tr['first_header'][:200].decode('ascii', 'replace')}")
    print(f"  chunks: {len(tr['chunks'])}")
    print(f"  b64 bytes: {len(joined)}")

    try:
        raw = base64.b64decode(padded, validate=True)
    except Exception as e:
        print(f"  base64 decode: FAILED: {e}")
        continue

    sha = hashlib.sha256(raw).hexdigest()
    print(f"  decoded bytes: {len(raw)}")
    print(f"  sha256: {sha}")

    out = Path(f"/tmp/reconstructed-kitty-transfer-{idx}.png")
    out.write_bytes(raw)
    print(f"  wrote: {out}")

    if sha in candidate_hashes:
        print(f"  matches candidate: {candidate_hashes[sha]}")
    else:
        print("  matches candidate: no")

Test with ssh and ssh+tmux and just tmux.

@Xyhlon Xyhlon changed the title Fix large Kitty direct image transfers over SSH. fix: large Kitty direct image transfers over SSH. May 8, 2026
Xyhlon added a commit to Xyhlon/Nixvim that referenced this pull request May 8, 2026
see 3rd/image.nvim#360 for details.
also enabled window_overlap_clear_enabled such that open images do not
remain when search dialogs are opened.
The issue is only triggered for large images.
@3rd

3rd commented May 12, 2026

Copy link
Copy Markdown
Owner

Hi @Xyhlon, thank you for the PR!
Surprised the 65536 bump worked, Kitty docs say we shouldn't send more than 4096 🤷
https://sw.kovidgoyal.net/kitty/graphics-protocol/#:~:text=chunks%20no%20larger%20than%204096%20bytes

@Xyhlon

Xyhlon commented May 12, 2026

Copy link
Copy Markdown
Contributor Author

Yeah works fine on my machine, but i understand that if they only guarantee this from the official channel you might want to keep it as a default. My suggestion would be an over-writable option so that the users downstream change it if they have performance issues and they get it to work. If that's fine by you i will add this option to the main plugin?

Xyhlon added 3 commits May 14, 2026 02:43
Without passing `editor_tty`, the image chunks go through Neovim's
stdout path, where Neovim redraw/control bytes can interleave with the
Kitty graphics payload. This corrupts the base64 stream.

Signed-off-by: Maximilian Philipp <maxkon2000@gmail.com>
this makes loading images much snappier and allows for Telescope preview
to work smoothly. Also since the fix 3rd#95 freezes the tty io, this
becomes necessary.

Signed-off-by: Maximilian Philipp <maxkon2000@gmail.com>
This commit introduces a new option `kitty_direct_chunk_size` to allow users to control the chunk size used when transmitting image data via the Kitty graphics protocol. Previously, the chunk size was hardcoded to 65536 bytes; now it defaults to 4096 bytes and can be adjusted per user preference for performance tuning.

@3rd 3rd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, thank you!

@3rd 3rd merged commit 44e0712 into 3rd:master May 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Large images inconsistent display using direct mode

2 participants