Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

## [0.3.0] - 2026-05-30
### Added
- **Fit-to-screen scaling** (`placement_cells()`): images larger than the terminal are downscaled to fit, preserving aspect ratio, by passing a `(cols, rows)` box to Kitty via the `c=`/`r=` placement keys. Images that already fit are rendered pixel-for-pixel as before. This also applies to Mermaid diagrams, since `mermaid.py` displays through `show.py`.

### Changed
- `image_rows()` (reserve-only, height capped at `ws_row - 1`, which let oversized images overflow onto following text) is replaced by `placement_cells()`, which returns a concrete fit-to-screen cell box. The vertical fit reserves `DRIFT_MARGIN + 2` rows of headroom so a downscaled image plus its anchor offset stay on one screen.
- Dropped the leading PTY newline in the placement path: with cursor policy `C=1` and the stdout row reservation, it only added an uncommitted top gap that Claude's renderer doesn't account for.

## [0.2.0] - 2026-05-29
### Added
- **Mermaid diagram rendering** (`mermaid.py`): turn a Mermaid definition (flowchart, sequence, state, ER, class, architecture) into an inline PNG. Renders the diagram and hands off to `show.py` for display in one step; reads from a `.mmd` file or stdin (`-`).
Expand All @@ -31,7 +39,8 @@ All notable changes to this project are documented here. The format is based on
- Initial release: display PNG/JPEG images inline in a Claude Code session running in the Kitty terminal, working around the Bash tool's lack of a controlling TTY.
- stdout-based row reservation so Claude's following text does not overlap the rendered image.

[Unreleased]: https://github.com/whallil/claude-kitty-image/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/whallil/claude-kitty-image/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/whallil/claude-kitty-image/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/whallil/claude-kitty-image/compare/v0.1.2...v0.2.0
[0.1.2]: https://github.com/whallil/claude-kitty-image/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/whallil/claude-kitty-image/compare/v0.1.0...v0.1.1
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Rendering is **local-first**: the diagram text never leaves your machine unless
## Known limitations

- **Drift margin is a heuristic.** Space reservation adds a small fixed margin to absorb the offset between where the image anchors and where the reserved rows land. For the skill's normal one-line invocation this is stable; a pathologically long, wrapping command could still clip a row or two.
- **Images taller than the screen** overflow, since reservation is capped at the terminal height. Fit-to-screen downscaling isn't implemented yet.
- **Images larger than the screen** are automatically scaled down to fit (aspect preserved) using Kitty's `c=`/`r=` placement keys, so they no longer overflow off the bottom. Images that already fit are shown at native size. Auto-fit needs the terminal to report pixel geometry (Kitty does); otherwise it falls back to native size.
- **Persistence.** Images stay until Kitty scrolls them out of view, you clear the screen, or you run `--clear`. Claude's redraws don't remove them.

