sugarcraft / sugar-spark
PHP port of charmbracelet/sequin — ANSI escape-sequence inspector. Decodes SGR / CSI / OSC / DCS / APC / SS3 sequences into human-readable labels.
Requires
- php: ^8.3
- sugarcraft/candy-ansi: dev-master
- sugarcraft/candy-core: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-21 21:17:41 UTC
README
SugarSpark
PHP port of charmbracelet/sequin — an ANSI escape-sequence inspector. Pipe styled output through it and each escape becomes a labelled line.
composer require sugarcraft/sugar-spark
CLI
$ printf '\e[31mhello\e[0m world\n' | sugarspark ESC[31m SGR foreground red hello ESC[0m SGR reset world
$ printf '\e]0;new title\e\\' | sugarspark ESC]0;new title set window title to "new title"
$ printf '\e[?2026h' | sugarspark ESC[?2026h enable synchronized output
Library
use SugarCraft\Spark\Inspector; foreach (Inspector::parse("\e[1;31mboom\e[0m") as $segment) { echo $segment->describe(), "\n"; } // One-shot report: echo Inspector::report($capturedTerminalOutput);
What it decodes
- SGR — foreground / background (16, 256, 24-bit truecolor) + bold / italic / underline / blink / reverse / strikethrough / faint.
- CSI — cursor moves, erase, scroll region (DECSTBM), scroll up/down, insert/delete line/char, tab forward/backward, DECSCUSR cursor shape, DECRQM mode query, DECRPM mode reply, request cursor position, XTVERSION query, kitty keyboard query/push/pop.
- CSI ~ keys — Home / End / Delete / PgUp / PgDn / F1-F12 / bracketed paste markers.
- DEC private modes — cursor visibility, mouse modes (1000/1002/1003/ 1006/1015), focus reporting (1004), alt screen (47/1047/1049), bracketed paste (2004), synchronized output (2026), unicode grapheme mode (2027).
- OSC — title (0/2), icon (1), palette (4), cwd (7), hyperlink (8), iTerm2 (9), taskbar progress (9;4), terminal colour set (10/11/12), clipboard (52), reset terminal colour (110/111/112).
- DCS — XTVERSION reply (
>|<term> <ver>), DECRPSS, sixel. - APC — CandyZone markers (
candyzone:S/E:<id>), kitty graphics (G…). - SS3 — F1-F4 / cursor / Home / End.
- 2-byte ESC — DECSC / DECRC / keypad mode / index / reverse-index / reset.
Anything unrecognised falls back to a generic CSI/OSC/... descriptor — an
unrecognised sequence is never silently swallowed.
Reported bytes are the bytes that were sent: parameters keep the separator the
sender used (ESC[4;3m never comes back as ESC[4:3m, and an omitted
parameter keeps its empty slot) — in a CSI and in a DCS prelude, which travel
through the same parameter arrays; a string's ESC \ terminator is reported once,
inside the sequence it closed; an abandoned ESC O leaves the text behind it
alone; and a sequence the stream cut short still arrives, as a truncated …
segment carrying its raw bytes.
Nine deviations from exact byte equality are known, and each is pinned by a named test rather than hidden — anything else that loses a byte is a bug:
- An OSC is re-emitted with a BEL terminator (
ESC ] … BEL) even when the input closed it withESC \, because that is the spelling terminals accept everywhere. (testOscTerminatedByStIsNormalizedToBelWithoutGhost) - A re-emitted DCS prelude is lossy: its final byte is dropped and an
intermediate byte comes back ahead of the parameters, so
ESC P 1 $ r STarrives asESC P $ 1 ST. The parameters themselves keep their own separators. (testDcsParametersKeepTheirOwnSeparators,testDcsPreludeRebuildHoistsTheIntermediateAheadOfTheParameters) - A sequence is capped at 32 parameters; past that the separator is dropped and
digits keep accumulating into the last slot, so
ESC[1;…;32;33marrives as the single parameter3233. (testParameterCapIsReportedAsTheParserSawIt) - A parameter value accumulates only up to 65535, so an oversized value arrives
clamped rather than truncated mid-digit:
ESC[99999misESC[65535m. - A string payload stops at 64 KiB, so a longer OSC arrives cut at exactly that length with its terminator still attached.
- Inside an OSC payload only, the C0 bytes candy-ansi maps to
None(0x00–0x06,0x08–0x17,0x19,0x1C–0x1F) never reach the buffer:ESC]0;a<EOT>b BELre-emits asESC]0;ab BEL. The same byte does survive in a DCS, SOS, PM or APC payload. (4–6:testIgnoredControlBytesAndParserCapsArriveAsTheParserRewroteThem) - An illegal parameter byte (
-, CAN, SUB) cancels the sequence and discards the bytes collected so far:ESC[31;-2mXreaches the segmenter as onlymX— no segment is invented for a sequence that never dispatched. The one exception is a cancelled prelude followed by a truncated tail: the tail reports the raw bytes still in flight, so it replays the cancelled prelude too (ESC[31;-2m+ESC[3arrives as one 11-bytetruncated CSI). That over-reports rather than under-reports, and CAN does not leak because itsexecutecallback flushes the byte window while an illegal byte leaves no callback at all. (testCancelledSequenceIsLostExactlyAsTheParserLosesIt) - A truncated UTF-8 rune at end of stream is dropped, because that is not an
escape-sequence state for
Parser::flush()to report. (testTruncatedUtf8TailIsDroppedExactlyAsTheParserDropsIt) - A sequence opened by an 8-bit C1 introducer (
0x90DCS,0x98SOS,0x9BCSI,0x9DOSC,0x9EPM,0x9FAPC) is re-emitted in its 7-bitESCspelling: candy-ansi folds the introducer choice away before the dispatch callback, leaving this inspector only the parsed pieces and a 7-bit template to rebuild from — so\x9b31marrives asESC [ 31 m. An unterminated tail is the exception: it replays the bytes in flight verbatim, keeping the raw C1 introducer. (testEightBitCsiIntroducerIsReplayedInSevenBitForm,testEightBitSosAndPmCarryTheirPayloadThroughTheSevenBitReplay)
The first two are this inspector's own re-emission choices. Items 3–8 are
candy-ansi rewriting the bytes before this lib ever sees a dispatch — recorded in
CALIBER_LEARNINGS.md, and each transformation is byte-identical to the behaviour
before this contract was written, so they are fidelity items for candy-ansi, not
for this inspector. Item 9 is the same fold in the other direction: candy-ansi
loses which spelling opened the sequence, so the inspector's re-emission can only
pick one. What is new here is what the inspector reports afterwards:
before this branch a truncated tail simply vanished, which is why item 7's
cancelled prelude can now reappear inside one.
Test
cd sugar-spark && composer install && vendor/bin/phpunit
