Add Sixel graphics support - #30
Conversation
Sixel images (ESC P ... q ... ESC \) are decoded and placed in the buffer. Each cell an image covers carries a reference to one shared, immutable TerminalImage plus the coordinates of the tile it shows, so a picture behaves like terminal content rather than an overlay. Sixel was not merely unimplemented before this, it was unreachable. The parser collapsed DcsEntry/DcsParam/DcsIgnore/DcsPassthrough into a single "discard every byte until ST" case, _dcs was allocated but never written to, and the Dcs event was marked [Obsolete] because nothing raised it. The payload never reached anything that could decode it. Why storage on the cell BufferCell is a struct and InputHandler.Print builds a fresh one for every character. That single fact gives the feature its semantics for free: printing over a tile replaces it, ED/EL/ECH clear it, scrolling carries it with its line, and an image is collected once the last cell holding it scrolls out of the scrollback. None of that needed code. A reference to a shared image rather than a per-cell bitmap slice gives identical overwrite granularity, one allocation per image instead of columns times rows, and lets a host coalesce a run of adjacent tiles into a single draw call. BufferCell grows from 32 to 40 bytes; the packed tile int fits in padding the reference already forces. Two live bugs fixed along the way CSI ? 1;1;0 S scrolled the screen. XTSMGRAPHICS shares its final character with SCROLL UP, and ToCsiCommand strips the private marker before the lookup, so a graphics capability query was routed to the scroll handler. Every Sixel-capable program sends one during startup, which made this routine rather than obscure. The primary DA reply did not advertise Sixel. libsixel, chafa, img2sixel and everything built on them read attribute 4 from CSI c and send text art instead of pictures without it. Decisions Images are dropped on a column resize: reflow re-wraps a logical line by copying ranges of cells, so tiles carried through would reassemble as a shuffled mosaic. A change of row count alone keeps them. The DCS payload is streamed rather than buffered, and an abandoned sequence is reported as such so a truncated image is discarded rather than half-drawn. Sixel colour registers are kept apart from ColorPalette, which the renderer reads on its hot path. Nothing in the decoder throws. The payload is untrusted output from another process. Validation dotnet test src/XTerm.NET.slnx -- 847 passing, 0 failing. Verified end to end against chafa and a Sixel image viewer running under a real ConPTY, including that ConPTY passes DCS through unmodified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrundhvU51mvU7QHTwvnfY
There was a problem hiding this comment.
🟡 Changes recommended
Confirmed correctness issues in the DCS parser’s _collect lifetime, XTSMGRAPHICS geometry reporting vs MaxSixelPixels, and invalid XML doc comments in Terminal.cs should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR makes Sixel graphics reachable and functional end-to-end by adding real DCS parsing with streamed payload delivery, decoding DECSIXEL into immutable TerminalImage instances, and placing image tiles directly into the terminal buffer cells so images behave like normal terminal content (erase/overwrite/scroll).
Changes:
- Implement DCS parsing with
DcsHook/DcsPut/DcsUnhookstreaming and retain a capped whole-payloadDcsevent for short sequences. - Add Sixel decoding, palette handling, buffer-cell image tiling, and image memory budgeting/eviction.
- Advertise and query graphics capability correctly (Primary DA includes Sixel attribute 4 when enabled;
XTSMGRAPHICShandled), plus documentation and test coverage.
File summaries
| File | Description |
|---|---|
| src/XTerm.NET/XTerm.NET.csproj | Bumps package/assembly version for the new feature set. |
| src/XTerm.NET/Terminal.cs | Wires DCS events to the input handler; adds image budget sweeping/eviction logic. |
| src/XTerm.NET/Parser/EscapeSequenceParser.cs | Implements full DCS prologue/payload state machine and streaming payload events. |
| src/XTerm.NET/Options/TerminalOptions.cs | Adds Sixel and image-related options (enable flag, cell pixel metrics, budgets). |
| src/XTerm.NET/InputHandler.cs | Adds DECSIXEL handling and placement; fixes XTSMGRAPHICS vs ScrollUp routing; updates DA reply. |
| src/XTerm.NET/Graphics/TerminalImage.cs | Introduces immutable, framework-neutral decoded image storage + tile geometry helpers. |
| src/XTerm.NET/Graphics/SixelPalette.cs | Adds VT340-style Sixel register palette with RGB/HLS support. |
| src/XTerm.NET/Graphics/SixelDecoder.cs | Streaming DECSIXEL decoder that produces TerminalImage without throwing on malformed input. |
| src/XTerm.NET/Events/ParserEvents.cs | Adds event args for DCS hook/put/unhook streaming API. |
| src/XTerm.NET/Common/Types.cs | Extends parser state enum with DcsIntermediate. |
| src/XTerm.NET/Common/TerminalMode.cs | Adds Sixel-related modes (DECSDM 80, 1070, 8452). |
| src/XTerm.NET/Buffer/TerminalBuffer.cs | Drops images on column resize (reflow), adds buffer-wide image clearing. |
| src/XTerm.NET/Buffer/BufferLine.cs | Adds per-line image clearing and HasImages fast check for renderers. |
| src/XTerm.NET/Buffer/BufferCell.cs | Adds per-cell image reference + packed tile coords; updates equality/hash semantics. |
| src/XTerm.NET.Tests/Parser/DcsSequenceTests.cs | New tests for DCS streaming behavior, termination/abandon semantics, and regression coverage. |
| src/XTerm.NET.Tests/InputHandlerTests.cs | Updates/extends DA tests to cover Sixel attribute advertising and disable behavior. |
| src/XTerm.NET.Tests/Graphics/SixelPlacementTests.cs | New tests for how images land in the buffer and cursor positioning modes. |
| src/XTerm.NET.Tests/Graphics/SixelDecoderTests.cs | New tests validating pixel output, malformed-input resilience, budgets, and write-boundary invariance. |
| src/XTerm.NET.Tests/Graphics/ImageCellLifetimeTests.cs | New tests for erase/overwrite/scroll/resize semantics and image budget eviction. |
| src/XTerm.NET.Tests/Graphics/GraphicsAttributesTests.cs | New tests covering XTSMGRAPHICS replies and the prior scroll regression. |
| README.md | Documents host responsibilities (cell metrics, window queries) and how to render image tiles. |
| FIXES.md | Records the Sixel implementation decisions, rationale, and validation notes. |
Review details
- Files reviewed: 22/22 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The feature list and the reference section already covered it, but the opening paragraph -- the part anyone actually reads before deciding whether the library does what they need -- did not mention graphics at all. Also fixes a long-standing typo in the same sentence: conosole -> console. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrundhvU51mvU7QHTwvnfY
Two follow-ups to the autofixes on this branch.
The collect buffer fix was load-bearing and is now covered. Nothing cleared the
buffer on the way out of a DCS, and an ESC sequence does not clear it on the way
in, so DECRQSS left its "$" behind and the next "ESC ( B" reported its
intermediates as "$(" -- designating a character set the program never asked for,
one sequence after the one that caused it. Reverting the one-line fix turns the
new test red with exactly that string, so it is a real guard rather than a
restatement.
The other autofix removed a doc comment that had come adrift: inserting
NoteImagePlaced between EnforceImageBudget and its summary left the summary
attached to a field instead. Deleting it silenced the warning at the cost of the
explanation, so it is reattached to the method it describes.
Validation: dotnet test src/XTerm.NET.slnx -- 849 passing, 0 failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrundhvU51mvU7QHTwvnfY
Two XML blocks were missing their opening <summary>, so the text above Colors and OscReceived was attached to the member before it instead -- ProgressValue and NotificationReceived each carried a stray fragment, and the two properties that the prose was written for had no documentation at all. Both predate this branch. Also completes two param lists in the new Sixel code that were partly filled in, which a build with GenerateDocumentationFile turned up alongside them. A scan of every /// block in src now finds no unbalanced summary, remarks, para, returns or value tag, and building with XML documentation enabled produces no CS15xx warnings. Validation: dotnet test src/XTerm.NET.slnx -- 849 passing, 0 failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MrundhvU51mvU7QHTwvnfY
Sixel images (
ESC P … q … ESC \) are decoded and placed in the buffer. Each cell an image covers carries a reference to one shared, immutableTerminalImageplus the coordinates of the tile it shows, so a picture behaves like terminal content rather than an overlay.Sixel was not merely unimplemented before this — it was unreachable.
EscapeSequenceParsercollapsedDcsEntry/DcsParam/DcsIgnore/DcsPassthroughinto a single "discard every byte until ST" case,_dcswas allocated but never written to, and theDcsevent was[Obsolete]because nothing raised it. The payload never reached anything that could decode it.Why the image lives on the cell
BufferCellis a struct, andInputHandler.Printbuilds a fresh one for every character. That single fact gives the whole feature its semantics for free:new BufferCell{…}+SetCellFill/ReplaceCellswithBufferCell.SpaceCircularListmoves wholeBufferLineobjectsTranslateToStringcell.Content" ", so copying yields blanksA reference to a shared image rather than a per-cell bitmap slice gives identical overwrite granularity, one allocation per image instead of columns × rows, and lets a host coalesce a run of adjacent tiles into a single draw call.
BufferCellgrows 32 → 40 bytes — the packed tileintfits in padding the reference already forces — which is roughly 0.65 MB on an 80-column buffer with 1000 lines of scrollback.Two live bugs fixed along the way
CSI ? 1;1;0 Sscrolled the screen. XTSMGRAPHICS shares its final character with SCROLL UP, andToCsiCommandstrips the private marker before the lookup, so a graphics capability query was routed to the scroll handler. Every Sixel-capable program sends one during startup, which made this routine rather than obscure.ScrollUpis now guarded onisPrivateand the query is answered.libsixel,chafa,img2sixeland everything built on them read attribute4fromCSI cand send text art instead of pictures without it. The reply is nowCSI ? 1 ; 2 ; 4 c, followingOptions.SixelEnabledso it never claims a capability that is switched off.Decisions worth recording
DcsHook/DcsPut/DcsUnhook, and only accumulates a whole-payload string for the legacyDcsevent when something is subscribed and the sequence stays under 4 KB.ESCmid-payload is resolved one character late, sinceESC \terminates and anything else abandons.ColorPalette, which the renderer reads on its lock-free hot path — recolouring text as a side effect of showing a picture would be a poor trade.A note for hosts
An unhandled
WindowInfoRequestedstill produces no reply — that contract is unchanged and still tested. But the README now spells out the handler, because getting it wrong is subtle and costly: an image viewer works out the cell size for itself by dividing the pixel size fromCSI 14 tby the row count it already has. Anything else in that figure — a scrollbar, window chrome, or the strip below the last row, since the grid is a truncated division — is read back as picture that does not fit, runs off the bottom, and scrolls the screen. The only safe answer isCols * CellWidthPixelsbyRows * CellHeightPixels, which is what xterm reports.Validation
New suites:
Parser/DcsSequenceTests,Graphics/SixelDecoderTests,Graphics/SixelPlacementTests,Graphics/ImageCellLifetimeTests,Graphics/GraphicsAttributesTests.Beyond the unit tests, verified end to end against
chafaand a Sixel image viewer driven through a real ConPTY — including confirming that ConPTY passes DCS through unmodified, that real encoder output decodes to the expected pixels, and that the placed image occupies exactly the rows the viewer drew its border around.🤖 Generated with Claude Code
https://claude.ai/code/session_01MrundhvU51mvU7QHTwvnfY