diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb82f7..b0cc493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (`-`). @@ -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 diff --git a/README.md b/README.md index ba9e131..2aa8cb1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plugins/kitty-image/.claude-plugin/plugin.json b/plugins/kitty-image/.claude-plugin/plugin.json index d6ee81c..29f81cd 100644 --- a/plugins/kitty-image/.claude-plugin/plugin.json +++ b/plugins/kitty-image/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/kitty-image/skills/kitty-image/SKILL.md b/plugins/kitty-image/skills/kitty-image/SKILL.md index e96e7bf..4a66dff 100644 --- a/plugins/kitty-image/skills/kitty-image/SKILL.md +++ b/plugins/kitty-image/skills/kitty-image/SKILL.md @@ -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//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 (`└─ `) 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 (`└─ `) 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 @@ -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. @@ -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**: diff --git a/plugins/kitty-image/skills/kitty-image/show.py b/plugins/kitty-image/skills/kitty-image/show.py index f257b91..1906207 100755 --- a/plugins/kitty-image/skills/kitty-image/show.py +++ b/plugins/kitty-image/skills/kitty-image/show.py @@ -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. @@ -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() @@ -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