Project 1: Sixel and the Kitty Graphics Protocol

3–4 weeks · ●●●●○ · touches DCS/APC parsing, image decoding, the cell/pixel boundary, rendering


1. The Problem

Your terminal displays text. It cannot display an image. Programs like timg, chafa, viu, img2sixel, matplotlib's sixel backend, and ranger's previewer all want to put pixels in a cell grid, and today your terminal silently discards their output.

Two competing protocols exist, and supporting either is a real feature.


2. Why It Is Hard

The difficulty is not decoding — it is that images break the cell abstraction your entire architecture is built on.

ProblemWhy it hurts
An image occupies pixels, the grid stores cellsterminal-core is forbidden from knowing about pixels. Where does the image live?
Images must scroll with the textSo they are anchored to grid positions that move
Images can be larger than the screenClipping, and partial visibility during scroll
Payloads are megabytesThe DCS/APC streaming interface exists exactly for this
Erase must delete imagesCSI 2 J has to know about a thing the grid does not contain
Resize changes the cell size in pixelsSo an image's cell footprint changes
The core must stay display-independentIt cannot decode a PNG; it has no GPU and may be running in a mux server

That last row is the design crux, and getting it right is the project.


3. The Design

The boundary decision

   ✗ WRONG: terminal-core stores decoded RGBA pixels.
     It now depends on an image decoder, cannot compile to wasm32 without one,
     and a headless mux server allocates megabytes per pane for pictures nobody
     is looking at.

   ✓ RIGHT: terminal-core stores an IMAGE PLACEMENT — an id, a grid anchor, a
     cell footprint, and the *undecoded* payload (or a handle to it). The
     RENDERER decodes and draws. The core knows where an image is, never what
     it looks like.
#![allow(unused)]
fn main() {
/// Lives in terminal-core. Note what is absent: no pixels, no decoder, no RGBA.
pub struct ImagePlacement {
    pub id: ImageId,
    /// Anchored in ABSOLUTE (scrollback-inclusive) coordinates so it scrolls
    /// with the text for free — the same trick as selections.
    pub anchor: AbsolutePoint,
    pub cells_wide: u16,
    pub cells_high: u16,
    /// Pixel dimensions as declared by the protocol; the renderer scales.
    pub pixel_width: u32,
    pub pixel_height: u32,
    pub z_index: i32,
}

/// The undecoded payload, in a side table the renderer consumes.
pub struct ImageStore {
    images: HashMap<ImageId, EncodedImage>,
    /// Bounded. An unbounded image store is a trivial memory-exhaustion vector.
    total_bytes: usize,
    limit: usize,
}
}

The two protocols

SixelKitty graphics
IntroducerDCS q ... STAPC G <key=value,...>;<base64> ST
EncodingSix vertical pixels per printable character, RLE, palette-indexedBase64 of PNG or raw RGB/RGBA
ColorsPalette (typically 256)Full RGBA, with alpha
PlacementAt the cursor; scrolls with textExplicit: id, placement id, z-index, cropping
DeletionImplicit (erase the cells)Explicit delete commands
Age1987 (DEC printers)2018
Supportxterm, foot, WezTerm, contour, mlterm, Windows Terminalkitty, WezTerm, Ghostty, konsole

Recommendation: implement sixel first. It is self-contained, has no external dependency (the decoder is ~200 lines), and forces you to solve the placement problem. Kitty's protocol is easier to parse but needs a PNG decoder and has a much larger command surface.

Sixel, decoded

   Each printable character 0x3F..0x7E encodes SIX VERTICAL PIXELS:
       value = byte - 0x3F        (0..63)
       bit 0 = top pixel ... bit 5 = bottom pixel

   Control characters within the payload:
       #<n>            select color n
       #<n>;<t>;<a>;<b>;<c>   define color n (t=2 → RGB, values 0-100)
       !<n><char>      repeat <char> n times (RLE)
       $               carriage return: back to the left of this six-pixel band
       -               newline: advance to the next six-pixel band
       "<a>;<b>;<w>;<h>   raster attributes: aspect ratio and size

   So a sixel image is drawn in horizontal BANDS SIX PIXELS TALL, left to right,
   band by band. That is the whole format.
#![allow(unused)]
fn main() {
/// Streaming sixel decoder. Fed via the parser's put() so a multi-megabyte
/// image never buffers whole.
pub struct SixelDecoder {
    palette: [Rgb; 256],
    current_color: u8,
    /// Position within the image, in PIXELS.
    x: u32,
    band_y: u32,
    /// The decoded image, grown band by band. Bounded by `max_pixels`.
    pixels: Vec<u8>,
    width: u32,
    height: u32,
    max_pixels: u64,
    repeat: u32,
}
}

4. Milestones

