A .NET terminal emulator library inspired by xterm.js. XTerm.NET provides a headless terminal emulator that parses and processes VT100/ANSI escape sequences, making it easy to host console applications in your .NET applications.
Sixel and Kitty graphics are supported. Sixel is content: each line keeps the pixels Sixel
painted on it, a later Sixel paints over them, and printing over a cell replaces its pixels. Kitty is
an overlay: a picture is held as a run on the line it appears on, ordered against the text by its
z-index. Either way a picture scrolls with its line, is cleared by ED/EL, and is freed when the
line falls out of the scrollback — and a resize does nothing to it at all. See Images.
- Full VT100/ANSI Escape Sequence Support — Process colors, cursor movement, text attributes, and more
- Headless Design — No UI dependencies; bring your own renderer (Console, WPF, MAUI, etc.)
- Dual Buffer Support — Normal and alternate screen buffers with scrollback
- Keyboard & Mouse Input Generation — Generate escape sequences for keyboard and mouse events
- Rich Event System — Subscribe to terminal events like title changes, bell, resize, and window manipulation
- 256 and True Color Support — Full RGB and 256-color palette support
- Unicode Support — Wide characters, combining marks, and grapheme clusters: ZWJ emoji
sequences, regional-indicator flags, skin-tone modifiers and Hangul jamo each occupy one cell, and
mode 2027 is supported. Widths follow python
wcwidth, whose tables are vendored and replayed codepoint-by-codepoint in tests so the two cannot drift apart. See Unicode - Kitty Keyboard Protocol — Progressive enhancement (
CSI > … u) with the flag stack, event types and alternate keys, falling back to legacy encodings for anything not requested - Left and Right Margins —
DECSLRMunderDECLRMM, so text and scrolling stay inside a pane - Shell Integration —
OSC 133prompt marks survive reflow,OSC 8hyperlinks,OSC 52clipboard, and desktop notifications (OSC 9/99/777) - Sixel Graphics — Decodes Sixel images (
ESC P … q) and paints them into the lines they land on, soimg2sixel,chafa,lsixandtimgwork against a host that renders them — and a program repainting small regions over and over costs one strip of pixels per line, however many Sixels it sends - Kitty Graphics — Decodes the Kitty protocol (
ESC _ G …), including chunked transmission, PNG, transmit-once/place-many by image id, animation, and U+10EEEE Unicode placeholders, soicat,chafa -f kitty,timg -pk,yaziandimage.nvimwork the same way - Kitty Text Sizing — Decodes the text sizing protocol (
OSC 66), so a client can state how many cells a string takes and ask for it larger or smaller than the base size - Capability Queries — Answers XTGETTCAP (
ESC P + q …), so a program can ask the terminal what it can do instead of trusting whatever terminfo entry happens to be installed. See Capability queries
3.0 changes how Sixel is stored. If you only host a terminal and draw text, or draw only Kitty pictures, nothing changes. If you draw Sixel, you draw it from a new place.
In 2.x a Sixel was a picture: a TerminalImage and one LinePlacement per row, split wherever
printing or a later Sixel covered part of it. A program that repaints small regions — a cursor, a
hover highlight, a diffing framebuffer such as SixelBuffer — left a patchwork behind: thousands of
fragments per screen, each still pinning its whole picture and each its own draw. Sixel is content,
so in 3.0 a line keeps the pixels Sixel painted on it, in a SixelStrip, and the picture that
painted them is not kept at all.
What changed.
| 2.x | 3.0 |
|---|---|
A Sixel appears in line.Placements and line.Images |
It is painted into line.Sixel, a SixelStrip read with CopyPixelsTo; Placements and Images hold Kitty pictures only |
PlacementKind.Sixel runs |
Never created. The value remains so code that compares against it still compiles |
TryGetPlacementAt / TryGetImageAt find Sixel |
They find Kitty pictures; ask line.Sixel?.HasPixelsAt(column) |
| A later Sixel splits the one under it | It paints over it: opaque pixels replace, unset pixels (background select 1) leave what was there |
ICH/DCH, insert mode and margin scrolls left Sixel behind |
Its pixels move with their cells |
| Kitty deletes by position or z-index also removed Sixel runs | They no longer touch Sixel; a=d,d=a still clears everything |
MaxImageBytes counted pictures |
It counts pictures and strips; scrollback strips go first, oldest line first, and what is on screen is never taken |
| iTerm2 inline images were Sixel placements | They are painted into strips, as Sixel is |
| Sixel colour registers were private to each image (xterm's default) | They are shared between images, as on a VT340 and in Windows Terminal; CSI ? 1070 h asks for private registers, and RIS resets the shared ones |
The raster attribute's aspect ratio ("Pan;Pad) was ignored |
Each sixel row is Pan/Pad pixels tall (capped at 16); P1 is still ignored |
line.HasImages is true for a line with a strip as well as one with Kitty runs.
What a renderer does now. Per line: Kitty pictures behind the text (negative ZIndex), then the
text, then the line's Sixel strip, then Kitty pictures in front — see Images for the whole
row. The strip is one blit, read out with CopyPixelsTo:
if (line.Sixel is { } strip)
{
// Up to the last painted cell, which is all the row shows. Cache the upload against the strip
// and upload again when strip.Version changes.
var cols = Math.Min(strip.PaintedColumns, line.Length);
var stride = cols * strip.CellWidth * 4;
var pixels = new byte[stride * strip.PixelHeight]; // or a pooled buffer
strip.CopyPixelsTo(pixels, stride, cols); // BGRA8888, straight alpha, top row first
DrawImage(BitmapFrom(pixels, cols * strip.CellWidth, strip.PixelHeight, stride),
dest: (0, row * cellHeight, cols * cellWidth, cellHeight));
}A strip has no Pixels property to hand out its memory: it is stored so that copying a line -- which
a host capturing frames does many times a second -- stays cheap, and CopyPixelsTo gives back an
ordinary contiguous image.
A strip is painted at the cell size in force when its Sixels arrived, so after a font change it is
stretched onto the new grid, as a picture is. A strip can be painted by the thread writing to the
terminal while you read it: read Version before copying and again after, and treat a copy whose
version moved as stale.
2.0 targets .NET 10 and removes the per-cell image API. If you only host a terminal and draw text, nothing changes but the target framework. If you draw images, the contract has been replaced rather than extended.
A picture used to be scattered across cells, each carrying a reference and a tile coordinate. It is
now a run held by the line: LinePlacement, with the columns it covers and the source rectangle it
reads. That is what makes a resize stop destroying pictures — a run keeps its natural width, so
narrowing a window shows less of a picture instead of losing it.
Removed from BufferCell. A cell is a struct, so a copy of one has no idea which line or column
it came from and cannot answer for a run anchored to both. Ask the line instead:
| Gone | Use |
|---|---|
cell.Image |
line.TryGetImageAt(column, out var image) |
cell.IsImage |
line.TryGetPlacementAt(column, out _) |
cell.ImageTile, cell.ImageCol, cell.ImageRow |
line.TryGetPlacementAt(column, out var p) — p.SrcX, p.SrcY |
BufferCell.PackTile(col, row) |
no longer meaningful; runs carry source pixels, not tile numbers |
Changed elsewhere.
TerminalimplementsIDisposable. It always had aDisposemethod, but without the interface nousingstatement, DI container or analyzer could see it. Disposing releases the parser subscriptions and clears every event handler; writes after disposal are ignored rather than throwing, because a host reading a pty on a background thread will race teardown as a matter of course and an exception there kills the read loop rather than the object being torn down.Terminalnow snapshots constructor-suppliedTerminalOptions. Mutating the original options object after construction no longer reconfigures that terminal or another terminal created from the same object. Change live settings throughterminal.Options; dimensions useResize, while scrollback and the initial theme remain construction-time settings.TerminalImage.ByteCountis along. An animation's frames are counted against the image budget alongside the root picture, and the two together can exceed what anintholds.Params.GetSubParamsreturnsIReadOnlyList<int>rather thanList<int>. It used to be a stub that returned an empty list; it now returns the real sub-parameters, which is what makes4:3a curly underline and38:2::r:g:ba colour instead of both being discarded.AttributeData.IsUnderline()reports the underline style rather than a separate flag, so a cell underlined by4:3or21now answers true. Keeping a flag beside the style is how a cell ends up underlined by one and not the other.
What a renderer does now. Take line.Placements, order them by ZIndex — a stable sort, so
equal depths keep the order they were placed in, which is age — and draw them back to front with the
text going down between the negative ones and the rest. Each run is a single blit: source
(SrcX, SrcY, SrcWidth, SrcHeight), destination the columns from Column for Cols cells. Clip the
destination to the line's width and narrow the source by the same proportion. Paint the cell
background from the bottom-most run covering it and no other, or a nearer picture will erase what is
behind it instead of blending over it.
For animation, draw image.CurrentPixels rather than image.Pixels, and re-upload a cached texture
when image.FrameSerial changes. Drive the clock with AdvanceAnimations(delta), and use
HasRunningAnimations() to decide whether you need one at all.
Install via NuGet Package Manager:
dotnet add package XTerm.NETOr via the Package Manager Console in Visual Studio:
Install-Package XTerm.NETA Terminal is a grid of BufferCell structs — one per cell of the screen — plus the scrollback
behind it. A cell carries a character, its width and its attributes, and nothing else: pictures and
sized text blocks are held by the line as runs, so they survive a resize rather than being
scattered across cells that get re-wrapped. Traffic runs in three directions:
- In. Write what the hosted process produced with
Write, and the terminal interprets the ANSI/VT escape codes in it — colour, cursor movement, attributes, graphics. A pty host should prefer theReadOnlySpan<byte>overload and hand over the bytes it read: the terminal decodes UTF-8 itself, statefully across calls, so a multi-byte character split across two reads still arrives whole and nothing pays for a UTF-16 transcode on the way in. - Out. Turn user input into the sequences the process expects with
GenerateKeyInput,GenerateCharInput,GenerateMouseEventandGenerateFocusEvent, and send the result to the pty. Anything the terminal itself needs to reply — cursor position reports, device attributes, capability answers — arrives on theDataReceivedevent, which a host forwards the same way. - Asks. Requests the terminal cannot answer alone are events:
TitleChangedfor a new window title,WindowInfoRequestedfor size queries,ClipboardReadRequestedforOSC 52, and the window-manipulation events below.
Create a terminal instance with default settings (80 columns × 24 rows):
using XTerm;
var terminal = new Terminal();Terminal is IDisposable — disposing releases the parser subscriptions and clears every event
handler, so a host that creates terminals per tab or per session should dispose them:
using var terminal = new Terminal();Writing to a disposed terminal is ignored rather than throwing. A host reading a pty on a background thread races teardown as a matter of course, and throwing there would kill the read loop rather than the object being torn down.
Or customize the terminal with TerminalOptions:
using XTerm;
using XTerm.Options;
var terminal = new Terminal(new TerminalOptions
{
Cols = 120, // Number of columns
Rows = 40, // Number of rows
Scrollback = 1000, // Scrollback buffer lines (0 to disable)
CursorStyle = CursorStyle.Block,
CursorBlink = true,
TermName = "xterm" // Terminal type for identification
});Resize the terminal dynamically to match your UI or window size:
// Resize to new dimensions
terminal.Resize(cols: 120, rows: 50);
// Query current size
int currentCols = terminal.Cols;
int currentRows = terminal.Rows;Write text and ANSI escape sequences to the terminal:
// Write text (no automatic newline)
terminal.Write("Hello, ");
// Write a line (adds \r\n)
terminal.WriteLine("XTerm.NET!");
// Write with ANSI colors and styles
terminal.WriteLine("\x1b[31mRed text\x1b[0m");
terminal.WriteLine("\x1b[1;32mBold green text\x1b[0m");
terminal.WriteLine("\x1b[38;2;255;100;200mTrue color (RGB) text\x1b[0m");
// Position the cursor and draw
terminal.Write("\x1b[5;10HText at row 5, column 10");Access the buffer to read terminal content:
var buffer = terminal.Buffer;
// Get cursor position
int cursorX = buffer.X;
int cursorY = buffer.Y;
// Read a line as a string
string lineContent = terminal.GetLine(0);
// Or access the buffer line directly
var line = buffer.Lines[0];
string content = line?.TranslateToString(trimRight: true) ?? "";Subscribe to events to integrate the terminal into your application:
// Data sent back from the terminal (e.g., query responses)
terminal.DataReceived += (sender, e) =>
{
// Send e.Data to your connected process/PTY
Console.WriteLine($"Terminal sent: {e.Data}");
};
// Terminal title changed (via OSC escape sequence)
terminal.TitleChanged += (sender, e) =>
{
// Update your window title
Console.WriteLine($"Title: {e.Title}");
};
// Terminal resized
terminal.Resized += (sender, e) =>
{
// Notify your PTY/process of the new size
Console.WriteLine($"Resized to {e.Cols}x{e.Rows}");
};
// Bell character received
terminal.BellRang += (sender, e) =>
{
// Play a sound or flash the window
Console.WriteLine("Bell!");
};
// Line feed occurred (useful for tracking output)
terminal.LineFed += (sender, e) =>
{
// Trigger a render update
};
// Cursor style changed
terminal.CursorStyleChanged += (sender, e) =>
{
// Update cursor rendering
Console.WriteLine($"Cursor: {e.Style}, Blink: {e.Blink}");
};
// Buffer switched (normal ↔ alternate)
terminal.BufferChanged += (sender, e) =>
{
Console.WriteLine($"Switched to {e.BufferType} buffer");
};
// Mouse pointer shape requested (OSC 22, Kitty pointer shapes).
// Off by default: set TerminalOptions.PointerShapesEnabled = true to opt in, which
// tells the terminal it may answer support queries because you handle this event.
// e.Shape is a CSS cursor name such as "pointer", "text" or "wait";
// null means no shape is requested and the host should use its own pointer.
terminal.PointerShapeChanged += (sender, e) =>
{
Console.WriteLine($"Pointer shape: {e.Shape ?? "(default)"}");
};Window manipulation events (used by some terminal applications):
terminal.WindowMoved += (sender, e) => Console.WriteLine($"Move to ({e.X}, {e.Y})");
terminal.WindowResized += (sender, e) => Console.WriteLine($"Resize to {e.Width}x{e.Height}");
terminal.WindowMinimized += (sender, e) => Console.WriteLine("Minimize");
terminal.WindowMaximized += (sender, e) => Console.WriteLine("Maximize");
terminal.WindowRestored += (sender, e) => Console.WriteLine("Restore");XTerm.NET is headless — you provide the rendering logic for your UI framework (Console, WPF, MAUI, Avalonia, etc.). Walk over the terminal buffer and render each cell according to its content and attributes:
void RenderTerminal(Terminal terminal)
{
var buffer = terminal.Buffer;
for (int row = 0; row < terminal.Rows; row++)
{
var line = buffer.Lines[buffer.YDisp + row];
if (line == null) continue;
for (int col = 0; col < terminal.Cols; col++)
{
BufferCell cell = line[col];
// Skip empty cells or continuation cells (wide character's second cell)
if (cell.Width == 0) continue;
// Get the character content
string character = cell.Content;
// Get foreground/background colors
int fgColor = cell.Attributes.GetFgColor();
int bgColor = cell.Attributes.GetBgColor();
int fgMode = cell.Attributes.GetFgColorMode(); // 0=default, 1=256-color, 2=RGB
int bgMode = cell.Attributes.GetBgColorMode();
// Check text style attributes
bool isBold = cell.Attributes.IsBold();
bool isDim = cell.Attributes.IsDim();
bool isItalic = cell.Attributes.IsItalic();
bool isUnderline = cell.Attributes.IsUnderline();
bool isBlink = cell.Attributes.IsBlink();
bool isInverse = cell.Attributes.IsInverse();
bool isInvisible = cell.Attributes.IsInvisible();
bool isStrikethrough = cell.Attributes.IsStrikethrough();
bool isOverline = cell.Attributes.IsOverline();
// Render the cell at (col, row) with the appropriate styling
// Your rendering code here — e.g., DrawText(col, row, character, fg, bg, styles...)
}
}
// Render the cursor if visible
if (terminal.CursorVisible)
{
int cursorX = buffer.X;
int cursorY = buffer.Y;
CursorStyle style = terminal.Options.CursorStyle; // Block, Underline, or Bar
bool blink = terminal.Options.CursorBlink;
// Draw cursor at (cursorX, cursorY) with the appropriate style
}
}Color mode values:
0— Default terminal color (use theme foreground/background)1— 256-color palette index (0–255)2— True color RGB (extract withcolor & 0xFFfor each channel)
Wide characters — CJK ideographs, Hangul, fullwidth forms, most emoji — have Width = 2. The first
cell holds the character and the next has Width = 0 as a spacer: skip it while rendering, but
allocate the two columns for the glyph.
The two always travel together, on every path that writes a cell. That is not a detail a renderer
can paper over: a Width = 2 cell whose second column holds a real character makes the renderer draw
a two-column glyph into one column, and every character to its right on that row shifts. So
printing over either half blanks the other, an erase that cuts through a pair widens to take the
whole character, and a wide character with no room at the right margin wraps rather than being
crammed in.
A cluster is one cell. A ZWJ emoji sequence (👨👩👧👦), a regional-indicator flag
(🇯🇵), a base plus combining marks (é), a skin-tone modifier (👍🏽) and a Hangul jamo
sequence each occupy a single cell holding every codepoint, so selection, search and
TranslateToString treat them as the unit a user sees. Mode 2027 is supported for clients that want
to state the same thing explicitly.
Widths follow python wcwidth, which is what ucs-detect measures terminals against. The tables
are vendored — generated from wcwidth itself by scripts/generate-width-tables.py — and a parity
test replays every codepoint against them, so the two cannot drift apart between releases.
Width deliberately diverges from Terminal Unicode Core in one place: a cluster is one cell, but its width follows wcwidth arithmetic — a spacing mark adds a column, conjunct letters keep theirs, capped at two — because that is what applications lay out against. kitty makes the same trade.
A cell can exceed two columns only through the OSC 66 text sizing protocol below, never through
character width.
Handling sized text (OSC 66):
The Kitty text sizing protocol — OSC 66 ; s=2 ; Heading ST — draws a run of text at a multiple of
the cell size. It has two halves, and a renderer can honour the first without the second.
The width half is the emulator's own and needs nothing from you. A run claims s * w columns (or,
with w=0, each of its characters claims s times its normal width), and it claims them the way a
double-width character does: the first cell carries the text with Width set to the columns it took,
and the rest are Width = 0 continuations. So the cursor, selection and search already agree with
the client about how much room the run occupies — which is the point of the w key, a client telling
the terminal a string's width instead of both sides guessing at Unicode. A line holding a run is not
re-wrapped by a resize, exactly as a double-width line is not: the block keeps its shape, and a
narrowing that cuts it erases it and blanks what is left rather than leaving a cell claiming columns
that are gone.
The scale half is yours. Ask the line what it holds:
if (line.TryGetSizedRunAt(col, out LineSizedRun run))
{
// run.Cols — the columns the run covers
// run.Rows — how many rows tall the block is (its scale), growing DOWNWARDS from this line
// run.Sizing — Scale, Width, the Numerator/Denominator fraction, and the two alignments
}Draw the run's text scaled to run.Cols by run.Rows cells, applying the fraction inside that block
when run.Sizing.IsFractional and placing the fractional area with VerticalAlignment and
HorizontalAlignment. The rows a tall block covers are held for it: printing that would land in one
of them steps past the block instead, so the text a client writes underneath a heading is placed
after it rather than beneath it. Ask the buffer which block covers a cell:
if (terminal.Buffer.TryGetSizedRunCovering(absoluteRow, col, out var run, out var anchorRow))
{
// the block is drawn from anchorRow; this cell is one of the rows it occupies
}Erasing or splicing any row a block touches — ED, EL, ECH, IL, DL — erases the whole
block, as the protocol asks, so a block never outlives the rows it was drawn over. A renderer that
cannot scale at all should draw the text at the base size in the first cell of the block; the columns
are reported honestly either way.
Two graphics protocols are decoded — Sixel (ESC P … q … ESC \) and Kitty
(ESC _ G … ESC \) — and both end up held by the line the picture appears on. Nothing about a
picture is written into cells, which is what makes it survive a resize: what a line holds keeps its
natural width, so narrowing a window shows less of a picture instead of destroying it. It scrolls
with its line, is cleared by ED/EL, and is freed when the line falls out of the scrollback.
The two protocols differ in what a picture is:
- Sixel is content. Each line keeps the pixels Sixel painted on it, in a
SixelStrip(line.Sixel): one cell tall, as wide as anything painted. A later Sixel paints over it, printing or erasing over a cell clears that cell's pixels, and an insert or delete moves them with their cells. The picture that painted them is not kept. - Kitty is an overlay. A placement is one appearance of a picture: which image it draws,
which rectangle of that image it takes, and how many cells it fills. It is held as one run per line
in
line.Placements. Kitty can transmit a picture once and place it many times, so several placements may share oneTerminalImage.
Tell the terminal your cell size. XTerm.NET is headless and cannot measure a font, so it cannot work out how many columns an image covers unless you say. Set these from your renderer's metrics, in device pixels:
terminal.Options.CellWidthPixels = 8;
terminal.Options.CellHeightPixels = 17;Answer the window queries from these same numbers. An image viewer works out the cell size for
itself, by dividing the pixel size it gets from CSI 14 t by the row and column counts it already
has. So your WindowInfoRequested handler must report the grid, not the control:
case WindowInfoRequest.SizePixels: // CSI 14 t
e.WidthPixels = terminal.Cols * terminal.Options.CellWidthPixels;
e.HeightPixels = terminal.Rows * terminal.Options.CellHeightPixels;
e.Handled = true;
break;
case WindowInfoRequest.CellSizePixels: // CSI 16 t
e.CellWidth = terminal.Options.CellWidthPixels;
e.CellHeight = terminal.Options.CellHeightPixels;
e.Handled = true;
break;Reporting your control's own size instead is the classic way to get this wrong. It includes the scrollbar, any window chrome, and the strip below the last row — the grid is a truncated division, so up to a whole row of the control's height belongs to no row at all. An application dividing that figure by the row count is told the terminal is taller than it is, sizes a picture to fill it, and the surplus runs off the bottom and scrolls the screen.
Say when the cell size changes. A font change, a zoom, or the window moving to a monitor with
another DPI changes how many pixels a cell is while the grid can stay the same — so Resize is not
called and nothing tells the application. Update the options and then call
NotifyTextAreaPixelsChanged():
terminal.Options.CellWidthPixels = newCellWidth;
terminal.Options.CellHeightPixels = newCellHeight;
terminal.NotifyTextAreaPixelsChanged();That sends an in-band resize report (CSI 48 ; rows ; cols ; height ; width t) to any application
that asked for one with mode 2048, and is a no-op otherwise, so it is safe to call whenever the
metrics are recomputed. Without it an application that sizes its pictures from the cell size goes on
drawing at the old one: after a move to a higher-DPI monitor every picture lands smaller than its
cells, with gaps between them.
Drawing a row. Per line, not per cell, in this order:
- Cell backgrounds — but not under a Kitty picture behind the text (negative
ZIndex), which the text sits on. - Kitty pictures behind the text, back to front.
- The text.
- The line's Sixel pixels, if it has any — one blit for the whole line.
- Kitty pictures in front of the text (
ZIndex >= 0), back to front.
var line = buffer.Lines[buffer.YDisp + row];
// Kitty runs, back to front. A stable sort, so equal depths keep the order they were placed in,
// which is age.
var kitty = line.Placements.OrderBy(p => p.ZIndex).ToList();
DrawBackgrounds(line, row); // skipping cells under a negative-z picture
foreach (var p in kitty.Where(p => p.ZIndex < 0))
DrawKittyRun(line, p, row);
DrawText(line, row);
if (line.Sixel is { } strip)
{
// BGRA8888, straight alpha, top row first: cells of strip.CellWidth x strip.CellHeight pixels.
// PaintedColumns is how far anything is painted, so a narrow picture on a wide line does not
// draw the rest of the row; clip it to the line as well.
var cols = Math.Min(strip.PaintedColumns, line.Length);
DrawImage(StripBitmap(strip),
source: (0, 0, cols * strip.CellWidth, strip.CellHeight),
dest: (0, row * cellHeight, cols * cellWidth, cellHeight));
}
foreach (var p in kitty.Where(p => p.ZIndex >= 0))
DrawKittyRun(line, p, row);
void DrawKittyRun(BufferLine line, LinePlacement p, int row)
{
// Matched by id rather than by column: two pictures may overlap the same columns, and a column
// lookup answers with whichever placement comes first, not necessarily this one.
var image = line.Images.FirstOrDefault(i => i.Id == p.ImageId);
if (image is null) return;
DrawImage(PictureBitmap(image),
source: (p.SrcX, p.SrcY, p.SrcWidth, p.SrcHeight),
dest: ((p.Column * cellWidth) + p.OffsetX, (row * cellHeight) + p.OffsetY,
p.Cols * cellWidth, cellHeight));
}Upload once, and key the upload on what changes.
- A Kitty picture: cache your bitmap against the
TerminalImage— aConditionalWeakTablekeyed on it lets the bitmap die when the image does, with no eviction list to maintain, and two placements of one picture share the single upload. For animation drawimage.CurrentPixelsand upload again whenimage.FrameSerialchanges. - A Sixel strip: cache against the
SixelStripand upload again whenstrip.Versionchanges, copying the pixels out withstrip.CopyPixelsTo(destination, stride, columns)-- into a pooled buffer if you upload every frame, so the upload is not itself a large allocation per frame. A strip is painted by the thread writing to the terminal, so readVersionbefore copying and again after: a copy whose version moved may be torn, so draw it but do not keep it. A line's copies (Clone,CopyFrom) share its strip, and writing to a shared strip copies only the part the write touches, so a frame captured for a renderer is cheap.
Draws, not pixels, are what a busy screen costs. A program repainting small regions over and over leaves at most one strip per row however many Sixels it sends, and a Kitty client that tiles the screen into blocks costs one draw per block per frame — keep pictures on the GPU between frames rather than handing the framework a fresh bitmap each time.
Mapping onto the grid. A strip is painted at the cell size in force when its Sixels arrived, and
a picture records the cell size it was laid out against (image.CellWidth/CellHeight). After a
font or DPI change, stretch them onto the current grid — map cells to cells, as above, rather than
pixels to pixels — so a picture stays locked to the text it was drawn among.
For Kitty runs, clip the destination to the line's width and narrow the source by the same
proportion — that is what makes a narrowed window show less of a picture rather than a squashed one.
OffsetX/OffsetY are the sub-cell shift a placement asks for with X=/Y=; they are zero for most
placements. Paint each cell's own background once, underneath the runs, and from the bottom-most run
covering it only — painting it again for a nearer run erases what is behind instead of letting it
show through. See Draw order below for why a negative ZIndex means behind the
text rather than merely further back.
Cells under a picture hold " " as their content, so TranslateToString and selection copy yield
blanks.
To ask what covers one column rather than walking the line:
if (line.Sixel?.HasPixelsAt(col) == true) { … } // Sixel pixels
if (line.TryGetPlacementAt(col, out LinePlacement placement)) { … } // a Kitty run
if (line.TryGetImageAt(col, out TerminalImage image)) { … } // the Kitty picture it showsThe Kitty lookups answer with the first placement covering that column, so they are the convenient
form for the ordinary case of one picture at a time — a renderer handling overlapping pictures should
walk line.Placements and resolve through line.Images as above.
Options:
| Option | Default | Purpose |
|---|---|---|
SixelEnabled |
true |
Decode Sixel, and advertise it in the primary Device Attributes reply |
KittyGraphicsEnabled |
true |
Decode Kitty graphics sequences and answer their queries |
CellWidthPixels / CellHeightPixels |
10 / 20 |
Cell size images are laid out against |
MaxSixelPixels |
4_000_000 |
Largest single image accepted |
MaxImageBytes |
64 MB |
Budget for image data live in the buffer — Kitty pictures and Sixel strips; past it, scrollback strips go first, then the oldest pictures |
MaxImageRegistryBytes |
32 MB |
Budget for transmitted-but-unplaced Kitty images, evicted oldest first |
A change of column count keeps pictures: narrowing shows less of them and widening shows more. Only lines that are part of a wrap chain drop theirs, because reflow re-wraps those by copying ranges of cells between lines and the pieces would reassemble in the wrong places. A change of row count alone keeps everything.
Supported: transmit (a=t), transmit-and-display (a=T), place a stored image (a=p), delete
(a=d), and query (a=q); chunked payloads (m=1/m=0); RGB (f=24), RGBA (f=32) and PNG
(f=100, including interlaced); zlib compression (o=z); source cropping (x,y,w,h);
cell-box scaling (c,r); pixel offsets within the first cell (X,Y); cursor policy (C); and
response suppression (q=1/q=2).
An image may be named by the id the client chose (i=) or by a number it chose (I=), in which
case the terminal picks an id and reports both back. The whole delete matrix is implemented — by id,
by number, by placement id, at the cursor, at a cell, by row, by column, and by z-index — with the
upper-case form of each additionally releasing the stored image.
Images may be transmitted once and placed repeatedly, including via U+10EEEE Unicode
placeholders, where the image id travels in the cell's foreground colour. That is how yazi,
ranger and image.nvim draw. The combining marks that state an explicit tile row and column are
decoded, so a client may write tiles in any order rather than as a rectangle in reading order.
Animation is supported: frame transmission (a=f), animation control (a=a) and frame
composition (a=c). Frames may carry only the rectangle that changed, composed onto a previous
frame or onto a flat colour, blended or replaced. Both driving styles work — the client can make a
frame current itself, or set gaps and hand the timing to the terminal.
The emulator owns no timer. It is driven entirely by Write, and starting a thread inside a library
that has none, to repaint a host that already has a render loop, would be the wrong place for it.
So a host drives the clock:
// From your render loop, with however long the last frame took.
if (terminal.AdvanceAnimations(elapsed))
InvalidateVisual();HasRunningAnimations() says whether a clock is needed at all, so a terminal showing nothing but
text runs no timer. Draw image.CurrentPixels rather than image.Pixels — the latter stays the
root frame and never changes, which is what makes it safe to hold and hand to another thread. Cache
your texture against the image as before, and re-upload when image.FrameSerial changes.
Not supported, and refused with a proper error reply rather than ignored:
- File, temp-file and shared-memory transmission (
t=f,t=t,t=s) are refused withENOTSUPby design. The terminal would be opening a path chosen by the program it hosts, and the host usually holds more privilege than that program does. Direct transmission (t=d) is the only medium accepted.
Draw order (z=) is honoured, including where pictures overlap. A picture is a run held by the
line rather than anything written into cells, so two pictures over the same columns are simply two
runs and the z-index says which a renderer draws last — ordered by z, and at equal z by which was
placed later. Covering one has no way to modify it: a translucent picture blends over the one behind
it, and deleting the front one reveals the back one whole, because the back one was never touched.
A host draws a line's runs from the back forwards, and paints each cell's own background once underneath them — painting it again with a nearer run would erase what is behind instead of letting it show through.
A negative z-index means something different in kind — behind the text. A Kitty placement is an
overlay rather than content, so printing over one never modifies it: the z-index alone decides
whether the glyph or the picture is on top, and a picture placed under existing text leaves it
readable. That is also why typing over a picture in front of the text hides the character without
destroying it, and deleting the picture gives it back. Sixel is the other kind and keeps its
replace-on-write. Erasing (ED/EL) clears both, because a picture showing through a cleared screen
would be a leak rather than a feature. A host renders these by drawing the tile first
and the glyph over it; text with no background colour of its own paints no fill, which is what lets
the picture show through.
A program's idea of the terminal comes from TERM and whichever terminfo database is installed on
the machine it is running on — which, over ssh or inside a container, describes some other terminal
entirely. XTGETTCAP is how it asks this one directly:
ESC P + q <name> ; <name> … ESC \
Each name is hex-encoded, two digits per character. Every name gets its own reply, so a client can pair each answer with the question it asked:
| Reply | Meaning |
|---|---|
ESC P 1 + r <name>=<value> ESC \ |
The terminal has this capability; value is hex-encoded |
ESC P 0 + r <name> ESC \ |
It does not, or the name was not readable hex |
The name comes back exactly as it was sent rather than re-encoded, so a client can match a reply
against its own bytes. A boolean capability — Tc, Su — succeeds with an empty value; that empty
value is the answer, which is why it is not a failure reply.
Both spellings are answered, because a caller uses whichever its own database uses: the two-letter
termcap name (Co, ku) and the terminfo one (colors, kcuu1). Names are case-sensitive, since
Co is the number of colours and co is the number of columns.
What comes back describes this emulator, not the entry named by TermName — that is the entire
point of asking. So RGB, Tc, setrgbf/setrgbb (direct colour), Smulx and Setulc (styled
and coloured underlines), rep (REP) and Ms (OSC 52) are all reported, Su is reported only
while SixelEnabled is on, and cols/lines report the size the terminal is now rather than the
one it started at. TN reports TermName, which is what the host tells programs to call it.
terminal.DataReceived += (_, e) => Console.WriteLine(Escape(e.Data));
// "TN" hex-encoded: ask the terminal what it calls itself.
const char Esc = (char)0x1B;
terminal.Write($"{Esc}P+q544e{Esc}\\");
// → ESC P 1 + r 544E=787465726D ESC \ ("TN=xterm")Note that ESC P q — with no + — is DECSIXEL, an image. The two differ by that one intermediate
character and are dispatched apart on it.
This project is licensed under the MIT License — see the LICENSE file for details.
- Tom Laird-McConnell — tomlm
- John Campion Jr — JohnCampionJr
