Search by

sugarcraft / sugar-vterm

detain

In-memory virtual terminal emulator — parses ANSI byte stream into a cell grid.

dev-master 2026-11-03 15:48 UTC

This package is auto-updated.

Last update: 2026-11-05 01:03:12 UTC


README

Formerly published as sugarcraft/candy-vt with namespace SugarCraft\Vt (renamed 2026-10).

CI codecov Packagist Version License PHP

In-memory virtual terminal emulator — parses an ANSI byte stream into a cell grid with cursor, mode, SGR style, and hyperlink state.

Used as the terminal emulator behind candy-vcr's render-tape pipeline — every frame produced for a GIF is a Snapshot::of($terminal, $time) after the renderer feeds output bytes into a Terminal instance.

Contents

Install

composer require sugarcraft/sugar-vterm

Quickstart

use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);

// Feed raw ANSI bytes from a terminal program.
$vt->feed("\x1b[1;1H\x1b[31mmhello\x1b[0m");

// Inspect the rendered screen.
$screen = $vt->screen();
foreach ($screen->lines() as $row => $line) {
    echo "$row: $line\n";
}

// Current cursor position.
$cursor = $vt->cursor();
echo "cursor at {$cursor->row},{$cursor->col}\n";

Captured-stream gotcha (ONLCR). A real terminal writes line breaks as \r\n because the tty layer (ONLCR) expands each \n on output. Feeding the emulator a raw \n reproduces exact VT semantics — the cursor moves down one row without resetting the column — so text captured with bare LFs (files, pipes, hand-written fixtures) stacks diagonally and the cell grid false-diffs against what a human saw on screen. Normalize \n to \r\n (collapsing existing \r\n first) before feed() when the source is not a tty capture.

Architecture

Layer Class Role
Facade Terminal\Terminal Owns a Parser + ScreenHandler; feed() drives bytes in
Parser AnsiParser\Parser\Parser VT500 state machine (owned by sugar-ansi-parser; consumed via src/Terminal/Terminal.php) — Paul-Williams algorithm, handles partial input, parses subparameters (:)
Handler AnsiParser\Parser\CsiHandler CSI sequence handler contract (owned by sugar-ansi-parser) — vterm's Parser\CsiHandlerImpl implements it
Handler AnsiParser\Parser\OscHandler OSC sequence handler contract (owned by sugar-ansi-parser) — vterm's Parser\OscHandlerImpl implements it
Handler Handler\ScreenHandler Dispatches parser actions to Buffer / Cursor / Sgr / Mode
Screen Screen\Screen Immutable snapshot — read current grid after feeding bytes
Buffer Buffer\Buffer Cell grid — rows × cols of styled grapheme cells
Cursor Cursor\Cursor Position + visibility + origin mode tracking
SGR Sgr\Sgr Current graphics rendition: foreground / background / attributes
Mode Mode\Mode DEC private mode flags (DECSET/DECRST) including DECAWM auto-wrap
Hyperlink Hyperlink\Hyperlink OSC 8 URL + id tracker
Scrollback Screen\Scrollback Ring buffer — stores rows that scroll off the top (default 1000)
Msg Msg\FocusInMsg / FocusOutMsg Focus-in / focus-out event records (CSI I/O, mode 1004)
Theme Theme 256-color palette + default fg/bg + factory methods for named themes
Catalog Themes Bundled theme catalog: all() and v1() accessors

Two Terminal classes

sugar-vterm ships two Terminal entry-points for distinct use cases — both share the short class name Terminal, so import one per file or alias the other (use SugarCraft\Vterm\Terminal\Terminal as FullTerminal;):

Class Use case Methods
SugarCraft\Vterm\Terminal\Terminal Full VT500 emulator — parses CSI/OSC/DCS, maintains Sgr/Mode/Hyperlink/Scrollback. new(cols, rows, ?scrollbackSize), feed(bytes, ?respond), feedAsync(bytes): PromiseInterface<string>, feedStream(ReadableStreamInterface, ?respond, ?cancellation): PromiseInterface<string>, replies(), flush(), screen(), cursor(), mode(), windowTitle() (null until the first title), palette(), clipboardEvents() and focusEvents() (both drain-on-read), resize(cols, rows), enableAltScreen()/disableAltScreen()/isAltScreen(), plus with*() builders for Buffer/Cursor/Mode/WindowTitle/TabStops/ScrollbackSize.
SugarCraft\Vterm\Terminal (root) Lightweight emulator used by candy-vcr's renderer — produces Snapshot value objects directly. new(cols, rows, ?Theme), theme(), feed(bytes): self (mutates in place, returns $this), snapshot(?time): Snapshot (deep-copies the grid), revision(): int, cursor(): Cursor, grid(): Buffer, windowTitle(): string (empty until the first title), isWrapPending(): bool. enableAltScreen()/disableAltScreen() exist for interface parity but ALWAYS throw \LogicException — alt-screen machinery lives only on the full Terminal\Terminal.
use SugarCraft\Vterm\Terminal;       // root — renderer path
use SugarCraft\Vterm\Theme;

