Skip to content

Terminal image passthrough

(feature: terminal-images)

Programs running inside a Terminal pane can draw pictures. TerminalScreen reads the Kitty graphics protocol out of the PTY byte stream, decodes it, and the renderer paints the result over the pane's text.

toml
tui-lipan = { version = "0.2", features = ["terminal-images"] }
bash
cargo run --example terminal_images --features terminal-images

It does not depend on the host terminal

The child's escapes are never forwarded to the host. They are decoded into pixels and re-encoded through the same path the Image widget uses, so the host renders them with whatever it supports — Kitty, iTerm2, sixel, or half-blocks. A pane in a plain xterm shows pictures.

Decoding rather than forwarding is also what makes the rest work: image ids from two panes cannot collide, a pane that is half scrolled off gets its pixels cropped instead of squashed, and the cell-diff render pipeline is untouched.

Setting the cell size

A terminal deals in cells; a program drawing a picture needs pixels. It learns the conversion from the PTY's TIOCGWINSZ pixel fields or by asking with CSI 14 t. Give both ends the same answer:

rust
use tui_lipan::prelude::*;

let cell = host_cell_size();

let mut screen = TerminalScreen::new(rows, cols, scrollback);
screen.set_cell_size(cell);

let config = TerminalPtyConfig::default().cell_size(cell);

ManagedTerminal does this for you. A raw TerminalScreen defaults to a 10x20 cell, which is a guess: a mismatch shows up as images that overlap the text below them or leave a gap, because the child reserved a different number of rows than the pane drew.

Pass the cell size to later resizes too, with TerminalPty::resize_with_cell_size; plain resize keeps the last one it was given.

Where images live

Two ways an image gets placed

At the cursor (a=T without U=1) is what icat and friends do: the image lands where the cursor is, the cursor moves past it, and the placement is anchored to that scrollback line.

Through placeholder cells (a=T,U=1) is what terminal UI toolkits do, ratatui-image among them: the transmission draws nothing, and the program then writes the placeholder character U+10EEEE into the cells the image should cover, tagging them with the image id (in the cell's foreground colour) and the position inside the image (in combining marks). Those placements are read back off the grid on every snapshot rather than stored, so they scroll, clear, and reflow with the cells holding them, for free. A cell may leave any of that out and inherit it from its left-hand neighbour — including the high byte of the image id — which is what keeps a row of placeholders down to a single escape sequence.

Cursor placements

A cursor placement is anchored to an absolute scrollback line, in the same space as OSC 133 semantic marks. That is what makes it behave like the text it was drawn against:

EventWhat happens
Output scrollsThe image scrolls with it, cropped row by row as it leaves the viewport
Scrolling backIt reappears at the line it was drawn on
A line falls out of scrollbackThe image's remaining rows stay; it goes once its last row is evicted
Alternate screenPlacements made there are dropped when the child leaves it
Column resizeKept, unless the change actually rewraps text — then the anchor stops naming what it named, and placements are dropped
RIS / TerminalScreen::resetEverything is cleared

A placeholder placement needs none of that bookkeeping: it is the text, so it does whatever the cells do, and it is gone the moment they are.

Each placement carries the image_id its transmission used. A renderer must key its encoding on that and not on the pixels alone: a host drawing through Kitty identifies a placement by the id of its encoding, so two placements sharing one encoding are a single placement to it — and two copies of one picture would collapse, the second silently not drawn.

TerminalRenderSnapshot::images carries the placements overlapping the visible rows, back to front by Kitty z-index. Rows and columns are viewport-relative, and a cursor placement's may be negative when the image starts above or to the left of the pane.

Memory

Decoded pixels are capped per screen, 96 MiB by default, and evicted least-recently-used once the cap is passed — placed images included, since leaving old plots on screen must not pin memory. One image larger than the whole budget is kept anyway; that is a budget set too low, not a picture that should silently fail to appear.

rust
screen.set_image_budget(16 * 1024 * 1024);

Payloads are bounded before decoding as well: 32 MiB per transmission, 16384 pixels per axis.

What is supported

KeySupport
a=t, a=T, a=p, a=d, a=qTransmit, transmit-and-display, display, delete, query
t=dDirect transmission, chunked with m=1
f=24, f=32, f=100RGB, RGBA, PNG
o=zzlib-compressed payloads
s, vPixel dimensions for the raw formats
i, I, pImage id, image number, placement id
x, y, w, hSource rectangle to display
c, rExplicit placement size in cells
zStacking order against the text layer
C=1Leave the cursor where it is
q=1, q=2Suppress success reports / all reports
d=a/A, i/I, n/N, c/C, z/Z, p/P, x/X, y/Y
U=1Virtual placements shown through Unicode placeholder cells

Not supported, and answered with the protocol's own ENOTSUPP report so a child that probes first gets a clean answer rather than silence:

  • File and shared-memory transmission (t=f, t=t, t=s). A pane can be attached from a different machine than the one that wrote the file, so a path is meaningless often enough that refusing is more honest than reading it sometimes. Tools that fall back to t=d still work.
  • The protocol's animation frames (a=a, a=f, a=c). A sender that animates by re-transmitting under the same image id — which is what ratatui-image does for GIFs — works regardless: the new pixels replace the old and the placeholders keep pointing at them.
  • Relative placements (parent references).

Sixel input from the child is a separate protocol and is not read.

Known limits

  • Reattaching to a session loses images drawn before the attach. export_replay_bytes is a text replay stream and does not re-emit image payloads.
  • A partly visible image is cropped, not scaled. That is what makes scrolling look right, but it also means an image wider than its pane shows its left part rather than shrinking to fit.
  • Encoding is asynchronous. The first frame after a new image has no pixels in it yet; the encode lands a frame or two later and the pane repaints.

Testing

The half-block encoder is the fallback path, which makes images assertable without a real terminal: render through TestBackend and read the colors back out of capture_frame(). See tests/terminal_images_render.rs.

MPL-2.0