## License
Expand Down
2 changes: 1 addition & 1 deletion plugins/kitty-image/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kitty-image",
"version": "0.2.0",
"version": "0.3.0",
"description": "Display PNG/JPEG images inline in a Claude Code session running in the Kitty terminal, reserving layout space so following text never overlaps the image. Works around the Bash tool's lack of a controlling TTY. Also renders Mermaid workflow/flowchart/sequence diagrams to PNG (local-first via mermaid-cli, optional mermaid.ink fallback).",
"author": {
"name": "Thistle Intelligence, LLC",
Expand Down
15 changes: 12 additions & 3 deletions plugins/kitty-image/skills/kitty-image/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ Do **not** invoke for:

The Bash tool runs in a process with no controlling TTY, which is why `kitty +kitten icat` fails (`OSError: No such device or address: '/dev/tty'`). This skill bypasses that by walking the process tree to find the user's `claude` ancestor, reading its TTY from `/proc/<pid>/stat`, and writing Kitty graphics protocol escape sequences (`\x1b_G...\x1b\\`) directly to that PTY. Kitty intercepts the escapes at the terminal-emulator layer and renders the image into its image plane, on top of the text grid that the claude TUI is drawing. The image persists through claude's normal redraws because it lives on a separate render layer.

**Avoiding text overlap (important).** The image lives on Kitty's graphics layer, which Claude Code's renderer knows nothing about — so by default Claude packs its subsequent text right over the image's rows. Newlines written to the PTY don't help: Claude repaints from its own screen model and discards them. The fix is to reserve real vertical space in the one channel Claude *does* commit — the tool's **stdout**. `show.py` measures the image's row-height (PNG pixel height ÷ the PTY's per-cell pixel height from `TIOCGWINSZ`) and prints that many blank lines to stdout, so Claude reserves genuine rows that scroll and pack with the conversation. Two wrinkles are handled: the image anchors a few rows below the reserved block (it's drawn during the "Running" phase while stdout lays out afterward), so a `DRIFT_MARGIN` over-reserves to absorb the offset; and a trailing caption line (`└─ <filename>`) prevents the blank rows from being trimmed as trailing whitespace.
**Avoiding text overlap (important).** The image lives on Kitty's graphics layer, which Claude Code's renderer knows nothing about — so by default Claude packs its subsequent text right over the image's rows. Newlines written to the PTY don't help: Claude repaints from its own screen model and discards them. The fix is to reserve real vertical space in the one channel Claude *does* commit — the tool's **stdout**. `show.py` computes the image's cell box (`placement_cells()`: PNG pixel size ÷ the PTY's per-cell pixel size from `TIOCGWINSZ`) and prints that many blank lines to stdout, so Claude reserves genuine rows that scroll and pack with the conversation. Two wrinkles are handled: the image anchors a few rows below the reserved block (it's drawn during the "Running" phase while stdout lays out afterward), so a `DRIFT_MARGIN` over-reserves to absorb the offset; and a trailing caption line (`└─ <filename>`) prevents the blank rows from being trimmed as trailing whitespace.

**Fit-to-screen (oversized images).** `placement_cells()` returns a concrete `(cols, rows)` box, which `show.py` passes as the Kitty `c=`/`r=` placement keys so Kitty scales the image into that box. If the native size already fits the viewport it's returned unchanged (pixel-for-pixel); if it would overflow, both dimensions are scaled down by a single factor so the aspect ratio is preserved. The box leaves one column of horizontal headroom (no wrap) and `DRIFT_MARGIN + 2` rows of vertical headroom, so a downscaled image plus its anchor offset still fit on one screen rather than clipping at the bottom.

## Workflow

Expand All @@ -57,7 +59,8 @@ The Bash tool runs in a process with no controlling TTY, which is why `kitty +ki
## Caveats to communicate to the user

- **Text overlap is handled, with a margin.** The skill reserves vertical space sized to the image (see "Avoiding text overlap" above), so following text lands below the image rather than on top of it. The reservation includes a fixed `DRIFT_MARGIN` (currently 6 rows) to absorb the offset between the image anchor and the stdout block. This is a heuristic tuned to the skill's normal one-line invocation — if the command echo is unusually tall (e.g. a very long image path that wraps), the drift can grow and a couple of rows of overlap may reappear.
- **Images taller than the screen.** `image_rows()` caps reservation at `ws_row - 1`, so an image taller than the terminal can't be fully reserved and will overflow onto following text. For oversized images, fit-to-screen downscaling via the Kitty `r=` placement key would be needed (not yet implemented).
- **Vertical placement is approximate.** Height (row count) is matched exactly, but the image's *start row* can drift by a few rows: it's painted at the PTY cursor during the "Running" phase while Claude Code lays out the committed stdout block independently, and the exact offset between the two is scroll/timing-dependent and partly controlled by Claude's renderer. Expect a small, usually-harmless top or bottom gap; it is not pixel-stable.
- **Images taller than the screen are scaled to fit.** `placement_cells()` downscales an oversized image (preserving aspect) to `ws_col - 1` × `ws_row - 2 - DRIFT_MARGIN` cells and hands Kitty the `c=`/`r=` keys to do the resampling, so tall or huge images no longer overflow onto following text. Trade-off: a very tall image is shrunk to fit the current window height, so a shorter terminal yields a smaller image — resize the window taller (or open the file externally) if you need more detail.
- **Persistence.** The image stays on screen until Kitty scrolls it out of viewport, the user clears the screen, or `--clear` is invoked. Claude Code redraws do NOT remove it.
- **Kitty-only.** This trick exploits the Kitty Graphics Protocol. It will not work in xterm, gnome-terminal, alacritty, or any terminal that doesn't speak that protocol. The skill checks `TERM=xterm-kitty` and refuses if it doesn't match.
- **Other Kitty-protocol terminals** (Ghostty, iTerm2, Konsole, Warp, WezTerm) *may* work if the user overrides with `--pts /dev/pts/N` and bypasses the TERM check, but this is untested.
Expand Down Expand Up @@ -118,7 +121,13 @@ For **workflows, flowcharts, sequence diagrams, state machines, ER/class diagram

You can also pipe a diagram in: `... | mermaid.py -`.

### Local-first, with an explicit remote opt-in (privacy)
### Keep inline diagrams compact and legible

A diagram is displayed *inline in the terminal*, so it is subject to fit-to-screen scaling. A tall diagram (many nodes top-to-bottom) gets downscaled hard to fit the viewport height, and the dark theme's thin light strokes can wash out at that reduction — it reads as a near-blank dark rectangle even though the PNG is fine. To keep diagrams sharp:

- **Prefer wide-and-short over tall-and-narrow.** Use `graph LR` (left→right) instead of `graph TD` when the flow is mostly linear, and keep it to a handful of nodes. A diagram that fits the viewport at native size needs no downscaling and stays crisp.
- **Split big flows** into a couple of smaller diagrams rather than one giant chart.
- If a tall diagram is unavoidable and looks washed out, tell the user it was downscaled and offer to render it `--no-show` to a PNG they can open at full size.

The diagram text is the payload — a workflow can encode internal architecture — so rendering is **local by default and never silently leaves the machine**:

Expand Down
85 changes: 57 additions & 28 deletions plugins/kitty-image/skills/kitty-image/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
# transcoded to PNG via Pillow in ensure_png_bytes().
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"

# The image is painted to the PTY during the Bash "Running" phase, so it anchors
# a few rows BELOW the top of the stdout reservation (the gap is ~the height of
# the command-echo header). We over-reserve by DRIFT_MARGIN rows to absorb that
# offset, and fit-to-screen subtracts the same margin so a downscaled image plus
# its anchor offset still fit on one screen (no clipped bottom).
DRIFT_MARGIN = 6


def ensure_png_bytes(data: bytes, source_path: Path) -> bytes:
"""Return PNG bytes. Fast-path PNG; transcode anything else via Pillow.
Expand Down Expand Up @@ -132,48 +139,74 @@ def png_dimensions(png_bytes: bytes) -> tuple[int, int]:
return (width, height)


def image_rows(tty_fd: int, png_bytes: bytes) -> int:
"""How many text rows the image occupies, so we can reserve that space.
def placement_cells(tty_fd: int, png_bytes: bytes) -> tuple[int, int]:
"""Return (cols, rows): the cell box the image should be displayed over.

Queries the PTY geometry (TIOCGWINSZ gives total rows + pixel size, hence
the per-cell pixel height) and divides the PNG's pixel height by it. Rounds
up so we never under-reserve (a sub-cell gap is harmless; overlap is the bug
we're fixing). Capped to one less than the screen height. Returns 1 if the
geometry can't be determined (e.g. terminal reports 0 pixels).
Native size is the PNG's pixel dimensions divided by the terminal's per-cell
pixel size (TIOCGWINSZ). If that fits the viewport, it's returned unchanged
so the image renders pixel-for-pixel. If it would overflow, both dimensions
are scaled down by a single factor so the aspect ratio is preserved (fit to
screen). These values are passed as the c=/r= placement keys, so Kitty does
the actual downscaling, and `rows` is also what the caller reserves on
stdout. Returns (1, 1) if the geometry can't be determined.
"""
_, img_h = png_dimensions(png_bytes)
if img_h <= 0:
return 1
img_w, img_h = png_dimensions(png_bytes)
if img_w <= 0 or img_h <= 0:
return (1, 1)
try:
packed = fcntl.ioctl(tty_fd, termios.TIOCGWINSZ, b"\x00" * 8)
ws_row, _ws_col, _ws_xpixel, ws_ypixel = struct.unpack("HHHH", packed)
ws_row, ws_col, ws_xpixel, ws_ypixel = struct.unpack("HHHH", packed)
except OSError:
return 1
return (1, 1)
if ws_row <= 0 or ws_ypixel <= 0:
return 1
return (1, 1)
cell_h = ws_ypixel / ws_row
rows = math.ceil(img_h / cell_h)
return max(1, min(rows, ws_row - 1))
# Prefer real horizontal geometry; fall back to a typical 1:2 cell aspect
# ratio if the terminal doesn't report x-pixels.
cell_w = (ws_xpixel / ws_col) if (ws_col > 0 and ws_xpixel > 0) else (cell_h / 2.0)

# Round native cell size UP so we never clip a partial row/column.
nat_c = max(1, math.ceil(img_w / cell_w))
nat_r = max(1, math.ceil(img_h / cell_h))

# Headroom: one column shy of the edge (avoid wrap); and leave room at the
# bottom for the caption line + prompt (2) plus the drift offset the image
# picks up from anchoring below the reservation (DRIFT_MARGIN), so a
# downscaled image fits fully on screen rather than clipping at the bottom.
avail_c = max(1, (ws_col - 1) if ws_col > 0 else nat_c)
avail_r = max(1, ws_row - 2 - DRIFT_MARGIN)

if nat_c <= avail_c and nat_r <= avail_r:
return (nat_c, nat_r)

# Overflow: scale both dims by one factor so aspect ratio is preserved.
scale = min(avail_c / nat_c, avail_r / nat_r)
fit_c = max(1, min(avail_c, round(nat_c * scale)))
fit_r = max(1, min(avail_r, round(nat_r * scale)))
return (fit_c, fit_r)


def send_kitty_graphics(png_bytes: bytes, pts_path: str) -> tuple[int, int]:
"""Write Kitty graphics protocol escapes to pts_path.

Returns (chunk_count, image_rows). We set cursor policy C=1 so the image
placement does not move the cursor; the caller reserves vertical space via
stdout (see main) rather than via PTY newlines, because directly-injected
placement does not move the cursor, and pass c=/r= so Kitty scales the image
to fit the viewport (see placement_cells). The caller reserves vertical space
via stdout (see main) rather than via PTY newlines, because directly-injected
newlines don't survive Claude Code's independent screen repaint.
"""
b64 = base64.standard_b64encode(png_bytes).decode("ascii")
chunk_size = 4096
chunks = [b64[i : i + chunk_size] for i in range(0, len(b64), chunk_size)]
with open(pts_path, "w") as tty:
rows = image_rows(tty.fileno(), png_bytes)
tty.write("\n") # leading newline so image doesn't collide with cursor row
cols, rows = placement_cells(tty.fileno(), png_bytes)
# No leading newline: C=1 keeps the cursor put, and the stdout block (see
# main) reserves the rows, so a leading PTY newline only adds an
# uncommitted top gap that Claude's renderer doesn't account for.
for i, chunk in enumerate(chunks):
more = 1 if i < len(chunks) - 1 else 0
if i == 0:
tty.write(f"\x1b_Gf=100,a=T,C=1,m={more};{chunk}\x1b\\")
tty.write(f"\x1b_Gf=100,a=T,C=1,c={cols},r={rows},m={more};{chunk}\x1b\\")
else:
tty.write(f"\x1b_Gm={more};{chunk}\x1b\\")
tty.flush()
Expand Down Expand Up @@ -241,14 +274,10 @@ def main() -> int:
# Reserve the image's height as REAL rows in Claude's committed tool-result
# block. stdout is the only channel Claude's renderer accounts for, so blank
# lines here scroll and pack correctly with the rest of the conversation;
# newlines written to the PTY do not.
#
# The image is drawn to the PTY during the "Running" phase, so it anchors a
# few rows BELOW the top of this stdout block (the gap is ~the height of the
# command-echo header). DRIFT_MARGIN over-reserves to absorb that offset so
# the image floats inside the blank block instead of spilling onto the text
# that follows. The trailing marker keeps the rows from being trimmed.
DRIFT_MARGIN = 6
# newlines written to the PTY do not. DRIFT_MARGIN (module-level) over-reserves
# to absorb the anchor offset so the image floats inside the blank block
# instead of spilling onto the text that follows; the trailing caption marker
# keeps the rows from being trimmed as trailing whitespace.
reserved = rows + DRIFT_MARGIN
# Strip non-printable characters from the filename before echoing it, so a
# name containing terminal control/escape bytes can't smuggle anything into
Expand Down
Loading