$vt = Terminal::new(cols: 80, rows: 24, theme: Theme::tokyoNight());
$vt = $vt->feed("\x1b[1;1H\x1b[31mhello\x1b[0m");
$snapshot = $vt->snapshot(time: 1.234);

The root Terminal MUTATES in place — feed() writes into the terminal's own grid and returns $this purely for chaining (it is not an immutable fluent builder). Frames are frozen explicitly: snapshot() deep-copies the grid, so a captured Snapshot never retro-mutates as later feeds land. The root path uses the HandlerAdapter + CsiHandlerImpl + OscHandlerImpl triple internally. The full Terminal\Terminal also mutates in place (feed(): void) — it's optimised for byte-stream ingest rather than per-frame snapshots.

Renderer value objects (vcr path)

The SugarCraft\Vterm root namespace provides simplified value objects for the candy-vcr VHS renderer path — independent of the full VT parser stack:

use SugarCraft\Vterm\Buffer\Buffer;
use SugarCraft\Vterm\Cell;
use SugarCraft\Vterm\Cursor;

// Cell — char + fg (0-255) + bg (0-255) + attrs bitfield
$cell = new Cell(char: 'X', fg: 196, bg: 21, attrs: Cell::ATTR_BOLD);
$cell = $cell->withFg(34);            // green foreground
$cell = $cell->withBg(226);           // yellow background
$cell = $cell->withAttrs(Cell::ATTR_ITALIC | Cell::ATTR_UNDERLINE);

// Buffer — the one 2D cell grid (emulator + renderer), mutable put(), dirty-region tracking
$grid = new Buffer(cols: 80, rows: 24);
$grid->put(0, 0, new Cell(char: 'H')); // in place, returns void
$grid->put(0, 1, new Cell(char: 'i'));
echo $grid->cell(0, 0)->char;         // 'H'
echo implode(',', $grid->dirtyRegion()); // minRow, maxRow, minCol, maxCol
$grid = $grid->clear();                // NEW empty grid, same size; dirty box resets
$grid = $grid->resize(100, 40);        // NEW grid; content within bounds kept,
                                       // cuts discarded, edge wide-base erased,
                                       // whole viewport marked dirty for repaint

// Cursor — row + col + shape + visibility
$cursor = new Cursor(row: 0, col: 0, shape: 0, visible: true);
$cursor = $cursor->at(5, 10);        // move to row 5, col 10
$cursor = $cursor->withShape(2);      // shape 2 = steady block
$cursor = $cursor->hidden();           // hide cursor
$cursor = $cursor->shown();          // show cursor

Parser handlers

Each handler translates parser actions into handler state mutations:

  • CursorHandler — CUP, HVP, CUU/CUD/CUF/CUB, CR, LF/VT/FF, BS, RI, HOME, DECSC/USC
  • SgrHandler — SGR sequences (colour + attributes)
  • EraseHandler — ED, EL, ECH
  • ScrollHandler — SU, SD, DECSTBM
  • ModeHandler — DECSET/DECRST/DECMODESET/DECMODERST (includes DECAWM mode 7)
  • TabHandler — TBC, HTS
  • OscHandler — OSC window title, hyperlink, colour palette
  • ScreenHandler — orchestrates all of the above; owns the Buffer

Snapshot

SugarCraft\Vterm\Snapshot is an immutable point-in-time grid + cursor + feed revision. Captures through Snapshot::of() or Terminal::snapshot() deep-copy the grid, so a snapshot keeps showing the frame as it was even after the terminal consumes more bytes:

use SugarCraft\Vterm\Snapshot;
use SugarCraft\Vterm\Terminal;

$terminal = Terminal::new(cols: 80, rows: 24)->feed("hello\r\n");

$snap = Snapshot::of($terminal, time: 1.234);  // deep-copies grid + cursor
$snap->grid;       // Buffer — frozen copy, unaffected by later feeds
$snap->cursor;     // Cursor
$snap->time;       // float — virtual playback time
$snap->revision;   // int — terminal's feed counter at capture time

$terminal->feed("world\r\n");                  // live grid moves on...
$snap->grid->cell(1, 0)->grapheme;             // ' ' — snapshot's row 1 stayed blank

$replay = Snapshot::of($terminal, time: 1.234);

$snap->equals($replay);          // false — the second feed changed the grid
$snap->equalsWithTime($replay);  // false — grid + cursor + time strict check

Build a Snapshot directly from its constructor only when you already own the Buffer — the constructor stores what it is given without copying.

Snapshot::equals() is the dedup signal used by candy-vcr's FrameDedup to collapse visually identical adjacent frames before GIF encoding. Terminal::revision() gives the cheaper first pass: two captures with the same revision saw no intervening write path at all.

