A terminal UI library for PHP, built bottom-up from one primitive: a Surface that is a terminal
emulator. Everything that shows on screen is a Surface; a shell in a pty and a PHP coroutine that
echos are the same kind of producer, writing the same bytes into it. On top: viewports onto
surfaces, a compositor for the real terminal, layouts (splits, stacks), and components with a
lifecycle and a coroutine of their own. Think tmux as a library, with Tether-style components.
Package phasync/tui, namespace phasync\Tui. PHP 8.3+. MIT.
Runtime: fiber-interop/api only. All concurrency goes through the fiber\ functions
(run, go, await, await_all, await_any, service, sleep, readable, writable,
fiber\Deferred, fiber\Timeout), so the library runs unchanged on phasync, Revolt/AMPHP and
ReactPHP. Its README and docs are in vendor/fiber-interop/api after install. The library's
composer.json requires fiber-interop/api and, as a dev dependency, the adapter
fiber-interop/phasync (which brings phasync/phasync); the tests run under that adapter. Do not
use the phasync class or phasync\Util\* in src/ (phasync's own types would tie the library to
one runtime). Two things fiber\ lacks are to be solved inside the library in the plain way:
- Cancellation is not in
fiber\: a component's coroutines end cooperatively. The component exposes a "stopped" awaitable (aDeferred) that resolves on unmount; the frame loop and other waits useawait_any([$work, $stopped])or short sleeps that checkisMounted(); aPtyends by killing its process, which ends the pump'sreadable(). - Byte queues between coroutines (pty output → Surface, keys → producer): a small internal
buffer class of this library, with
await-able readiness viaDeferred. If thefiber\surface proves genuinely insufficient for something the spec requires, say exactly what and why in DEVNOTES.md and in the report, with the smallestfiber\addition that would do; do not quietly reach for phasync, and do not work around the API's limits by going beneath it: no\FiberAPI (Fiber::getCurrent()and the like), no Swoole or Revolt calls, no assumption about what a task is. Whatfiber\go()returns is opaque: it is only ever passed toawait,await_allorawait_any, or compared with===againstfiber\current()(api 0.3.1), never inspected. A gap is a finding to report (the owner offiber-interop/apiwould rather add the primitive than have libraries guess), and until it is filled the library does without, in the plain way, even at some cost.
ext-phasync is optional: with it, phasync\ext\sapi() routes a coroutine's echo into a Surface
(the one place that may mention phasync, guarded by function_exists); without it, the application
calls $surface->write().
Audience: any PHP developer writing a command-line program, not only phasync users. The API must read naturally to someone who knows the terminal and PHP, with no knowledge of the internals. Design for the general case; no API exists for one demo.
- Structurally better than what PHP has. symfony/console, laravel/prompts and termwind print. symfony/tui (2026, experimental; widgets with a CSS-like stylesheet and Twig templates on Revolt) is a widget toolkit that paints cells: it has no terminal emulator and cannot host a program in a window. This library emulates: every window is a terminal, and widgets are programs writing to one. It also runs on any fiber-interop runtime, Revolt included. The yardsticks are the best of other languages: tmux and Zellij for windowed terminals and layouts, Textual (Python) and Ink (Node) for the component model, Ratatui and Bubble Tea for how widgets and events are shaped. Take their best idea per layer and make it read like PHP, not like a port.
- A real terminal in every window, no cheating. The Surface is a faithful xterm-class emulator
and is the only way pixels get on screen. No special cases for known programs, no shortcuts that
make the library's own output look right while a foreign program's would not, no shelling out to
tput/clearfor drawing. The windowed shell command (vim, htop, a build, an ssh session in a pane of a PHP program) is the differentiator; it only works if the emulator is honest. - Clean layers, one structure. Each layer depends only on the one below it and is usable alone: a Surface without a terminal, a Pty without a layout, hosting without components. And the structure is self-similar: the root is a Surface hosting Surfaces, each of which may host Surfaces; the real terminal is only a device attached to the root. No layer knows about phasync, Swerve or any application.
- Small, complete, consistent API. One way to do each thing; names a terminal person expects (rows/cols, scrollback, focus, cursor); the same shape for every producer. Documented so a program can be written from the docs alone.
A grid of cells with its own cols × rows, independent of the screen, plus scrollback.
- A cell: grapheme (one or more code points; wide East-Asian characters occupy two cells, the second a continuation), fg, bg (default / 16 / 256 / truecolor), attributes (bold, dim, italic, underline, blink, inverse, strikethrough, hidden).
write(string $bytes): the VT state machine. It parses ANSI/VT100–xterm byte streams and mutates the cells: printable text with autowrap; C0 controls (BS, HT, LF, CR, BEL); CSI cursor movement (CUU/CUD/CUF/CUB/CNL/CPL/CHA/CUP/VPA/HPA), ED/EL erase, IL/DL, ICH/DCH, ECH, SU/SD, DECSTBM scroll region, SGR (all attributes, 16/256/truecolor, reset), DECSET/DECRST private modes needed by common programs (DECAWM autowrap, DECTCEM cursor visibility, alt screen 1049, bracketed paste 2004, mouse modes 1000/1002/1003/1006, focus events 1004, synchronized output 2026, application cursor keys, Kitty keyboard protocol push/pop/query (CSI > u,CSI < u,CSI ? u) and modifyOtherKeys as state the Surface's key encoder honours), save/restore cursor (DECSC/DECRC and CSI s/u), tab stops, OSC strings (title: stored; others parsed and ignored), DCS/APC/PM/SOS strings parsed and ignored, and ESC sequences (RIS, IND, RI, NEL, HTS, charset designations parsed, DEC line drawing set honoured). Unknown sequences are consumed without corrupting the output. An escape sequence split across twowrite()calls is handled. Scrolling off the top goes to scrollback (configurable limit, default a few thousand lines).- Replies: a Surface answers the queries a program may send (DSR 5/6, DA primary/secondary,
XTVERSION, DECRQM mode queries, Kitty keyboard query, OSC 10/11 colour queries with a sensible
answer) by handing the reply bytes to its producer (see
Input), as the terminal it claims to be inTERM. Title changes are observable. OSC 52 clipboard writes from a program are passed up to the host terminal. - Widths: grapheme clusters by Unicode rules (combining marks, ZWJ emoji sequences, variation selectors), East Asian wide and emoji presentation as two cells, as modern terminals do; a program that assumes those widths (Ink does) must line up.
- ANSI is the only way to render into a Surface. There is no direct cell-drawing API
(no
put()/fill()): widgets, borders, graphs and the compositor's own drawing all go throughwrite()as escape sequences, so that every bug in the parser shows up at once in the library's own output. Correctness of the emulator comes before everything else in this library; a sequence that a real xterm handles must produce the same cells here. A smallAnsihelper that builds sequences (cursor to x,y; SGR for a style; erase) is welcome, as the producer side of the same protocol. - Parse character by character, properly. The parser is generated from a PEG grammar with
charm/parsing(Packagist;vendor/bin/charm-compile grammar.cpeg -o ... -c ...produces a standalone PHP class): the grammarsrc/Surface/ansi.cpegis the readable definition of what the emulator accepts (the VT500 structure: C0 controls, ESC sequences, CSI with params and intermediates, OSC/DCS/APC/PM/SOS strings with ST or BEL, UTF-8 text runs), the compiled class is committed next to it, and a test asserts the committed class is current. Eachwrite()chunk is parsed as a sequence of items with semantic actions applying them to the cells; a sequence cut off at the end of a chunk is matched by a lowest-priority "incomplete tail" rule and kept for the next chunk. Do not take shortcuts for speed; PHP is fast enough when the parser is written well (a cursor into the input, no copying of the remainder per step). Correctness first; measure later. If charm/parsing turns out unable to express or run this, say exactly why in DEVNOTES.md and in your report, and only then hand-write the state machine. - Reading the cells:
cell($x, $y),line($y)as text and as styled runs,resize($cols, $rows)(reflow is not required; preserve what fits). - Damage tracking: which rows (or cells) changed since the last
takeDamage(), so a compositor repaints only what changed. - Cursor: position, visibility, shape (as far as reported by the stream).
- Input side: a Surface has an input channel that the focused producer reads keys from. For a pty
the library writes to the master; for a coroutine the bytes are readable from a stream or
StringBufferof the Surface. Terminal replies (DSR, DA) go to the same channel. toString()/dump()for tests and headless use: the visible text, and a styled form.
- Pty: runs a command with
proc_open(..., [['pty'], ['pty'], ['pty']])and pumps its output into a Surface, keys from the Surface's input to the process, withfiber\go()coroutines andfiber\readable()/writable(), never blocking the loop. Resize:stty -F <slave> cols C rows Rfrom a helper process (sets the size and raises SIGWINCH in the child); the slave path comes from the child or/proc. Exposes exit status,wait(),kill(). The environment setsTERM=xterm-256colorandCOLORTERMto match what the Surface implements. - Coroutine:
Surface::run(Closure)runs the closure in a coroutine of the Surface's. With ext-phasync the closure runs underphasync\ext\sapi()so itsecho/print/printfandfwrite(STDOUT)-free output lands in the Surface; without it, the closure receives the Surface and writes with$surface->write(). Document the difference plainly. Either waysymfony/consoleorlaravel/prompts-style ANSI output in the closure renders correctly, because the Surface is a terminal. - Program-generated output: widgets and applications that draw (graphs, tables, borders)
produce escape sequences, with the
Ansihelper, andwrite()them like any other producer.
The design is self-similar: the root is a Surface hosting Surfaces. There is no separate "screen" kind of thing. A Surface can host child Surfaces, each placed through a Viewport (a rectangle on the host showing a rectangle of the child: offset, clipping, z-order, a scroll position that follows the tail or stays pinned in scrollback, optional border and title drawn by the host). A hosted Surface can itself host Surfaces, to any depth: a pane of a multiplexer running another multiplexer is the same structure one level down, and so is a component tree.
- Compositing is blitting (decided 2026-11-01; until 0.0.3-beta7 a host repainted children by
writing escape sequences to itself, which cost a serialise-and-parse at every nesting level and
was the source of every latency finding of 0.0.3). ANSI remains the only way to render into a
Surface: producers, components and the device's own drawing all go through
write(). Between Surfaces, cells are copied:Surface::copyArea(x, y, w, h): Areaslices a rectangle of rows (one array of graphemes and one of interned style ids per row, wide characters clipped to whole cells, an orphaned half becomes a space) andSurface::drawArea(Area, x, y)splices it in, clipped to the destination. Those two primitives, plus a shade transform for dialog shadows, are all a compositor may do; they arearray_slice/array_splicefirst and are optimised later if a measurement asks (packed cells are the known next step). Each frame the root builds one offscreen Surface by blitting every visible window in z-order with its clipping; containers that only arrange have no window of their own. - One diff, at the end. The root's offscreen frame is diffed against the frame the device last wrote, row by row, and only the changed runs become ANSI for the terminal; there is no diff and no damage bookkeeping between levels, because a recompose costs the visible area (a few thousand cells), whatever the depth of the tree or the number of Surfaces that changed. The diff compares visible identity: a blank cell's foreground, bold, dim and italic are invisible and do not count (background, underline, strike and inverse do), a blank with the default background equals an empty cell, and the ANSI for a blank run sets no foreground. Tests: the blit composite equals the previous ANSI composite on random window trees (property test); a one-cell change emits a few dozen bytes; two frames with the same visible glyphs and attributes emit nothing; a frame's compositing at 160x50 costs about a millisecond regardless of nesting depth.
- Scrolling, later. A pane scrolling one line changes all its rows; the diff then rewrites the
pane. ANSI can scroll a full-width band (
DECSTBM+SU/SD, universal) and, where the terminal hasDECSLRM(xterm, recent VTE, WezTerm; not everywhere), a narrower one. A row-shift detector in the final diff that emits those instead of the rows is an optimisation of the output volume only (bytes over ssh, not CPU), to be added when a measurement asks for it. - Input goes down the same tree. A host has one focused child (or itself). Key, mouse and paste events arrive at the root and are passed down the focus path; at the focused leaf, the Surface encodes them to the bytes its producer expects (application cursor keys, Kitty keyboard state, mouse mode, bracketed paste state of that Surface) and puts them on its input channel. Mouse coordinates are translated by each viewport on the way down. A host may handle an event itself first (a multiplexer's prefix key, a mouse drag for selection when the child has not asked for mouse reporting).
- Replies go up. A hosted Surface's replies to its producer's queries (DSR, DA, Kitty query, OSC 52 clipboard) stay with that Surface's producer; OSC 52 and title changes bubble to the root so the real terminal gets them.
- Resize goes down. Resizing a host re-lays its viewports (section 5), which resizes the children, which resize their ptys.
The root Surface is attached to the terminal by a Terminal device, which is only an I/O
adapter: it writes the root's damage to the tty as the minimum cursor moves and SGR runs against
the previous frame (at most 60 frames a second, only when damaged, driven by a fiber\go()
coroutine of the device that ends when it detaches), and decodes the tty's input into events for
the root (keys: legacy, SS3, CSI, CSI-u/Kitty, alt-modified, UTF-8 text; mouse: SGR 1006, buttons,
wheel, drag; bracketed paste; focus in/out; resize via SIGWINCH with pcntl or polling stty size).
It takes over the terminal (raw mode via stty, alternate screen, cursor hidden while composing,
mouse and bracketed paste and focus reporting enabled, the Kitty keyboard protocol pushed if the
host supports it) and restores it on exit, exceptions and signals.
- Headless: a
FakeTerminaldevice of a given size, for tests and headless programs: it exposes the root's frame as text and the emitted byte stream, and injects input events. Every feature is testable without a tty. - Self-hosting is the structure's own test: a root attached to a
FakeTerminalwhose byte stream is written into a Surface must yield the same cells as the root.
A program that runs several processes and shows a progress line for each, or a few progress bars
and a total, should not take the terminal over. Tty::inline() shows the root Surface in the
terminal's normal screen: no alternate screen, mouse reporting off unless the program asks (the
terminal's own selection and scrolling keep working), raw mode only while the region is live, the
terminal restored on exit with the last frame left standing (or erased, keep: false).
The model is the Surface's own: the root Surface's visible rows are the live region, and the
root's scrollback is the terminal's scrollback. The region sits at the bottom, under everything
the terminal already shows, and is as tall as the root's content, at most the terminal's height.
When the content grows past that, the root scrolls: the rows that leave the top of the Surface are
written once more into the terminal above the region, as they last looked, and are never touched
again. So a progress line that runs from 1% to 100% stays live while it is in the region and
freezes at whatever it showed when newer lines pushed it out, exactly as if the program had printed
it. Plain output from the program ($tty->println(...), or echo captured the same way as a
coroutine's into a Surface) goes to the same place: into the scrollback above the region, which is
redrawn under it.
The device paints with relative cursor motion only (the region moves whenever the terminal scrolls), repaints the damaged cells against the previous frame as in fullscreen mode, and parks the cursor on the line under the region. A width change repaints the whole region; a height change of the content scrolls the terminal by the difference and repaints. Resizing the terminal below the region's height scrolls the top rows out like content growth does.
Nothing above the device changes: the same component tree runs fullscreen with Tty::attach(),
inline with Tty::inline(), and headless under FakeTerminal (which gains the same mode, exposing
the frozen lines and the live region separately). Limits to state in the docs: while the region is
live the program reads its keys through the library, not fgets(STDIN); a hosted alternate-screen
program (vim) inside an inline region is possible but pointless.
Tests: five progress bars and a total under FakeTerminal::inline(); twenty process lines on a
10-row terminal, finishing in random order, where the frozen text of each line is what it showed
when it left the region; println() between frames puts its lines above the region and the region
is intact after; a byte-stream replay into a Surface of the same size shows the same frozen lines
and the same region.
- A layout arranges a host's viewports:
Split(horizontal/vertical) with children sized fixed, percentage or weight, with optional separators;Stack(z-order, for overlays and modal dialogs, with placement and size); nesting by giving a child Surface a layout of its own. Resizing re-lays children and resizes their Surfaces and ptys. - Focus: one focus owner;
Tab/Shift-Taborder, programmatic focus, mouse click to focus (opt-in).
A class owning a Surface and a lifecycle, after Tether: mount(), render() (either draw into
$this->surface directly or return a child layout), run() (a coroutine running while mounted;
go() for more, all told to stop on unmount through the component's stopped awaitable, and the
component's disposal waits for them briefly), requestRender() (batched into the next frame),
dispose(), children with keys, event handlers for keys/mouse/focus/resize, and an error boundary
that contains a child's failure to its rectangle. Built-in components, minimal but complete:
Terminal (a pty in a component), Log (append-only text with scrollback and follow),
TextInput (single line with editing keys and cursor), StatusBar, Box (border + title around
a child). More widgets belong to applications or later versions.
Phase 1 proved the base: a real terminal in every window. Phase 2 builds the layer applications are written in. Four decisions, then the widgets.
render() may still draw with Ansi (right for a graph), or return an element tree of plain
PHP objects: Column/Row (the Splits), Layers (the Stack), Box, Text, and every widget
below is an element. The library lays the tree out into the component's surface tree and paints
each element through write(), exactly as any producer; the ANSI-only rule holds. Elements
are cheap values rebuilt on every render; the library keys them (by position, or an explicit
key:) so that the Surfaces, scroll positions, focus and child components behind them persist
across renders, as Tether keys children. A child component is placed in the tree as an element
($this->child('log', fn () => new Log()) returned where it goes). No markup parser in this
phase; the tree is the model a Twig/HTML front-end can target later.
A component is rendered again in the next frame after: an event handler of its ran (key, mouse,
focus, a child's event); one of its coroutines (run(), go()) resumed from a wait and returned
to the scheduler; $this->state->set(...) or an assignment on $this->state (a small object the
component owns, with typed public properties, whose writes mark the component dirty); a child
emitted an event it listens to; its props changed (propsChanged()). Many changes in a tick are
one render; renders are batched into the frame as today. Computed values are methods; there are
no signals, observables or dependency tracking. Document the rule in one paragraph, and make
requestRender() the rare explicit call it should be.
Elements take a style: (or class:) and the application a Stylesheet: properties color,
background, bold, dim, italic, underline, inverse, border (style and colour),
padding, align, width/height constraints where the element allows; selectors by element
type, .class, #id, and states :focused, :disabled, :selected, :hover (mouse);
theme variables ($accent) and a default theme that looks good on dark and light terminals;
cascade: stylesheet rules → classes → inline. Not CSS layout; layout is the tree above.
A component declares bindings(): array (['ctrl+s' => 'save', 'enter' => 'submit']) mapping
to its public methods; the application overrides per component class or instance; the library
dispatches, and a Help element can list the active bindings. Handlers remain possible for
anything a map can't express.
A widget may show a surface on top of everything, outside its own rectangle and its parents'
clipping: a dropdown's list, autocomplete under an Editor, a context menu, a tooltip, a command
palette, and Dialog itself. One primitive, on the component:
$this->overlay($element, anchor: Rect|null, place: 'below'|'above'|'center'|Rect, modal: bool)
returns an Overlay handle (close(), closed() awaitable, result). The library hosts the
element on the root component (in a nested instance, the inner root) at absolute coordinates from
the anchor, and orders later overlays above earlier ones, keeping the order across renders. An
overlay's desired size is measured again every frame: when its content grows or shrinks it is
placed again and the cells it no longer covers are repainted by what is under them. The root's rectangle is a hard boundary: nothing of an overlay is ever placed outside
it (in a nested instance the inner root's pane is that boundary, as in tmux). Placement, in
order: the preferred side of the anchor if the overlay fits there; else the opposite side if it
fits; else the side with more room, with the overlay shrunk to that room and its content
scrolling (a dropdown of 30 items in 6 free rows shows 6 and scrolls; it never clips silently);
horizontally the overlay is shifted left or right to stay inside, and narrowed only when wider
than the root. Every overlay element therefore supports a height and width smaller than its
content (a ScrollView inside, or the element's own scrolling), and is re-placed by the same rules
when the root or the anchor changes. The decision is a pure function of what a floater needs to
know: the anchor's absolute rectangle (Surface::area()), the root's size, the overlay's desired
size and its placement preference; it returns the rectangle to host at. Implement it as a small
value class (Placement::for(anchor, root, desired, place)), tested on a table of cases (bottom
row, right edge, no room on either side, a terminal smaller than the overlay), used by overlay()
and available to any widget that wants to decide for itself; the area event is what triggers a
re-decision. Resizes, which happen at any time, go through the same path: the root's size
changes, the layout re-lays the tree, anchors move or disappear, and overlays are re-placed after
the layout pass of the frame that handles the resize and before painting (an anchor's rectangle
is not final earlier), once per frame however many resizes arrived. An overlay keeps its state
across re-placement (selection kept, the scroll offset adjusted so the selected row stays visible
in the new height); one whose anchor is no longer shown closes, with its result cancelled. Test:
open a dropdown at the bottom, shrink the terminal under it, grow it again; open a dialog, shrink
the terminal below the dialog's size. Focus moves
to the overlay by the surface tree's own rule; Escape, a click outside, or the anchor's component
losing focus closes a non-modal overlay; a modal one takes all input until closed; closing returns
the focus to where it was. The overlay follows its anchor when the anchor's area changes (resize,
scroll, layout rebuild) and closes when the anchor is no longer shown. Dialog is an overlay with
place: 'center' and modal: true; Select's popup and Editor's autocomplete are the first
non-modal users.
Each one complete, documented, with its states styled and its keyboard and mouse behaviour the one a terminal user expects:
Editor: TextInput grown up: multi-line, word wrap, undo/redo, kill ring, selection with shift+arrows and mouse, bracketed paste, amaxLines/single-line mode that replaces TextInput.Select(the ListView grown up): header row, sortable columns, filter-as-you-type, multi-select,onSelect/onActivate, keeps selection acrosssetItems().Table: columns with widths, alignment, truncation, horizontal scrolling of wide tables.Dialog: modal over aLayers, title, buttons, returns its result as an awaitable ($ok = await($this->dialog(new Confirm('Delete?')))), Escape cancels, focus returns.Form: fields (Editor,Checkbox,Radio,Select,Button), tab order, validation messages, submit.Tabs,ProgressBar,Spinner(driven from a coroutine),Tree(expand/collapse, lazy children),ScrollView(any content larger than its box, both axes).Splitseparators draggable with the mouse;Terminalgets a raw-output tap (onOutput).Markdownviewer (headings, lists, code blocks with ANSI highlighting through a pluggable highlighter, links), streaming-friendly: appending text re-renders only the tail.
Also: Ansi::clearHome() and the small helpers users kept writing; a clear error when a program
is started with a fiber-interop/api older than required.
The emulator, the hosting and the widgets exist. The next measure is how little a programmer has to know and write to get a dynamic UI that looks good. symfony/tui is the reference for this part: its Tailwind-style utility classes, its one-object entry point and its markup in text are what PHP programmers will expect; this library offers the same vocabulary on top of its own model, and is judged by the sixty-line dashboard at the end of this section.
A program is an element tree and a runner. Tui::run($root) takes an element, a component or a
closure returning either, attaches the Tty, runs until the root quits (Ctrl-Q by default when
nothing binds it) and gives the terminal back; Tui::inline($root) does the same in inline
mode (4.1); Tui::headless($root, cols, rows) returns the FakeTerminal. A closure is a
component: it is rendered again whenever state it reads changes, so
fiber\run(function () {
$s = new State(['done' => 0]);
fiber\go(function () use ($s) { for ($i = 1; $i <= 100; $i++) { fiber\sleep(0.05); $s->done = $i; } });
Tui::inline(fn() => new Column([
new Text("Working… {$s->done}%"),
new ProgressBar($s->done / 100),
]));
});is a whole program (fiber\run() is the one entry point of fiber\; go() outside it throws, and
run() returns when the program's own coroutines have finished, so a coroutine that should stop
with the screen checks a flag or the runner's result). Element content may be a closure (new Text(fn() => ...)) so a single
element can be dynamic without the tree around it being rebuilt. Input reaches a closure program
without a class: Tui::run($root, keys: ['down' => fn() => ..., 'shift+right' => ...]) binds
keys on the root (same syntax as bindings()), elements take onKey: and onMouse: closures
(an element with onKey: is focusable; one with onMouse: receives presses, drags and releases
over it), and Dialog::open($dialog) / Confirm::ask(...) are awaitable from any coroutine and
host the dialog on the running root. A component instance kept across renders ($bars[$i] ??= new ProgressBar(...)) keeps its identity, state and surface wherever it appears in the next tree.
Classes stay for anything with behaviour of its own; a closure component is the same thing
without a name.
class: takes utility classes next to the program's own, resolved by the stylesheet with the
Tailwind vocabulary (as symfony/tui spells it, so what people know works here):
- spacing:
p-{n},px-,py-,pt-/pr-/pb-/pl-,gap-{n},m-{n}(margin, the element's space inside its parent); - border:
border,border-round,border-double,border-heavy,border-ascii,border-none,border-{color},title-center; - colour:
text-{color}andbg-{color}, where a colour is a Tailwind family and shade (sky-400,slate-800), a hex ([#ff5500]), a palette index, or one of the 16 names (red,bright-blue); the device degrades truecolour to 256 or 16 colours when the terminal has no more, by nearest colour, in one place; - text:
bold,dim,italic,underline,strike,inverse,text-left/center/right,truncate,wrap/nowrap; - size and layout:
w-{n},h-{n},w-1/2,w-full,grow,grow-{n}(fr),shrink-0,items-start/center/end,justify-start/center/end/between,hidden; - variants:
focus:,hover:,disabled:,selected:prefixes for states, on elements as well as components (an element entersselectedthrough aselected:argument or astate:list,hoverthrough the pointer over it,focusthrough the focused component containing it); andsm:,md:,lg:,xl:for the root's width with Tailwind's min-width meaning: the base class applies at every width,sm:from 60 columns,md:from 80,lg:from 100,xl:from 140, later breakpoints overriding earlier ones, soclass: "flex-col md:flex-row"is a column under 80 columns and a row from 80 on.
Order in the cascade: stylesheet rules, then utility classes (later ones win), then style:.
Everything a utility class does is a stylesheet property too, so themes and utilities compose.
Text and every label (Box titles, Button labels, Table cells, Help, Log lines)
accept the markup PHP programmers know from symfony/console: <b>, <i>, <u>, <dim>,
<s>, <fg=red>, <bg=blue>, <fg=#ff5500>, <span class="text-sky-400 bold">, closed by
</>, with \< escaping and Text::raw() for none. Markup is parsed once per string and
renders through the same write() path as everything else.
Stylesheet::default() looks like a modern tool with no styling at all: rounded borders,
one accent colour, the focused window's border in the accent, dimmed titles elsewhere, a
visible cursor only where text is edited, Select rows in inverse, dialogs with a shadow.
Three themes ship (dark, light, mono), chosen by Tui::run($root, theme: ...) or
automatically from the terminal's background (OSC 11, dark by default), and a theme is a
stylesheet, so a program's own rules refine it. Every widget takes the common things as
constructor arguments (Box(title:, class:), Column(gap:, padding:, align:),
Text(align:)) so no stylesheet is needed for the first version of anything.
No child() for elements placed with new (keys do it), no requestRender() (state does it),
no Split/Stack for ordinary screens (Row/Column/Layers), no sizes in most places (9.2's
auto rules), no Surface unless hosting a program or writing a widget. docs/ gets a
"Cookbook" of ten screens, each under forty lines, and the README opens with the dashboard.
examples/dashboard.php, under sixty lines, no class: a header row with a Spinner and a title,
three Boxes side by side with a ProgressBar and a Text each, updated by three coroutines at
different rates, a Log that receives events from them, a Markdown pane with release notes,
and a Help line; styled with utility classes only, stacked vertically under 80 columns,
good-looking in all three themes, correct under FakeTerminal at 60×20 and 160×50, flicker-free
at 10 Hz, and the same program runs in inline mode with Tui::inline() showing the three
boxes only. The README shows it with a screenshot.
php-tui (MIT, a ratatui port) is the reference for this part: its Canvas with Braille/half-block/
block/dot markers and shapes (Line, Rectangle, Circle, Points, Sprite, a world Map), its Chart
(axes, datasets, line/scatter/bar graph types), BarChart, Sparkline, Gauge, LinearGradient, and
image rendering through ImageMagick and BDF bitmap fonts. This library offers the same things on its
own model: every one of them is a component that paints through write() into its Surface, so a
graph in a pane composites, scrolls, resizes and freezes (inline mode) like any other content.
Canvas(marker:) is a dot grid over its cells — braille (2x4 dots per cell, the default),
half (1x2 blocks), quarter (2x2), block, dot — with the browser's 2D context API
(decided by Frode 2026-11-01), so what PHP programmers know from CanvasRenderingContext2D works:
paths (beginPath, moveTo, lineTo, arc, arcTo, bezierCurveTo, quadraticCurveTo,
rect, closePath, stroke, fill, fillRect, strokeRect, clearRect), state
(fillStyle/strokeStyle taking a colour or a gradient from createLinearGradient()/
createRadialGradient() with addColorStop(), lineWidth, globalAlpha blending toward the
background, save/restore, translate/scale/rotate/transform, clip), text and images
(fillText places real glyphs at the nearest cell, one cell high, or a BDF font from 10.4;
drawImage samples an image into the dot grid or cells), pixels (getImageData/putImageData
on the dot grid). Stated differences from a browser: a pixel is a marker dot; colour is per cell
(dots sharing a cell share a foreground; a fill wins over a stroke where they meet; Braille dots
are glyphs, so their background cannot be set independently, which is what the block markers are
for); no anti-aliasing; lineWidth quantised to dots. Conveniences on top: line, circle,
points, polyline, sprite, and map() (the world coastline data php-tui ships, as data).
The canvas paints by assembling the dot grid into graphemes and SGR runs once per frame; one
whose drawing did not change paints nothing. A draw: fn(Canvas $c) closure draws from a
State, so the dashboard's graphs are live, and charts (10.2) draw through the same context so a
program can draw on top of a chart.
Pointer: a canvas with onMouse: or :hover asks for any-motion tracking (1003) and, when the device
probed it as supported, SGR-Pixels (CSI ?1016 h: xterm >= 359, foot, WezTerm, mlterm, kitty, Ghostty;
not VTE, Konsole, Windows Terminal or xterm.js). Its Mouse events carry the cell as always and the
position in the canvas's own coordinate space as floats (x, y): exact to the pixel where the
terminal reports pixels (mapped through the cell size from CSI 16 t), the cell's centre elsewhere,
with precise: bool saying which. The emulator understands 1016 so a hosted program gets pixel
reports when the outer terminal gives them.
Chart(datasets:, x:, y:) with Axis(title:, bounds:, labels:) and Dataset(points, type: line|scatter|bar, marker:, color:) renders through a Canvas; BarChart (groups, labels, values
on the bars, horizontal or vertical), Sparkline(values, max:) (one row of ▁▂▃▄▅▆▇█, or more rows),
Gauge(ratio, label:) (a ProgressBar with a gradient fill and a centred label) complete the set.
They take closures for their data like every other element, so a Sparkline(fn() => $s->samples)
updates when the State does. Default colours come from the theme's accent and its palette;
class: utilities apply.
Gradient::linear([$from, $to], angle:) with any number of stops, and Gradient::radial();
accepted wherever a colour is: a Text's text- colour (per grapheme along the string), a bg-
(across the element's box), a bar, a canvas shape, a border. Resolved per cell at paint time, to
truecolour, and degraded by the device like every colour. Utility class: bg-gradient-to-r,
from-sky-500, to-emerald-500 (Tailwind's spelling).
There is no separate image component (decided by Frode 2026-11-01): the Canvas is the only
graphics primitive. Its marker decides what its raster is. The glyph markers (braille, half,
quarter, block, dot) draw a dot grid with a small pure-PHP rasteriser and output text cells,
on every terminal. The pixels marker makes the raster a real bitmap of the canvas's cells times
the terminal's cell size in pixels (CSI 16 t, else 8x16 assumed), emitted as one image through
the device's protocol, probed once like hyperlinks (FakeTerminal configurable), in this order:
Kitty graphics (kitty, WezTerm, Ghostty, Konsole; its own query), iTerm2 inline images (OSC 1337 File=: iTerm2, WezTerm, Konsole and xterm.js with @xterm/addon-image, so VS Code's terminal and
a browser terminal; PNG, truecolour), Sixel (xterm, foot, mlterm, iTerm2, Windows Terminal,
xterm.js; 256 colours per image, dithered; DA1 ;4). With no protocol the same bitmap is
downsampled to quarter blocks; the program does not change. With ext-gd the pixel raster is a
GdImage and the context methods map onto GD's drawing, imagepng for Kitty/iTerm2 and
imagetruecolortopalette with GD's dithering for Sixel; drawImage() takes a GdImage, an
Imagick, a path or PNG bytes (a CDP screencast frame, a Phame frame), and fillText() with a
TTF path or a BDF font draws real type (the Figlet of this library is a canvas). Without GD the
pure-PHP rasteriser and a built-in PNG encoder do the same, slower.
In the cells: an image is interned like a hyperlink, one id per (image, column offset, row offset), so copyArea/drawArea copy it and the diff compares it without knowing what it is;
clipping and overlapping windows need nothing special. The device, after the diff, groups changed
image cells into rectangles of contiguous offsets: Kitty places fragments by cell (c=, r=,
x=, y=), the image bytes uploaded once per id; iTerm2 and Sixel cannot place fragments, so a
cropped copy is encoded per visible rectangle and cached by (id, rect). A glyph written over an
image cell drops the image id (the glyph wins, as in xterm and kitty). The emulator understands all
three protocols so a hosted program's images show too, with the same cell ids. A canvas may redraw
every frame (a Browser pane whose draw: closure drawImages the latest screencast frame, with
the pane's keys and mouse forwarded through CDP), placements following per frame.
examples/graphs.php becomes: a line Chart with two datasets and axes, a BarChart, a Sparkline
and a Gauge, all live from coroutines, in a dashboard that also shows an Image (the project
logo) and a BigText title, in the three themes, at 60x20 and 160x50, flicker-free at 10 Hz;
inline mode shows the Sparkline and Gauge only. A Canvas test draws the world Map at two sizes
and compares with recorded screens; a gradient test checks the per-cell colours; the image tests
run with and without GD/Imagick and on a Sixel, a Kitty and a plain device.
The library is judged first by hosting a demanding full-screen program in a window, with everything working, before any UI component matters. Two programs are the acceptance test:
- Claude Code (
claude, installed here: a Node/Ink program), in a pane next to a shell: typing, its redraws (cursor-up/erase-line repaints, no alt screen), colours, emoji and wide characters with correct widths, bracketed paste in, its cursor-position and terminal queries answered, keyboard protocol negotiation (Kitty keyboard / modifyOtherKeys: pass the host terminal's support through, or answer as a terminal that lacks it, consistently), synchronized output (mode 2026), focus events (1004), title changes, mouse (when the program asks for it) and scrolling, and copy and paste: when the pane has not asked for mouse reporting, mouse drag in a window selects text in that Surface, highlighted, and copies it to the system clipboard via OSC 52 to the host terminal (withxclip/wl-copy/pbcopyas fallback); the host's bracketed paste goes to the focused pane as a paste (bracketed if the pane enabled it). What makes this benchmark hard is exactly what the emulator must get right. - Self-hosting:
examples/split-shells.phprunning inside a pane of another instance of itself (as tmux inside tmux), nested twice, with resizes, focus and scrollback working at every level. Everything the library emits must be exactly what its own Surface understands.
A general examples/host.php <command...> runs any command in a window (with a status bar) so
both can be tried by hand; tests drive them through a root on a FakeTerminal and a pty.
composer.json(phasync/tui, PSR-4phasync\Tui\→src/; requiresfiber-interop/api,charm/parsingonly if the compiled parser needs it at runtime; dev:fiber-interop/phasync,pestphp/pest), all from Packagist.src/,tests/(Pest, run withvendor/bin/pestunder the phasync adapter, green; also green with ext-phasync loaded viaphp -d extension=vendor/phasync/phasync/ext/phasync-8.5-nts-x86_64-glibc.so, where theecho-capture path is exercised). The VT parser has known-answer tests for every sequence class above and tests from recorded real program output (e.g.ls --color, asymfony/consoleprogress bar,vimstartup/exit,htopframes captured withscript), and tests comparing the Surface against a reference where one is available (e.g. the same bytes throughtmux/scriptandtmux capture-pane, if tmux is installed).examples/: at leastsplit-shells.php(two shells side by side, Tab to switch, status bar, resizes with the terminal, scrollback with the wheel),coroutine-pane.php(one pane a shell, one a PHP closure withecho, a progress bar and colors),dashboard.php(graphs and boxes drawn with escape sequences from coroutines).README.md(what it is, install, the three-minute tour, the model: Surface → producers → viewport → screen → layout → component),docs/one page per layer with every public method described, andCHANGELOG.mdwith a section per version (## 0.0.1-betaN (date)), each entry stating what changed and what it fixes in terms a user recognises. Every public class and method has a docblock that says what it does and when to use it.- A git tag for the version, locally.
Windows console, Unicode bidi, true reflow on resize, an HTML/CSS layer, a widget catalogue, a direct cell-drawing API (deliberately: not until the emulator is proven rock solid).