Images in the terminal. No dependencies.
A .NET library that draws images with the best protocol the terminal has. Detection reads the environment, then asks the terminal once, and never hangs. The core publishes with NativeAOT.
dotnet add package Nullean.TerminalImages
$ tcat sunset.png
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Everything between pixels and a terminal
The pieces are plain C#, so there is nothing to install beside the package.
The PNG codec, the resampler and all four encoders are managed code. No SkiaSharp, no ImageSharp, no native assets: a NativeAOT tcat is 1.7 MB.
The probe ends with a request every terminal answers. A silent terminal costs 250 ms; a talkative one costs a round trip.
Median-cut Sixel with dithering, Kitty chunks with optional compression, and half blocks in 24-bit or 256 colours.
NULLEAN_TERMINAL_IMAGES forces a protocol, and the result lists a line of reasons for every decision it made.
A renderable for Spectre.Console, a view for Terminal.Gui v2, and WriteImage for XenoAtom.Terminal.
Images are wrapped for tmux, one sequence at a time, and the probe is sent through to the outer terminal.
Detect, then draw
The renderer scales the image to fit, picks the encoder, and leaves the cursor below the image.
var capabilities = await TerminalImageDetector.DetectAsync(); var renderer = new TerminalImageRenderer(capabilities); renderer.RenderPng(File.ReadAllBytes("photo.png"), Console.Out);
Asking the terminal, once
Terminals answer in order, and every one of them answers Device Attributes. So that goes last.
ESC _ G i=31,s=1,v=1,a=q,t=d,f=24;AAAA ESC \ Kitty query ESC [ > 0 q name and version ESC [ 16 t cell size in pixels ESC [ c the sentinel: DA1
Cheapest evidence first
Detection stops as soon as it has an answer it can trust.
- OverrideThe
NULLEAN_TERMINAL_IMAGESvariable, or code. Never probed. - GatesOutput redirected, or
TERM=dumb: nothing is drawn. - EnvironmentKitty, Ghostty, iTerm2, WezTerm, Windows Terminal and others, with no I/O.
- ProbeOne write to
/dev/tty, with a timeout that applies only when the terminal says nothing.
And it says why
The result lists what was found and a line for every decision. This is a scripted terminal that answers like WezTerm.
protocol: ITerm2 supported: ITerm2, Sixel, Kitty, HalfBlocks terminal: wezterm 20240203-110809-5046fc22 confidence: Probed cell size: 10x20px sixel regs: 256 truecolor: True multiplexer: False ssh: False - No terminal recognised from the environment. - Probe answered: preferred protocol ITerm2, Sixel listed by the terminal, cell 10x20px.
Four ways to draw
The renderer picks, in the order your terminal prefers.
| Protocol | Sent as | Scaling | Terminals |
|---|---|---|---|
| Kitty | raw RGBA in 4096-byte base64 chunks, or a PNG | by the terminal | Kitty, Ghostty |
| iTerm2 | a PNG file in one OSC 1337 sequence | by the terminal | iTerm2, WezTerm, Konsole, mintty |
| Sixel | 6-pixel bands with a palette, run-length encoded | none: drawn at pixel size | foot, Windows Terminal, xterm, WezTerm |
| Half blocks | ▀ with a foreground and a background colour | none | anything with colour |
In your framework
Each host is its own package. The core never references one.
A TerminalImage renderable. Half blocks use Spectre's own colours, so they downgrade with the console.
A TerminalImageView that feeds Terminal.Gui's raster pipeline, and detection that sees through tmux and SSH.
terminal.WriteImage(image), written atomically, with no SkiaSharp.
What is verified
Verified: the encoders, PNG codec, resampler, detection logic and renderer, with unit tests that were checked by breaking the code on purpose; the terminal probe, by running the published NativeAOT binary in a pseudo-terminal with a scripted terminal, on macOS and Linux; the Terminal.Gui view in a live Terminal.Gui application; XenoAtom on its in-memory backend.
Not yet verified: output on real terminals (Kitty, Ghostty, iTerm2, WezTerm, foot, Windows Terminal, xterm), and tmux or SSH. The terminal-specific choices come from how other tools and the terminals' own documentation treat them. If one is wrong, tcat --diagnostics shows why, and an override fixes it. What detection assumes →