Parser state machine

The VT500 state machine is not in this package — it is owned by sugar-ansi-parser as SugarCraft\AnsiParser\Parser\Parser, a Paul Williams machine (reference) that drives a AnsiParser\Parser\Handler interface with parsed actions. sugar-vterm consumes it directly — both Terminal\Terminal and the root Terminal import SugarCraft\AnsiParser\Parser\Parser by use statement (src/Terminal/Terminal.php, src/Terminal.php) and feed it the same byte chunks; what lives here is the screen-side handler layer that the parser drives.

Component Owner Role
AnsiParser\Parser\State sugar-ansi-parser Enum of parser states: Ground, Escape, CsiEntry, CsiParam, CsiIntermediate, CsiIgnore, DcsEntry, OscString, etc.
AnsiParser\Parser\Action sugar-ansi-parser Enum of dispatch actions: Print, Execute, CsiDispatch, EscDispatch, OscDispatch, DcsDispatch, Hook, Put, Unhook.
AnsiParser\Parser\Transitions sugar-ansi-parser Static transition table — (state, byte) → (action, next_state).
AnsiParser\Parser\Handler sugar-ansi-parser The contract the parser drives.
AnsiParser\Parser\CsiHandler sugar-ansi-parser CSI dispatch contract — printable, cuu/cud/cuf/cub, cup, sgr, ed/el, decset/decrst, decstbm, tbc, cht/cbt, gridRows.
AnsiParser\Parser\OscHandler sugar-ansi-parser OSC dispatch contract — title, hyperlink.
AnsiParser\Parser\HandlerAdapter sugar-ansi-parser Glue that wires CsiHandler + OscHandler into a single Handler for the parser.
Parser\CsiHandlerImpl sugar-vterm Default CSI handler used by the root Terminal renderer path.
Parser\OscHandlerImpl sugar-vterm Default OSC handler — tracks last window title.
Parser\DebugHandler sugar-vterm Local debug sink that dumps every parsed action.
Parser\RendererHandler sugar-vterm Screen-writing handler for the renderer path.

Partial input is supported — feed any byte boundary and the parser state persists across feed() calls. Subparameter colons (CSI 4:2 m → double underline) are parsed natively.

The full emulator also speaks ReactPHP: feedAsync() parses a chunk and resolves with the terminal→host reply bytes (DA1/CPR/XTWINOPS …) instead of requiring the feed($bytes, $respond) callback, and feedStream() pumps a ReadableStreamInterface (PTY output, socket) through the same parser — incrementally per data event, flushed at end, rejecting on error or a close that never signalled end-of-stream. The synchronous feed() surface is unchanged; these are additive.

CSI coverage table

The default CsiHandlerImpl (used by SugarCraft\Vterm\Terminal) implements the renderer-relevant subset of CSI dispatches. The full Handler\ScreenHandler (used by SugarCraft\Vterm\Terminal\Terminal) implements the superset.

Final byte Name Method Behavior
@ ICH ich($n=1) Insert N blank characters at cursor.
A CUU cuu($n=1) Cursor up N, clamped to scroll region top.
B CUD cud($n=1) Cursor down N, clamped to scroll region bottom.
b REP rep($n=1) Repeat the preceding printable N times (renderer only; the emulator CSI dispatcher has no b arm and discards it).
C CUF cuf($n=1) Cursor right N, clamped to right margin.
D CUB cub($n=1) Cursor left N, clamped to column 0.
E CNL (Handler) Cursor down N + column 0.
F CPL (Handler) Cursor up N + column 0.
G CHA (Handler) Cursor to column N.
H CUP cup($row, $col) Move cursor to (row, col) 1-indexed.
I CHT cht($n=1) Cursor forward N tab stops (emulator honours the tab-stop table; the renderer single-steps).
J ED ed($mode=0) Erase display: 0=cursor→end, 1=begin→cursor, 2=all, 3=all+scrollback (emulator clears the ring; the renderer has no ring, so 3 blanks the grid).
K EL el($mode=0) Erase line: 0=cursor→eol, 1=bol→cursor, 2=full line.
L IL il($n=1) Insert N blank lines at cursor.
M DL dl($n=1) Delete N lines at cursor.
P DCH dch($n=1) Delete N characters at cursor.
S SU su($n=1) Scroll up N lines.
T SD sd($n=1) Scroll down N lines.
X ECH (Handler) Erase N characters from cursor (preserves BCE bg).
Z CBT cbt($n=1) Cursor backward N tab stops (emulator honours the tab-stop table; the renderer single-steps).
d VPA (Handler) Cursor to row N.
f HVP hvp($row, $col) Same as CUP — move cursor to (row, col).
g TBC tbc($mode=0) Tab clear: 0=column, 3=all (renderer: no-op — tab stops unmodelled).
h SM/DECSET decset($mode, $prefix) Set mode. ? prefix → private. ?7 h enables DECAWM, ?12 h BCE, ?25 h cursor visible, ?47/?1047/?1049 h alt screen, ?1004 h focus events, ?2026 h sync-update, ?6 h DECOM.
l RM/DECRST decrst($mode, $prefix) Reset mode (mirror of h).
m SGR sgr($params) Set graphic rendition. Subparam colons (4:1..4:5) parsed.
r DECSTBM decstbm($top, $bottom) Set scroll region rows (1-indexed).
s DECSC scosc() Save cursor + Sgr + origin mode (emulator); the renderer saves the cursor position only.
u DECRC scorc() Restore cursor + Sgr + origin mode (emulator); the renderer restores the position only.
<n> SP q DECSCUSR (Handler) Cursor shape 0–6 (block/underline/bar × blink).
<n> I/O Focus (Handler) Focus-in / focus-out report (under ?1004 h).

