Skip to content

terminal/kitty: fix various graphics deletion mismatches with spec - #13895

Merged
mitchellh merged 1 commit into
mainfrom
kitty-delete
Aug 18, 2026
Merged

mitchellh merged 1 commit into
mainfrom
kitty-delete

Conversation

@mitchellh

Copy link
Copy Markdown
Contributor
  • Limit d=a/A to non-virtual placements that intersect the active screen.
  • Keep unrelated unplaced image data when processing d=A.
  • Make d=R delete matching unused images even when they have no placements, and default an omitted x bound to zero.
  • Give ED2 a separate clear path that preserves scrollback references while reclaiming every unreferenced image.

References:

- Limit d=a/A to non-virtual placements that intersect the active screen.
- Keep unrelated unplaced image data when processing d=A.
- Make d=R delete matching unused images even when they have no
  placements, and default an omitted x bound to zero.
- Give ED2 a separate clear path that preserves scrollback references
  while reclaiming every unreferenced image.

References:
- Spec:
  https://sw.kovidgoyal.net/kitty/graphics-protocol/#deleting-images
- Delete reference implementation:
  https://github.com/kovidgoyal/kitty/blob/0ecb10d158943e971e5254c554c2a58f1fcc79fe/kitty/graphics.c#L2114-L2363
- ED2 reference implementation:
  https://github.com/kovidgoyal/kitty/blob/0ecb10d158943e971e5254c554c2a58f1fcc79fe/kitty/screen.c#L2919-L2958
@mitchellh mitchellh added this to the 1.4.0 milestone Aug 18, 2026
@mitchellh
mitchellh requested a review from a team as a code owner August 18, 2026 18:30
@mitchellh

Copy link
Copy Markdown
Contributor Author

Verification:

#!/bin/sh
set -eu

# Kitty graphics protocol deletion rules:
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#deleting-images
# Pinned Kitty reference implementation:
# https://github.com/kovidgoyal/kitty/blob/0ecb10d158943e971e5254c554c2a58f1fcc79fe/kitty/graphics.c#L2249-L2363
# Pinned Kitty ED2 implementation:
# https://github.com/kovidgoyal/kitty/blob/0ecb10d158943e971e5254c554c2a58f1fcc79fe/kitty/screen.c#L2919-L2958

if ! command -v python3 >/dev/null 2>&1; then
    echo "python3 is required" >&2
    exit 1
fi

if [ -n "${TMUX-}" ]; then
    echo "warning: run this directly in Kitty/Ghostty; tmux may filter graphics replies" >&2
fi

echo "warning: use a disposable terminal tab; this test clears the screen and visible graphics" >&2

python3 - <<'PY'
import base64
import os
import select
import sys
import termios
import time
import tty

ESC = b"\x1b"
ST = ESC + b"\\"
tty_fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
saved = termios.tcgetattr(tty_fd)
results = []


def write(data):
    os.write(tty_fd, data)


def text(value):
    write(value.encode("utf-8"))


def graphics(control, payload=b""):
    write(ESC + b"_G" + control.encode("ascii") + b";" + payload + ST)


def drain():
    while select.select([tty_fd], [], [], 0)[0]:
        os.read(tty_fd, 4096)


def request(control, payload=b"", timeout=2.0):
    drain()
    graphics(control, payload)
    deadline = time.monotonic() + timeout
    response = bytearray()
    while time.monotonic() < deadline:
        ready = select.select([tty_fd], [], [], deadline - time.monotonic())[0]
        if not ready:
            break
        response.extend(os.read(tty_fd, 4096))
        if ST in response:
            break
    return bytes(response)


def fire(control):
    drain()
    graphics(control)
    time.sleep(0.05)
    drain()


def transmit(image_id, rgb):
    payload = base64.b64encode(bytes(rgb))
    response = request(f"a=t,f=24,s=1,v=1,i={image_id}", payload)
    if b";OK" not in response:
        raise RuntimeError(
            f"image transmission {image_id} failed or timed out: {response!r}"
        )


def place(image_id, placement_id):
    return request(
        f"a=p,i={image_id},p={placement_id},c=4,r=1,C=1"
    )