#GoalDemonstrable by
1Parse and discard, correctlyimg2sixel photo.png leaves the screen clean, no stray text, and the byte count is logged
2Decode to an RGB buffermini-term run --dump-images -- img2sixel photo.png writes image-0.ppm you can open
3Placement in the grid--format debug shows image 0 at row 3 col 0, 40x20 cells
4Render itThe image appears in your GUI, at the right size and position
5Scroll and eraseIt scrolls with the text; CSI 2 J removes it; leaving the alt screen removes it
6 (stretch)The kitty protocolkitten icat works

Milestone 1 alone is a legitimate, shippable improvement: a terminal that cleanly ignores images is better than one that vomits sixel data onto the screen.


5. The Tests

#![allow(unused)]
fn main() {
#[test]
fn sixel_payload_is_streamed_not_buffered() {
    // A 10 MB sixel must not accumulate in the parser. The DCS hook/put/unhook
    // interface exists for exactly this.
    let mut t = Terminal::new(24, 80, cfg());
    let mut input = b"\x1bPq".to_vec();
    input.extend(std::iter::repeat(b'~').take(10_000_000));
    input.extend_from_slice(b"\x1b\\");
    t.advance(&input);
    assert!(t.parser_buffered_bytes() < 100_000);
}

#[test]
fn image_store_is_bounded() {
    let mut t = Terminal::new(24, 80, TerminalConfig { image_limit: 1 << 20, ..cfg() });
    for _ in 0..100 { t.advance(&one_megabyte_sixel()); }
    assert!(t.image_store().total_bytes() <= 1 << 20);
}

#[test]
fn images_scroll_with_the_text() {
    // Absolute anchoring means this is free. If it is not free, the anchor is
    // in screen coordinates and will drift.
    let mut t = Terminal::new(10, 40, cfg());
    t.advance(&sixel_10x10());
    let before = t.images()[0].anchor;
    for _ in 0..20 { t.advance(b"\n"); }
    assert_eq!(t.images()[0].anchor, before, "the anchor is absolute, so unchanged");
    assert!(t.images()[0].screen_row(&t).is_none(), "but it is now off-screen");
}

#[test]
fn erase_display_removes_images() {
    let mut t = Terminal::new(10, 40, cfg());
    t.advance(&sixel_10x10());
    assert_eq!(t.images().len(), 1);
    t.advance(b"\x1b[2J");
    assert_eq!(t.images().len(), 0);
}

#[test]
fn malformed_sixel_never_panics() {
    // Fuzz-adjacent: images are attacker-controlled input.
    for case in malformed_sixel_corpus() {
        let mut t = Terminal::new(24, 80, cfg());
        t.advance(&case);
    }
}

#[test]
fn terminal_core_still_builds_for_wasm() {
    // The boundary check that matters: adding images must NOT pull an image
    // decoder into the core.
    // CI: cargo build --target wasm32-unknown-unknown -p terminal-core
}
}

That last one is the real test of the project. If it fails, the placement/pixel boundary is wrong.


6. The Measurement

MetricMethodCompare with
Decode throughput (MB/s)A 5 MB sixel, timedlibsixel's decoder
Memory per imageRSS before/after, 20 imagesThe theoretical w × h × 3
Frame time with N imagesThe debug overlayText-only frame time
Time to first pixelRecording → renderedfoot, WezTerm

Report all four, including the ones where you lose. "Our decoder is 4× slower than libsixel because it is a straightforward scanline implementation with no SIMD" is a good sentence.


7. Known Traps

TrapDetail
Decoding in the coreKills wasm32, bloats the mux server. Store encoded; decode in the renderer.
Screen-coordinate anchorsImages drift on scroll. Use absolute coordinates.
Unbounded image storeA page of images is a memory-exhaustion vector. Bound it and evict.
Forgetting the alt screenImages placed on the alt screen must vanish on exit.
Cell size assumed constantA resize or a font change alters an image's cell footprint.
Sixel $ vs. - confused$ returns to the band's left edge; - advances a band. Swapping them shears the image.
RLE (!) not applied to the next character onlyOff-by-one produces plausible-looking garbage.
Ignoring ws_xpixel/ws_ypixelPrograms use them to compute image size. Set them in TIOCSWINSZ.

Tip: Milestone 1 (parse and discard) takes an afternoon and immediately improves your terminal. Do it first, ship it, then decide whether to continue. That is the correct way to attack every project in this portfolio.


Deliverables

  • A streaming sixel decoder, bounded, that never panics on malformed input.
  • ImagePlacement in terminal-core with no pixel data and no decoder dependency.
  • Rendering in the GUI, correctly positioned and scaled.
  • Scroll, erase, and alt-screen interactions correct.
  • cargo build --target wasm32-unknown-unknown -p terminal-core still passes.
  • The four measurements.
  • A written note on what you did not implement (transparency? animation? the kitty protocol?).

Next: Project 2 — Reflow on Resize