(Handler) rows live in the full Handler/ScreenHandler dispatcher used by SugarCraft\Vterm\Terminal\Terminal; the renderer's upstream HandlerAdapter does not route those final bytes, so the renderer path silently discards them. Rows with an explicit Method are implemented by Parser/CsiHandlerImpl — the emulator implements a superset of those. Three asymmetries are deliberate and pinned out of tests/EngineDifferentialTest.php's corpus: tab handling (I/Z/g — the emulator walks a tab-stop table, the renderer single-steps and TBC no-ops), b REP (renderer only, the emulator discards it), and BCE background-color erase (X ECH and mode-3 scrollback clearing are emulator-only). The renderer honours the same DECAWM/DECTCEM private modes, origin-mode clamps, and SGR set as the emulator.

The printable and execute paths also route through the same adapter — printable bytes call CsiHandlerImpl::printable() (writes the cell and defers the wrap at the right margin, exactly like the emulator's ScreenHandler), and C0 controls (\b \t \n \r) call the corresponding cursor handler.

OSC coverage

The default OscHandlerImpl (renderer path) implements:

Sequence Method Behavior
OSC 0;<title> BEL/ST title($title) Set window title (and icon name).
OSC 2;<title> BEL/ST title($title) Set window title only.
OSC 8;<id>;<uri> BEL/ST hyperlink($uri, $id) Begin/end hyperlink (OSC 8). Empty URI ends the link.
lastTitle() accessor Most recent title set during the stream.

The full Handler\OscHandler (used by SugarCraft\Vterm\Terminal\Terminal) also handles palette set (OSC 4 — writes only, no query reply) and clipboard get/set requests (OSC 52). The OSC 52 log is a bounded ring: a remote program can arm OSC 52 itself, so past 1024 entries the oldest event is dropped. Drain it with Terminal::clipboardEvents(), which returns and clears the backlog (like replies()). OSC 10/OSC 11 (default foreground/background query and set) are discarded — the emulator keeps no host-window or default-colour state. Focus and mode-query events are CSI-driven, not OSC: see the focus-reporting and DEC 2026 sections below.

SGR attributes

Sgr\Sgr tracks the current pen state. Supported attributes:

SGR param Effect
0 Reset all attributes.
1 Bold.
2 Faint.
3 Italic.
4 (or 4:1) Single underline.
4:2 / 4:3 / 4:4 / 4:5 Double / Curly / Dotted / Dashed underline.
5 / 6 Blink (slow and rapid fold onto one bit).
7 Inverse (swap fg/bg).
8 Hidden (conceal).
9 Strikethrough.
21 Accepted and ignored — the SGR handler has no arm for it.
22 Reset bold + faint.
23 / 24 / 25 / 27 / 28 / 29 Reset italic / underline / blink / inverse / hidden / strikethrough.
30..37 / 90..97 Set 8 + 8 bright ANSI foreground.
38;5;<n> / 38;2;<r>;<g>;<b> 256-color / truecolor foreground.
40..47 / 100..107 Set 8 + 8 bright ANSI background.
48;5;<n> / 48;2;<r>;<g>;<b> 256-color / truecolor background.
39 / 49 Reset fg / bg to default.
58;5/58;2 / 59 Underline-colour specifiers consumed and discarded (no per-style colour state).

Attribute bitfield exposed as Cell::ATTR_* constants: ATTR_BOLD, ATTR_ITALIC, ATTR_UNDERLINE, ATTR_INVERSE, ATTR_STRIKETHROUGH.

Background Color Erase (BCE)

When BCE mode is enabled (CSI ?12 h), erase operations (CSI K, CSI J, CSI X) fill cleared cells with the current SGR background color instead of a plain blank cell. This prevents "ghosting" in terminals with colored backgrounds — the erased area retains its visual background rather than going black.

use SugarCraft\Vterm\Color\Color;  // color value object
use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);
$vt->feed("\x1b[48;5;196m\x1b[?12h");  // red background + BCE on
$vt->feed("\x1b[2J");                   // erase screen — cells carry red bg

