Skip to content

terminal/kitty: reject conflicting image identifiers - #13889

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

mitchellh merged 1 commit into
mainfrom
kitty-i

Conversation

@mitchellh

Copy link
Copy Markdown
Contributor

Reject Kitty graphics commands that specify both an image ID and an image number across every protocol action.

These commands previously produced no wire response for transmissions, while put and delete actions could proceed using one identifier and mutate terminal state.

Retain the original command identifiers, validate them before action dispatch, and preserve them in the EINVAL response. Add regression coverage for every action, response encoding, quiet suppression, and pre-mutation rejection.

https://sw.kovidgoyal.net/kitty/graphics-protocol/#requesting-image-ids-from-the-terminal

Reject Kitty graphics commands that specify both an image ID and an
image number across every protocol action.

These commands previously produced no wire response for transmissions,
while put and delete actions could proceed using one identifier and
mutate terminal state.

Retain the original command identifiers, validate them before action
dispatch, and preserve them in the EINVAL response. Add regression
coverage for every action, response encoding, quiet suppression, and
pre-mutation rejection.

https://sw.kovidgoyal.net/kitty/graphics-protocol/#requesting-image-ids-from-the-terminal
@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 17:07
@mitchellh

Copy link
Copy Markdown
Contributor Author

Verification script:

#!/bin/sh
set -u

# Run this directly inside Kitty and Ghostty. The embedded Python only handles
# raw TTY I/O and timeouts; all protocol commands are shown below as bytes.
exec python3 - <<'PY'
import os
import re
import select
import sys
import termios
import time
import tty

ESC = b"\x1b"
ST = ESC + b"\\"
CPR = re.compile(rb"\x1b\[\??[0-9]+;[0-9]+R")
GRAPHICS_RESPONSE = re.compile(rb"\x1b_G.*?\x1b\\", re.DOTALL)


def command(control, payload=b""):
    separator = b";" if payload else b""
    return ESC + b"_G" + control + separator + payload + ST


def write_all(fd, data):
    while data:
        written = os.write(fd, data)
        data = data[written:]


def exchange(fd, data, timeout=2.0):
    # CPR is processed after the graphics command, making its response a FIFO
    # barrier even when the graphics command itself correctly emits nothing.
    write_all(fd, data + ESC + b"[6n")
    received = b""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        remaining = deadline - time.monotonic()
        readable, _, _ = select.select([fd], [], [], remaining)
        if not readable:
            break
        received += os.read(fd, 4096)
        if CPR.search(received):
            break
    return GRAPHICS_RESPONSE.findall(received), CPR.search(received) is not None


def visible(data):
    return data.replace(ESC, b"<ESC>").decode("ascii", "backslashreplace")


image_id = 4040201
image_number = 4040202
placement_id = 7
expected_prefix = f"i={image_id},I={image_number},p={placement_id};EINVAL".encode()

tests = [
    (
        "transmit",
        command(
            f"a=t,f=24,s=1,v=1,i={image_id},I={image_number},p={placement_id}".encode(),
            b"/wAA",
        ),
    ),
    (
        "put",
        command(
            f"a=p,i={image_id},I={image_number},p={placement_id},C=1".encode()
        ),
    ),
    (
        "delete",
        command(
            f"a=d,d=i,i={image_id},I={image_number},p={placement_id}".encode()
        ),
    ),
]

fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
old_settings = termios.tcgetattr(fd)
results = []
try:
    tty.setraw(fd)

    # Give the put/delete cases an existing image. q=1 suppresses its OK.
    setup = command(
        f"a=t,f=24,s=1,v=1,i={image_id},q=1".encode(),
        b"/wAA",
    )
    _, setup_barrier = exchange(fd, setup)
    if not setup_barrier:
        raise RuntimeError("terminal did not answer the cursor-position barrier")

    for name, data in tests:
        responses, barrier = exchange(fd, data)
        passed = barrier and any(expected_prefix in response for response in responses)
        results.append((name, passed, responses, barrier))

    # Remove any setup image or placement left by either implementation.
    cleanup = command(f"a=d,d=I,i={image_id},q=2".encode())
    exchange(fd, cleanup)
finally:
    termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
    os.close(fd)

terminal = os.environ.get("TERM_PROGRAM", "unknown terminal")
version = os.environ.get("TERM_PROGRAM_VERSION", "unknown version")
print(f"KGP-02 check in {terminal} {version}")
all_passed = True
for name, passed, responses, barrier in results:
    all_passed &= passed
    print(f"{name:8} {'PASS' if passed else 'FAIL'}")
    if responses:
        for response in responses:
            print(f"         {visible(response)}")
    else:
        suffix = "" if barrier else " (cursor barrier also timed out)"
        print(f"         no graphics response{suffix}")

if not all_passed:
    print("Expected each command to return i, I, p, and an EINVAL error.")
sys.exit

@mitchellh
mitchellh merged commit 409e682 into main Aug 18, 2026
194 of 195 checks passed
@mitchellh
mitchellh deleted the kitty-i branch August 18, 2026 17:24
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