def record(name, passed, response):
    results.append((name, passed, response))
    status = "PASS" if passed else "FAIL"
    # Never write a decoded terminal reply back to the terminal: ESC is valid
    # ASCII, so doing so would execute the reply as a fresh graphics command.
    detail = repr(response) if response else "no response"
    text(f"{status}: {name} ({detail})\r\n")


try:
    tty.setraw(tty_fd)
    terminal = os.environ.get("TERM_PROGRAM", "unknown terminal")
    text(f"KGP-07 conformance checks in {terminal}\r\n\r\n")

    # d=A selects visible placements. An unrelated image with no placement
    # must remain available for a later placement.
    image_id = 7000701
    transmit(image_id, (0, 255, 0))
    fire("a=d,d=A")
    response = place(image_id, 1)
    record("d=A preserves unrelated unplaced image data", b";OK" in response, response)
    fire(f"a=d,d=I,i={image_id}")

    # The uppercase range action names images, so it must free matching data
    # even if the image has no placement.
    # Use the smallest valid client image id so the omitted x bound selects
    # only this id (0..1), rather than sweeping a broad range in the terminal.
    image_id = 1
    transmit(image_id, (255, 0, 0))
    fire(f"a=d,d=R,y={image_id}")
    response = place(image_id, 1)
    record(
        "d=R defaults omitted x to zero and frees unplaced data",
        b"ENOENT" in response,
        response,
    )

    rows = max(os.get_terminal_size(tty_fd).lines, 2)

    # A placement wholly in scrollback is not selected by d=A. Re-placing the
    # same image verifies that the preserved scrollback reference kept its data
    # alive. The original blue placement is also left in scrollback for visual
    # inspection after the checks finish.
    image_id = 7000703
    write(ESC + b"[2J" + ESC + b"[H")
    text("BLUE scrollback marker (d=A)\r\n")
    transmit(image_id, (0, 96, 255))
    response = place(image_id, 1)
    if b";OK" not in response:
        raise RuntimeError(f"initial blue placement failed: {response!r}")
    text("\r\n" * (rows + 1))
    fire("a=d,d=A")
    response = place(image_id, 2)
    record("d=A preserves a wholly scrolled-back placement", b";OK" in response, response)
    fire(f"a=d,d=i,i={image_id},p=2")

    # ED2 likewise clears visible graphics without deleting a placement that
    # has already moved into scrollback. The original magenta placement is
    # retained for visual inspection.
    image_id = 7000704
    write(ESC + b"[2J" + ESC + b"[H")
    text("MAGENTA scrollback marker (ED2)\r\n")
    transmit(image_id, (255, 0, 255))
    response = place(image_id, 1)
    if b";OK" not in response:
        raise RuntimeError(f"initial magenta placement failed: {response!r}")
    text("\r\n" * (rows + 1))
    unplaced_id = 7000705
    transmit(unplaced_id, (255, 255, 0))
    write(ESC + b"[2J" + ESC + b"[H")
    response = place(image_id, 2)
    record("ED2 preserves a wholly scrolled-back placement", b";OK" in response, response)
    fire(f"a=d,d=i,i={image_id},p=2")
    response = place(unplaced_id, 1)
    record("ED2 reclaims unrelated unplaced image data", b"ENOENT" in response, response)
    fire(f"a=d,d=I,i={unplaced_id}")

    passed = sum(1 for _, ok, _ in results if ok)
    text(f"\r\nSummary: {passed}/{len(results)} checks passed.\r\n")
    text("Scroll up: the original BLUE and MAGENTA markers/images should still be present.\r\n")
    text("Run this once in Kitty and once in the Ghostty build under test.\r\n")
    if passed != len(results):
        sys.exit(1)
finally:
    termios.tcsetattr(tty_fd, termios.TCSADRAIN, saved)
    os.close(tty_fd)
PY

@mitchellh
mitchellh merged commit 2ced1e5 into main Aug 18, 2026
35 checks passed
@mitchellh
mitchellh deleted the kitty-delete branch August 18, 2026 18:39
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.

1 participant