$bg = $vt->screen()->cell(0, 0)->background();  // ?Color on the erased cell
echo $bg->value, "\n";                  // 196 — palette slot ($bg->kind is 2 = indexed-256)
echo Color::truecolor(255, 0, 0)->red(), "\n"; // 255 — truecolor splits via red()/green()/blue()

BCE state is tracked via the Sgr object's background color — the pen must be set before the erase sequence for the background to carry through.

Diff API

After feeding bytes, snapshot the screen and compare:

use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);
$vt->feed("before\r\n");
$before = $vt->screen();

$vt->feed("after\r\n");
$after  = $vt->screen();

foreach ($before->diff($after) as $change) {
    // Each change is ['row' => int, 'col' => int, 'prev' => Cell, 'next' => Cell].
    echo "{$change['row']},{$change['col']}: '{$change['prev']->grapheme}' → '{$change['next']->grapheme}'\n";
}

Auto-wrap (DECAWM)

DECAWM (CSI ? 7 h / CSI ? 7 l) controls whether the cursor automatically wraps to the next line when a character is printed at the rightmost column. The Mode object exposes this as:

use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);

// Query the current state.
$mode = $vt->mode();
var_dump($mode->autoWrap);  // bool

// Build a new mode with auto-wrap forced on/off.
$wrapped = $mode->withAutoWrap(true);

When auto-wrap is on (the default, matching xterm/VT/ANSI.SYS), a graphic landing in the rightmost column parks the cursor there arming a deferred (phantom) wrap; the next graphic consumes it, moving to column 0 of the next row before writing — and if that row is within a scroll region the region scrolls up by one line, matching VT100 behavior. When off, the cursor clamps at the rightmost column and subsequent graphics overwrite it (the phantom flag is set by geometry alone, so a mid-stream ?7l / ?7h toggles like xterm).

Scroll regions (DECSTBM, CSI r) and auto-wrap interact correctly: wrapping at the bottom of a scroll region triggers a scroll within that region, not on the whole buffer.

SGR underline styles

SGR CSI 4:N controls the underline style. The Sgr object exposes this as an UnderlineStyle enum and a fluent setter:

use SugarCraft\Vterm\Sgr\Sgr;
use SugarCraft\Vterm\Sgr\UnderlineStyle;

// Build an Sgr state with a specific underline style.
$sgr = Sgr::empty()->withUnderlineStyle(UnderlineStyle::Double);
echo $sgr->underline;           // true
echo $sgr->underlineStyle->value; // 2 (Double)

UnderlineStyle values map to CSI sequences as follows:

Enum value CSI sequence Description
None CSI 4:0 m No underline
Single CSI 4:1 m Single underline
Double CSI 4:2 m Double underline
Curly CSI 4:3 m Curly underline
Dotted CSI 4:4 m Dotted underline
Dashed CSI 4:5 m Dashed underline

CSI 24 m clears underline (any style). Plain CSI 4 m (no subparam) is equivalent to 4:1 (single).

Scrollback buffer

When the screen scrolls (LF at the bottom, DECSTBM scroll region, or auto-wrap at the bottom of a region), rows pushed off the top are stored in a ring-buffer Scrollback. Access it via:

use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 4);
foreach (range(1, 6) as $n) {
    $vt->feed("line $n\r\n");  // each LF at the bottom row scrolls one off: lines 1-3 land in the ring
}

$screen = $vt->screen();
$scrollback = $screen->scrollback();

foreach ($scrollback->all() as $rowIndex => $row) {
    // $row is array<int, Cell>
    echo "scrollback row $rowIndex\n";
}

The default capacity is 1000 rows. Configure it at construction or retrospectively:

use SugarCraft\Vterm\Terminal\Terminal;

// Set at construction.
$vt = Terminal::new(cols: 80, rows: 24, scrollbackSize: 5000);

// Retrospective change — existing scrollback is replaced.
$vt = $vt->withScrollbackSize(5000);

Available Scrollback accessors:

use SugarCraft\Vterm\Terminal\Terminal;

$scrollback = Terminal::new(cols: 80, rows: 24)->screen()->scrollback();

$scrollback->count();     // int — rows currently stored
$scrollback->maxSize();   // int — configured capacity
$scrollback->at(0);       // array<int, Cell>|null — row offset N from oldest
$scrollback->all();       // array<int, array<int, Cell>> — all rows oldest→newest

The scrollback is consumed by ScreenHandler's scroll-up and scroll-down operations (SU/SD/IND/RI/NEL). Erase-of-scrollback (CSI 3 J) clears the ring buffer.

Origin mode (DECOM)

DECOM (CSI ?6 h / CSI ?6 l) controls whether cursor addressing is relative to the absolute screen origin or to the scroll region defined by DECSTBM. The Mode object exposes this as:

use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);

$mode = $vt->mode();
var_dump($mode->originMode);  // bool

$origin = $mode->withOriginMode(true);

When origin mode is on, the cursor is constrained to the scroll region and cursor-addressing commands (CSI row;col H, CSI row;col f) treat (1,1) as the top-left of the DECSTBM region. When off (the default), (1,1) is the absolute top-left of the screen. Leaving origin mode (CSI ?6 l) flips only the mode: the DECSTBM region stays armed and is re-adopted when origin mode is re-enabled.

Cursor shape (DECSCUSR)

DECSCUSR (CSI Ps SP q) sets the terminal cursor shape. The handler records it on two fields — Cursor::$shape, the copy a renderer would read, and Mode::$cursorShape, the copy mode state carries — which the handler writes together and keeps equal across every rebuild of the cursor. Mode exposes the value as an integer:

use SugarCraft\Vterm\CursorShape;
use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);

$mode = $vt->mode();
var_dump($mode->cursorShape);  // int (0–6)

// Build a new mode with a specific cursor shape.
$bar = $mode->withCursorShape(CursorShape::SteadyBar->toInt());

CursorShape values map to VT sequences:

Enum value Value CSI sequence Description
BlinkingBlock 0/1 CSI 0 SP q Blinking block
SteadyBlock 2 CSI 2 SP q Steady block
BlinkingUnderline 3 CSI 3 SP q Blinking underline
SteadyUnderline 4 CSI 4 SP q Steady underline
BlinkingBar 5 CSI 5 SP q Blinking vertical bar
SteadyBar 6 CSI 6 SP q Steady vertical bar

CursorShape::fromInt() normalises both 0 and 1 to BlinkingBlock to match the VT spec.

Across the reset and screen-swap paths the shape follows xterm-411: RIS (ESC c) and DECSTR (CSI ! p) reset it to 0 on both fields, while DECALN (ESC # 8) and the alt-screen swaps (DECSET 1048/1049) preserve it. Injecting both a Cursor and a Mode into ScreenHandler is the one documented case where the pair is taken exactly as supplied. DECSTR resets it because xterm's soft reset shares RIS's cursor block and InitCursorShape() recomputes the style from resources, so a DECSCUSR setting does not outlive a soft reset.

Focus events (mode 1004)

When focus event reporting is enabled (CSI ?1004 h / CSI ?1004 l), the terminal records focus-in (CSI I) and focus-out (CSI O) events. The Mode object tracks the reporting flag:

use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);
$vt->feed("\x1b[?1004h");  // start reporting focus in/out

$mode = $vt->mode();
var_dump($mode->reportFocusEvents);  // bool — true under mode 1004

$tracking = $mode->withReportFocusEvents(true);

Events land in a bounded focus ring inside the handler (drop-oldest past 1024 entries — a hostile stream can arm mode 1004 and flood CSI I/O), as FocusInMsg / FocusOutMsg value objects. The emulator facade drains it: Terminal::focusEvents() returns the backlog oldest-first and clears it, so poll after each feed. For live notification instead of draining, pass an onFocusEvent callback to the constructor.

Combining characters and wide characters

ScreenHandler::printChar() categorises every incoming rune by width:

  • Wide (width ≥ 2): CJK and most emoji occupy 2 cells. A Cell::continuation() marker is written to the second cell and inherits SGR + hyperlink from the base cell. The cursor advances by the full width.
  • Normal (width 1): Written to a single cell.
  • Combining mark (width 0): Zero-width Unicode combining diacritical marks (U+0300–U+036F) are not silently discarded. Instead they are attached to the preceding cell via Cell::withCombining() so the base character and its combining marks render as a single composed grapheme in one column:
use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);
$vt->feed("\x1b[31me\xcc\x81");  // 'e' + combining acute accent → 'é'
$cell = $vt->screen()->cell(0, 0);
echo $cell->grapheme, "\n";   // 'e'
echo $cell->combining, "\n";  // "\xcc\x81" — attach to grapheme for full cluster

Cell::$combining is a plain string; $cell->withCombining($mark) appends to it. The $combining field is compared in Cell::equals() so snapshots that include composed characters compare correctly.

Combining marks that arrive at column 0 or immediately after a wide-char continuation cell are silently dropped — nothing in the preceding column to attach to.

Synchronized output (DEC 2026)

DEC 2026 (CSI ?2026 h / CSI ?2026 l) enables synchronized output mode. While active, all buffer mutations (character writes, combining attachments, erase fills) are held in a queue on ScreenHandler instead of being applied immediately. When the mode is disabled (CSI ?2026 l), all queued mutations are replayed atomically. This prevents mid-sequence screen updates from being visible to the user, matching xterm's batched update behavior.

use SugarCraft\Vterm\Terminal\Terminal;

$vt = Terminal::new(cols: 80, rows: 24);

$mode = $vt->mode();
var_dump($mode->syncUpdate);  // bool — false until DEC 2026 is set

$vt->feed("\x1b[?2026h");       // enter sync-update mode
$vt->feed("\x1b[31mA\x1b[0m");  // queued, not yet visible
$vt->feed("\x1b[?2026l");       // flush → both cells appear together
var_dump($vt->screen()->cell(0, 0)->grapheme);  // 'A'

Mutations are queued on the handler's internal sync buffer and replayed atomically when the window closes. BCE erase (CSI ?12 h) and combining-char attachments are both eligible for queued writes. The queue is bounded: past 16 384 pending mutations a partial flush lands to the grid, so an adversarial flood costs memory safety before it costs content — all-or-nothing is surrendered only under hostile pressure.

Theme catalog

Theme provides a 256-color palette with factory methods for named themes. Themes is the catalog listing all available themes:

use SugarCraft\Vterm\Theme;
use SugarCraft\Vterm\Themes;

// Tokyo Night — the standard theme of the monorepo's 305 .tape demo files
$theme = Theme::tokyoNight();

// Index into the 256-color palette
$red = $theme->color(1);       // 0xf7768e

// Resolve RGB components from a 256-color index
$rgb = Theme::rgb(196);        // [255, 0, 0]

// Map ANSI 0-15 slot to its 256-color index
$idx = Theme::fgIndex(1);        // 1 (Ansi1 → index 1)

// V1-ready themes — every bundled theme carries a complete palette,
// so v1() currently lists the same five themes as all()
foreach (Themes::v1() as $name => $theme) {
    echo "$name\n";
}
// TokyoNight, TokyoNightLight, TokyoNightStorm, Dracula, SolarizedDark

// All themes in the catalog
foreach (Themes::all() as $name => $theme) {
    echo "$name\n";
}
// TokyoNight, TokyoNightLight, TokyoNightStorm, Dracula, SolarizedDark

Available themes:

Theme Status Description
TokyoNight ✅ v1 Dark theme — the standard of the 305 monorepo tapes
TokyoNightLight ✅ v1 Light variant of TokyoNight
TokyoNightStorm ✅ v1 Storm variant of TokyoNight
Dracula ✅ v1 Full Dracula palette
SolarizedDark ✅ v1 Full Solarized Dark palette

Attribute constants on Theme match Cell::ATTR_* for SGR bitfield construction: Theme::ATTR_BOLD, Theme::ATTR_ITALIC, Theme::ATTR_UNDERLINE, Theme::ATTR_INVERSE, Theme::ATTR_STRIKETHROUGH.

Theme accessors

Method Returns
$theme->color($index) (instance) int — packed 24-bit RGB for palette slot 0..255. Falls back through Theme::rgb() for grayscale (232..255) when a slot is absent.
Theme::rgb($index) array{int,int,int} — [R,G,B] resolution for any 256-color index, including the 6x6x6 color cube (16..231) and the 24-step grayscale ramp (232..255).
Theme::fgIndex($slot) int — Map an ANSI 0..15 slot to its 256-color index (currently a no-op identity, kept for spec parity).
Theme::bgIndex($slot) int — Same for background slot.
Theme::defaultPalette() array<int,int> — Bootstrap palette (xterm defaults); the Theme constructor falls back to it when no palette is passed (Themes::ansi() does not exist).
Theme::ANSI_OFFSET / Theme::CUBE_OFFSET / Theme::GRAYSCALE_OFFSET int constants 0 / 16 / 232 — palette region boundaries.

Subsystems

Each subdirectory of src/ owns a small piece of VT state:

Namespace Purpose Public surface
Buffer\Buffer Cell grid storage (rows × cols array of Cell). cell($row, $col) (either coordinate out of bounds → Cell::empty()), put($row, $col, $cell) (out-of-grid writes silently ignored), insertRows($count, $from, ?$top, ?$bottom), deleteRows($count, $from, ?$top, ?$bottom), dirtyRegion(), each(): Generator, copy(): array, resize($cols, $rows): self (refuses dims < 1, repairs wide pairs at the cut, marks the whole viewport dirty).
Screen\Screen Immutable snapshot of Buffer + Scrollback; cell-level diff API. fromBuffer($buf, ?$scrollback), lines() (Generator: full-width rows, combining marks re-appended), linesWithHistory() (Generator: scrolled-off rows oldest-first, then visible), cell($row, $col) (out-of-bounds in EITHER coordinate returns Cell::empty()), scrollback(), diff($other).
Screen\Scrollback Ring buffer (default 1000 rows) for rows scrolled off the top. count(), maxSize(), at($n), all().
Cell\Cell Cell-grid cell — grapheme + Sgr + Hyperlink + combining marks. Used by the full Terminal facade. empty(), continuation($prev), withCombining($s), sgr(), foreground(), background(), equals().
Cursor\Cursor Position + visibility + shape + saved-state. withRow($r), withCol($c), withVisible($v), withShape($s), save(), restore(), equals().
Sgr\Sgr Pen state (fg, bg, attrs bitfield, underline style). empty(), withBold/Italic/Underline/Strikethrough/Blink/Reverse/Dim/Hidden($v), withUnderlineStyle($style), withForeground(?$color), withBackground(?$color), equals().
Sgr\UnderlineStyle Enum: None/Single/Double/Curly/Dotted/Dashed. Backed int enum (None=0 … Dashed=5); ->value is the CSI code (no fromInt()).
Mode\Mode DEC private modes. withAltScreen/AltScreenVariant/CursorVisible/MouseEncoding/MouseHighlights/MouseAny/MouseCellMotion/MouseExtended/BracketedPaste/SyncUpdate/AutoWrap/OriginMode/CursorShape/ReportFocusEvents, isAltScreen(), isMouseReporting(), equals().
Hyperlink\Hyperlink OSC 8 URI + id state. fromRaw($id, $uri), equals().
Msg\FocusInMsg / FocusOutMsg Focus event records — land in the emulator's bounded focus ring (DEC mode 1004) and drain via Terminal::focusEvents(). Plain DTOs.
Color\Color Color value object — palette index or truecolor RGB. default(), indexed16($i), indexed256($i), truecolor($r,$g,$b), fromInt($kind,$v), red(), green(), blue(), equals().
CursorShape (root enum) BlinkingBlock (0/1) / SteadyBlock (2) / BlinkingUnderline (3) / SteadyUnderline (4) / BlinkingBar (5) / SteadyBar (6). fromInt(), toInt().

Trust boundary and hostile input

Both facades exist to parse bytes emitted by UNTRUSTED programs, so every unbounded channel is capped and nothing leaves the grid unsanitised:

  • Parser string buffer — Terminal::MAX_STRING_BUFFER (64 KiB, shared by both facade parsers). OSC/DCS payloads past the cap are truncated, so no per-stream allocation grows with attacker-chosen length.
  • Replies — the emulator's query-reply queue is a 1024-entry drop-oldest ring; under a DA/DSR flood an awaited reply can be evicted, so drain replies() (or pass the ?respond callback) promptly.
  • Clipboard / focus events — OSC 52 and DEC 1004 are self-armed by the remote program (no capability negotiation), so both logs are 1024-entry drop-oldest rings. clipboardEvents() and focusEvents() drain-on-read.
  • DEC 2026 sync queue — at most 16 384 pending cell writes; past that the queue partially replays to the grid. Atomicity is surrendered only under adversarial pressure, never silently dropped.
  • Scrollback — bounded ring (default 1000 rows); withScrollbackSize() drops oldest rows when shrinking. The renderer path keeps no scrollback.
  • Stored strings are attacker text — window titles, OSC 8 hyperlinks and OSC 52 payloads are kept VERBATIM; consumers must sanitise before display or onward propagation (Sanitize::untrusted() upstream).
  • Discarded without parsing — DCS, SOS, PM and APC payloads; malformed UTF-8 bytes (the upstream tokenizer emits no print event for them, so the grid only ever holds decoded graphemes).
  • BEL — counted via bells() on the emulator path only; the renderer discards BEL upstream in the adapter before any sink exists.
  • One-way doors — mouse, focus and bracketed-paste sequences are DECODED only. Mode\MouseEncoding describes the decoder slot; the library ships no encoders, so programs emitting those sequences build their own bytes.
  • No I/O — src/ never writes streams, spawns processes, or touches the filesystem; feedStream() merely accepts one.
  • Renderer silences — the CSI finals the parser adapter does not route (E F G d X c n t p q, SP q, and OSC 4/10/11/52) are discarded on the renderer path; the CSI/OSC coverage tables above are the per-sequence ledger for which engine implements what.

Development

cd sugar-vterm && composer install
vendor/bin/phpunit                                              # test suite
vendor/bin/phpstan analyze                                      # static analysis (level: max)
vendor/bin/php-cs-fixer fix --config=../.php-cs-fixer.dist.php  # lint + auto-fix style

Snapshot tests

Render output is covered by golden-file snapshot tests. Fixture files live in tests/fixtures/ with a .golden extension and are compared against actual ANSI byte output via SugarCraft\Testing\Snapshot\Assertions::assertGoldenAnsi(). To re-record a fixture after an intentional output change, delete it and re-run the test — assertGoldenAnsi() writes the file when it is missing (there is no env-var override):

rm tests/fixtures/<name>.golden
vendor/bin/phpunit --filter <TestName>

Code style is enforced by php-cs-fixer via the root .php-cs-fixer.dist.php (PSR-12 + declare_strict_types + strict_param + short array syntax). Append --dry-run --diff to preview without writing.

Related

Credits & inspiration

Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.