A terminal UI library for PHP built on a real terminal emulator. Think tmux as a library: every
window is a terminal, so a shell, vim, htop or Claude Code runs in a pane of your PHP program
exactly as in a terminal, next to widgets that are themselves programs writing to a terminal.
This is a whole program, examples/dashboard.php (php examples/dashboard.php, with
--inline for the three boxes in the shell's own scrollback, --theme=light or mono):
⠇ deploying v2.4 staging · eu-west
╭ build ────────────────────────╮ ╭ test ─────────────────────────╮ ╭ deploy ──────────────────────╮
│ ██░░░░░░░░░░░░░░░░░░░░░░░░ 9% │ │ ████████████████░░░░░░░░░ 63% │ │ ███████░░░░░░░░░░░░░░░░░ 30% │
│ run 3 ▁▇█▂▂█▅▁▃▅▄▃▅▃▅▆ │ │ run 1 ▇▂▅▅▅▅▇▂▇█▁▂▇▅▅▄ │ │ run 0 ▅▁▆▅▂▁ │
╰───────────────────────────────╯ ╰───────────────────────────────╯ ╰──────────────────────────────╯
╭ events ────────────────────────────────────────╮ ╭ release notes ────────────────────────────────╮
│08:46:56 ✓ build #1 done │ │ Release 2.4 │
│08:46:57 ✓ test #1 done │ │ │
│08:46:57 ✓ build #2 done │ │ • Faster start-up, half the memory │
│08:46:58 ✓ build #3 done │ │ • --json output for every command │
│ │ │ • Wide characters line up: 日本語 ✓ │
│ │ │ │
│ │ │ │ Thanks to everyone who reported bugs! │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
╰────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────╯
ctrl+q quit
use phasync\Tui\Component\{Log, Markdown, ProgressBar, Spinner};
use phasync\Tui\Element\{Box, Column, Help, Row, Text};
use phasync\Tui\{State, Tui};
$jobs = ['build' => 0.07, 'test' => 0.15, 'deploy' => 0.4]; // seconds per step
$s = new State(['done' => array_map(fn() => 0.0, $jobs), 'runs' => array_map(fn() => 0, $jobs), 'steps' => array_map(fn() => [], $jobs)]);
$log = new Log();
$notes = new Markdown(<<<'MD'
# Release 2.4
- **Faster** start-up, half the memory
- `--json` output for every command
- Wide characters line up: 日本語 ✓
> Thanks to everyone who reported bugs!
MD);
$spark = fn(array $steps) => implode('', array_map(fn(float $v) => mb_substr(' ▁▂▃▄▅▆▇█', (int) min(8, $v * 9), 1), $steps));
$box = fn(string $job) => new Box(new Column([
new ProgressBar(fn() => $s->done[$job], label: '{percent}'),
new Text(fn() => "<dim>run</dim> {$s->runs[$job]} <span class=\"text-accent\">{$spark($s->steps[$job])}</span>"),
]), "<b>$job</b>", class: 'grow px-1 hover:border-accent');
$boxes = fn() => new Row(array_map($box, array_keys($jobs)), class: 'flex-col md:flex-row md:gap-1');
$screen = fn() => new Column([
new Row([new Spinner('<b>deploying</b> v2.4'), new Text('staging · eu-west', class: 'grow text-right text-muted')], class: 'px-1'),
$boxes(),
new Row([new Box($log, 'events', class: 'grow'), new Box($notes, 'release notes', class: 'grow px-1')], class: 'grow flex-col md:flex-row md:gap-1'),
new Help(class: 'px-1'),
]);
$theme = ($t = preg_grep('/^--theme=/', $argv)) ? substr(reset($t), 8) : null;
\fiber\run(function () use ($jobs, $s, $log, $screen, $boxes, $theme, $argv) {
foreach ($jobs as $job => $every) {
fiber\go(function () use ($job, $every, $s, $log) {
while (true) { // (fiber\sleep() ends the job when the screen quits)
fiber\sleep($every * (0.5 + mt_rand() / mt_getrandmax()));
$step = mt_rand(2, 12) / 100;
$s->steps = [...$s->steps, $job => [...array_slice($s->steps[$job], -15), $step * 8]];
$s->done = [...$s->done, $job => min(1.0, $s->done[$job] + $step)];
if ($s->done[$job] >= 1.0) {
$s->runs = [...$s->runs, $job => $s->runs[$job] + 1];
$log->append(date('H:i:s') . " <span class=\"text-success\">✓</span> $job #{$s->runs[$job]} done\n");
$s->done = [...$s->done, $job => 0.0];
}
}
});
}
in_array('--inline', $argv, true) ? Tui::inline($boxes, theme: $theme, keys: ['ctrl+q' => ['quit', 'quit']]) : Tui::run($screen, theme: $theme, keys: ['ctrl+q' => ['quit', 'quit']]);
});Three jobs run in coroutines of the program (fiber\go(); the program is the fiber\run() it
is in, so once the screen has quit the run is cancelled and their fiber\sleep() throws
fiber\Cancelled, which ends them) and write their progress into a State; the screen is closures
over that state, so each ProgressBar and Text is drawn again when what it read changes,
and nothing else is. The look is utility classes (grow, px-1, text-muted,
flex-col md:flex-row: stacked under 80 columns) on the default theme (rounded borders, one
accent colour, the focused window in it). docs/cookbook.md has ten more
screens like it: a log tail with a filter, a form in a dialog, a file list with a preview, a
tabbed shell host, a table with a search box, a chat pane, settings, a live chart and a
Markdown viewer with a table of contents.
The smallest program:
use phasync\Tui\{State, Tui};
use phasync\Tui\Component\ProgressBar;
use phasync\Tui\Element\{Column, Text};
$s = new State(['done' => 0]);
fiber\run(function () use ($s) {
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),
]), keys: ['ctrl+q' => ['quit', 'quit']]);
});That is a whole program: a line and a progress bar as a live region under the shell's output,
counting to 100%. The closure is the screen; it is rendered again whenever a State it read
changes, here from a coroutine. It binds no quit key (add keys: ['ctrl+q' => ['quit', 'quit']], as examples/working.php does); Tui::run() shows the same screen full screen,
Tui::headless() on an in-memory terminal for tests (examples/working.php runs both ways).
fiber\go() is fiber\go() for a coroutine of the program: its exception ends the program, and
it stops with the program when it waits with fiber\sleep(); both must be called inside
fiber\run(), which is why the program is wrapped in one. A loop that should run once per frame
the terminal really gets (a game) waits with Tui::nextFrame() instead of fiber\sleep(): it
returns after the frame was drawn and sent, with the seconds since the one before.
Such a program takes input without a class too: keys: on the runner binds keys to closures
(Tui::run($screen, keys: ['ctrl+n' => [fn() => add($s), 'new card']])), every element takes
onKey: (keys that apply while the focus is in it; an element with nothing focusable inside is a stop of the Tab walk itself), onMouse: (presses, drags and the
release of a drag, with what it was dropped on) and focused: (the focus follows the state:
focused: $s->sel === $i; a ScrollView keeps the focused, and the selected:, card in view),
and a handler may await a dialog:
fiber\await(Confirm::ask('Delete', 'Delete the card?')), Dialog::open(new Dialog(...)). See
docs/components.md.
Classes are for what has behaviour of its own. A chat-like log with an input line next to your shell:
use phasync\Tui\{Component, Key, Layout, Split, Tty};
use phasync\Tui\Component\{Log, Terminal, TextInput};
final class App extends Component
{
protected function render(): Layout
{
$log = $this->child('log', fn() => new Log(wordWrap: true));
$input = $this->child('input', fn() => new TextInput(prompt: '> ', history: true,
onSubmit: function (string $line, TextInput $input) use ($log) {
$log->append("you said: $line\n");
$input->setValue('');
}));
$shell = $this->child('shell', fn() => new Terminal(getenv('SHELL') ?: '/bin/sh'));
$input->focus();
return Split::horizontal(separators: true)
->add(Split::vertical()->add($log->surface)->add($input->surface, 1))
->add($shell->surface, '50%');
}
protected function onKey(Key $key): bool
{
match (true) {
$key->is('ctrl+q') => $this->dispose(), // quit
$key->is('alt+left') => $this->surface->focusToward('left'),
$key->is('alt+right') => $this->surface->focusToward('right'),
default => null,
};
return $key->is('ctrl+q') || $key->is('alt+left') || $key->is('alt+right');
}
}
fiber\run(fn() => (new App())->start(new Tty()));In any terminal size: the log keeps its text and wraps it again whenever the terminal is
resized, the shell is told its new size. Alt-Left/Alt-Right move between them; Ctrl-Q quits,
which hangs the shell up and gives the terminal back, and fiber\run() returns.
PHP 8.3+, MIT. Runs on any fiber-interop runtime: phasync, Revolt/AMPHP, ReactPHP.
composer require fiber-interop/phasync phasync/tuiThe first package is the event loop. phasync/tui runs on any loop through fiber-interop/api:
if your application already runs on Revolt, Amp or ReactPHP, install fiber-interop/revolt,
fiber-interop/amp or fiber-interop/react instead of fiber-interop/phasync, and the
library uses the loop you have. (Without an adapter the first Tui::run() throws
fiber\NotConfiguredException naming the four.) Optional: ext-phasync 2.0.7+ (bundled with
phasync/phasync) captures echo of a PHP coroutine into a window; ext-pcntl gives immediate
resizes and terminal restore on SIGTERM. PHP 8.3 or later, ext-gd for Canvas.
A first program, examples/kitchen-sink.php: a little of everything on one screen, no
class of its own. Tab moves the focus, the program binds Ctrl-Q to quit; TUI_HEADLESS=80x22 php examples/kitchen-sink.php prints the screen as text without a terminal:
⠹ working 10%
╭ select ──────────╮╭ widgets · apple ─────────────────────────────────────────╮
│Apple ││█████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ progress 11%│
│Pear ││ │
│Plum ││type and press Enter │
│ ││ │
│ ││[ ] Loud │
│ ││ │
│ ││[ Quit… ] │
│ ││ │
╰──────────────────╯╰──────────────────────────────────────────────────────────╯
╭ canvas ──────────────────────────────╮╭ log ─────────────────────────────────╮
│ ││started │
│ ⡠⠊⠉⢂ ││ │
│ ⠌ ⣀⣤⠂ ││ │
│⣐⣠⣴⣾⣿⣿ ││ │
│⣿⣿⣿⣿⣿⣿ ││ │
│⣿⣿⣿⣿⣿⣿ ││ │
│⣿⣿⣿⣿⣿⣿ ││ │
│⣿⣿⣿⣿⣿⣿ ││ │
╰──────────────────────────────────────╯╰──────────────────────────────────────╯
ctrl+l log a line ctrl+q quit
use phasync\Tui\Component\{Button, Canvas, CanvasContext, Checkbox, Confirm, Dialog, Editor, Log, ProgressBar, Select, Spinner};
use phasync\Tui\Element\{Box, Column, Help, Row, Text};
use phasync\Tui\{State, Tui};
$s = new State(['progress' => 0.0, 'samples' => [], 'fruit' => 'apple', 'loud' => false]);
$log = new Log();
$log->append("started\n");
\fiber\run(function () use ($s, $log) {
fiber\go(function () use ($s) { // a coroutine feeds the bar and the graph
for ($t = 0; ; $t++) {
$s->progress = $s->progress >= 1 ? 0.0 : $s->progress + 0.01;
$s->samples = [...array_slice($s->samples, -59), [50 + 35 * sin($t / 4), 50 + 30 * sin($t / 11) * cos($t / 29)]];
fiber\sleep(0.1);
}
});
$graph = function (CanvasContext $c) use ($s) {
[$w, $h] = [$c->width(), $c->height()];
$c->fillStyle = 'rgba(251, 191, 36, 0.5)'; // the slow wave, as an area (amber)
$c->beginPath();
$c->moveTo(0, $h);
foreach ($s->samples as $i => [, $v]) {
$c->lineTo($i * $w / 59, $h * (1 - $v / 100));
}
$c->lineTo((count($s->samples) - 1) * $w / 59, $h);
$c->fill();
$c->lineWidth = 0.25; // the fast one, a line one Braille dot (or a few pixels) wide
$c->beginPath();
foreach ($s->samples as $i => [$v]) {
$c->lineTo($i * $w / 59, $h * (1 - $v / 100));
}
$c->stroke();
};
Tui::run(fn() => new Column([
new Row([new Spinner('working'), new Text(fn() => sprintf('%d%%', $s->progress * 100), class: 'grow text-right text-muted')], class: 'px-1'),
new Row([
new Box(new Select(items: ['apple' => 'Apple', 'pear' => 'Pear', 'plum' => 'Plum'], onSelect: fn(string $key) => $s->fruit = $key), 'select', class: 'w-20'),
new Box(new Column([
new ProgressBar(fn() => $s->progress, label: 'progress {percent}'),
new Editor(placeholder: 'type and press Enter', maxLines: 1, onSubmit: fn(string $text, Editor $e) => [$log->append("you typed: $text\n"), $e->setValue('')]),
new Checkbox('Loud', onChange: fn(bool $on) => $s->loud = $on),
new Button('Quit…', function () use ($log) {
\fiber\await(Dialog::open(new Confirm('Quit the kitchen sink?'))) ? Tui::quit() : $log->append("stayed\n");
}),
], gap: 1), fn() => "widgets · {$s->fruit}" . ($s->loud ? ' · <b>loud</b>' : ''), class: 'grow'),
], class: 'h-10'),
new Row([
new Box(new Canvas(draw: $graph), 'canvas', class: 'w-1/2'),
new Box($log, 'log', class: 'grow'),
], class: 'grow'),
new Help(),
]), keys: ['ctrl+q' => ['quit', 'quit'], 'ctrl+l' => [fn() => $log->append(date('H:i:s') . " hello\n"), 'log a line']]);
});The pattern is the one every program here follows: a State holds what changes, coroutines
and handlers change it, and a closure builds the screen from it; what read a value that changed
is drawn again, nothing else. The cookbook has ten more screens like it.
One primitive, one structure:
- Surface (docs): a terminal emulator in memory, xterm-compatible.
Bytes in with
write(), cells out withcell(),line(),text(). ANSI escape sequences are the only way anything gets onto a Surface; there is no cell-drawing API, and the library draws its own widgets with escape sequences too (cells written once can be copied between surfaces withcopyArea()anddrawArea()). - Producers (docs): what writes into a Surface. A program in a
Pty, a PHP closure withSurface::run()(whoseechois captured with ext-phasync), or your own code with theAnsihelper. Input goes back through$surface->input. - Hosting (docs): a Surface hosts other Surfaces through
Viewports, to any depth. What a host shows is its own cells with its windows copied over them, to any depth, made again only on the rows something changed on; input events go down the focus path and are encoded for each program's modes. The screen of your program is a root Surface; a pane running another multiplexer is the same structure one level down. - Terminal device (docs):
Ttyattaches the real terminal to a root Surface (drawing only what changed, decoding keys, mouse and paste, following resizes);FakeTerminaldoes the same in memory for tests and headless programs. - Layout (docs):
SplitandStackarrange a host's windows; resizing goes down to every pty. - Components (docs):
Componentadds a lifecycle, a coroutine, keyed children and error boundaries;render()returns an element tree (Column,Row,Layers,Box,Text,Help) that the library lays out into surfaces and paints throughwrite(); state that renders by itself, a stylesheet, key bindings as data, and overlays: anything can float over everything (overlay()), placed next to its anchor inside the screen. Built-insTerminal,Log,TextInput,ListView,StatusBar,Box. A closure is a component too:Tui::run(fn() => ...)renders it again when the state it read changes. - Styling (docs): Tailwind's utility classes in
class:, a stylesheet with variables and states, symfony/console markup in text (<b>,<fg=red>); the themesdark,lightandmono, chosen by the terminal's background; colours drawn in what the terminal has. - Widgets (docs):
Editor(with history and completion),Select(a list, or a dropdown),Table,DialogandConfirm,FormwithButtonandCheckbox,ScrollView,ProgressBar,Spinner,Canvas,Markdown. - Graphics (surface, terminal,
Canvas): images are cells too. The emulator understands Kitty
graphics, iTerm2 inline images and Sixel (a program in a pane can show pictures, and they
move, crop and get covered with its window); the device asks the terminal what it shows and
draws them in Kitty graphics, iTerm2 images, Sixel or quarter blocks;
Canvasis the browser's 2D context on top of that. - Cookbook (docs): ten screens of under forty lines each, to copy from.
The surfaces form one tree, whatever is in it: every surface knows its parent() and its
area() on the root, and focus (focus(), focusNext(), focusToward('left')) follows that
tree through plain surfaces, nested layouts and components alike. The focus belongs to the
surface focused last, wherever it is shown: a re-render that wraps the layout in a Stack with a
dialog and takes it away again leaves the keys where they were, and the window focused before a
dialog gets them back when it closes.
A Surface is a terminal. Write what a program would write; read what a terminal would show.
$s = new Surface(24, 3);
$s->write("\e[1;32mok\e[0m tests passed\r\n中文 and 👍 are two cells wide");
echo $s->dump(); // [bold fg=2]ok[/] tests passed
// 中文 and 👍 are two cell
// s wideA program in a window. Inside fiber\run(), start it in a pty attached to a surface:
fiber\run(function () {
$s = new Surface(80, 24);
$pty = Pty::start(['ls', '--color=always', '/'], $s);
$pty->wait();
echo $s->text();
});Surfaces host surfaces. Put windows on a root, with borders and titles; the root's cells show them.
$root = new Surface(60, 12);
$log = new Surface();
$root->setLayout(Split::horizontal()->add($log, title: 'log')); // a border with a title
$log->write("first line\r\n");
echo $root->text();Draw with escape sequences. Widgets are producers too: build sequences with Ansi.
$log->write(Ansi::moveTo(0, 3) . Ansi::styled('warning', Style::of(Color::YELLOW, attrs: Style::BOLD)));Show it on the terminal. Attach a device to the root; input arrives as events.
$tty = new Tty();
$tty->attach($root);
$root->on('key', function (Key $key) use ($tty) {
if ($key->is('ctrl+q')) {
$tty->detach(); // gives the terminal back (and closes the root)
return true; // handled: it goes no further down
}
});
$tty->wait();Components give pieces of UI a lifecycle and a coroutine (mount() for state, sized() for
work that needs the component's real size, render(), run()):
final class App extends Component
{
protected function render(): ?Layout
{
$log = $this->child('log', fn() => new Log());
$input = $this->child('input', fn() => new TextInput(prompt: '> ', onSubmit: function (string $line, TextInput $input) use ($log) {
$log->append("you said: $line\n");
$input->setValue('');
}));
$input->focus(); // keys go to the input (by default the first window has the focus)
return Split::vertical()->add($log->surface)->add($input->surface, 1);
}
}
fiber\run(fn() => (new App())->start(new Tty())); // until it quitsScreens from elements. render() may return an element tree instead: the library lays it
out into surfaces, keeps what is behind each element (focus, scroll, the child components)
from render to render, and paints it with escape sequences like everything else. State
renders by itself; keys are data; a form collects its fields; a dialog's answer is an awaitable.
use phasync\Tui\{Component, Element, Stylesheet, Tty};
use phasync\Tui\Component\{Button, Checkbox, Confirm, Editor, Form, Select};
use phasync\Tui\Element\{Box, Column, Help, Row, Text};
final class Profile extends Component
{
protected function bindings(): array
{
return ['ctrl+q' => ['quit', 'quit', 'always' => true]]; // also while a dialog is open
}
protected function render(): Element
{
return new Column([
new Text('Profile', class: 'title'),
new Form(
new Column([
new Box((new Editor(placeholder: 'your name', maxLines: 1))->with(id: 'name'), 'Name'),
(new Select(['dev' => 'Developer', 'ops' => 'Operations'], dropdown: true))->with(id: 'role'),
(new Checkbox('Send me the newsletter'))->with(id: 'news'),
new Row([new Button('Save'), new Button('Quit', onPress: fn() => $this->quit())], gap: 2, height: 1),
]),
validate: fn(array $v) => $v['name'] === '' ? ['name' => 'Your name, please.'] : [],
onSubmit: fn(array $v) => $this->save($v),
),
new Text($this->state->status ?? 'not saved yet', class: 'muted'),
new Help(show: 'application'), // ctrl+q quit
]);
}
public function save(array $profile): void
{
$this->go(function () use ($profile) {
if (fiber\await($this->dialog(new Confirm('Save the profile?')))) {
$this->state->status = "saved: {$profile['name']}, {$profile['role']}"
. ($profile['news'] ? ', newsletter' : ''); // shows by itself
}
});
}
public function quit(): void
{
$this->dispose();
}
}
$app = new Profile();
$app->setStylesheet(Stylesheet::default()->with(['Text.title' => ['bold' => true, 'color' => '$accent']]));
fiber\run(fn() => $app->start(new Tty()));The name field has the focus and types every letter; Tab goes from field to field (the role is
a dropdown: Enter or Down opens its list), Space ticks the box, Enter submits the form (in a
field, or on the Save button, which has no onPress of its own). Without a name the form
stays, with its message and the field marked :invalid; with one, Confirm asks, and the
answer arrives in the coroutine that awaits it. examples/form.php is a larger form.
Styling where it is used. class: takes Tailwind's utility classes (padding, gap, borders,
colours, grow, widths, focus:, hover:, selected: and the width variants sm: md:
lg: xl:, from 60, 80, 100 and 140 columns on), and text takes symfony/console's markup
tags. Both resolve through the stylesheet, so a theme can set everything a class sets; the
terminal draws truecolour in the nearest colours it has. Three themes ship, dark, light
and mono, chosen by the terminal's background or Tui::run($root, theme: 'light').
(docs)
use phasync\Tui\Tui;
use phasync\Tui\Element\{Box, Column, Row, Text};
Tui::run(fn() => new Column([
new Row([
new Text('<b>deploy</b> v1.4', class: 'grow'),
new Text('<fg=green>●</> online', class: 'text-right'),
], class: 'px-1 bg-slate-800'),
new Row([
new Box(new Text('3 jobs running'), 'jobs', class: 'grow title-center'),
new Box(new Text('<dim>no errors</dim>'), 'errors', class: 'grow border-emerald-500'),
], class: 'gap-1 flex-col lg:flex-row'),
], class: 'p-1'));On a terminal 100 columns or wider (lg:) the two boxes stand side by side, on a narrower one
on top of each other; resizing across that width lays the screen out again.
Markdown, as it arrives. A Markdown pane renders CommonMark (headings, lists, code with
highlighting, tables, links) and takes text in chunks that end anywhere, as a model's answer
does; with tail it follows the end until the user scrolls back.
final class Answer extends Component
{
protected function render(): Element
{
return new Box($this->doc(), 'answer');
}
private function doc(): Markdown
{
return $this->child('doc', fn() => new Markdown(tail: true, scrollbar: true));
}
protected function run(): void
{
foreach (["# Hel", "lo\n\nThis *arri", "ves* in chunks: `code`, ", "[a link](https://phasync.dev)."] as $chunk) {
$this->doc()->append($chunk);
$this->sleep(0.2);
}
}
}Tabs, with a shell that keeps running. Tabs shows one content per tab; a component in
a hidden tab keeps its state, so the shell goes on running while the notes show. Tui::run()
is the whole program: it runs the root until it quits and gives the terminal back.
final class Workspace extends Component
{
protected function bindings(): array
{
return ['ctrl+q' => ['dispose', 'quit', 'first' => true]]; // before the shell sees it
}
protected function render(): Element
{
return $this->child('tabs', fn() => new Tabs([
'shell' => ['label' => 'Shell', 'content' => new Terminal(getenv('SHELL') ?: '/bin/sh')],
'notes' => ['label' => 'Notes', 'content' => new Markdown("# Notes\n\nThe shell keeps running.")],
]));
}
}
Tui::run(new Workspace());Left/Right or a click change tab on the bar, Enter goes into the shell; Ctrl-Tab changes tab
from anywhere, also from inside the shell. A first binding runs before the focused child gets
the key (a plain binding runs after it, when nothing took the key), so Ctrl-Q quits even while
the shell has the focus. examples/tabs.php adds and closes tabs.
A tree of 100,000 nodes. Tree lays out and draws only the rows in view, so a large tree
costs what a screenful does; lazy children may come from an awaitable (load:).
final class Outline extends Component
{
protected function render(): Element
{
$tree = $this->child('tree', fn() => new Tree(
items: array_map(fn(int $i) => ['label' => "section $i", 'expanded' => true, 'children' => array_map(
fn(int $j) => ['label' => "item $i.$j", 'leaf' => true], range(1, 99))], range(1, 1000)),
onActivate: fn(string $key) => $this->state->picked = $key,
));
return new Box($tree, $this->state->picked ?? 'outline');
}
}
Tui::run(new Outline());Up/Down, Page Up/Page Down and Home/End move, Left closes a section, Enter on an item puts its
key in the title. examples/tree.php browses a directory, loading folders as they open.
Inline, under the shell's output. Tty::inline() shows the same components as a live
region at the bottom of the terminal instead of taking it over; println() writes lines above
it into the terminal's scrollback, and when the program ends the last frame stays with the
prompt under it.
final class Copy extends Component
{
private float $done = 0.0;
protected function render(): Element
{
return new Row([new Text('copying', width: 8), new ProgressBar($this->done)]);
}
protected function run(): void
{
foreach (['a.txt', 'b.txt', 'c.txt', 'd.txt'] as $i => $file) {
$this->sleep(0.2);
$this->device()?->println("copied $file"); // above the region, scrolled out with the rest
$this->done = ($i + 1) / 4;
$this->requestRender();
}
$this->dispose(); // the last frame stays
}
}
fiber\run(fn() => Tui::inline(new Copy())); // or (new Copy())->start(Tty::inline())examples/inline-progress.php runs five jobs this way, or full screen with --fullscreen.
How a program ends. fiber\run() returns once nothing runs any more. Quitting (disposing
the root component, closing the root surface, or detaching the device; each leads to the others)
disposes the component tree: Terminals hang their programs up with everything those started
(a shell's foreground vim or sleep too), coroutines are told to stop,
the terminal is restored. An exception in the root component, in a coroutine of it, or in the
code that started it does the same and is then thrown to you, by start() or by
fiber\run(), with the terminal already given back. Tui::run($root), Tui::inline($root)
and Tui::headless($root, $cols, $rows) are the runner: they start the root, quit it when a key the program bound to quit
(keys: ['ctrl+q' => ['quit', 'quit']]; the runner binds none itself) is
pressed (or when Tui::quit() is called), and return when it ended. A Pty you start yourself is yours to end.
Test it headless. FakeTerminal is a terminal device in memory; what it shows must equal
the root's cells, and input is encoded as a real terminal would: type() for text, press()
for keys by name, feed() for raw bytes as a terminal sends them.
fiber\run(function () {
$app = (new App())->start($terminal = new FakeTerminal(80, 24));
$terminal->run(function (FakeTerminal $t) { // detaches afterwards, also when it fails
$t->type("hello\n");
$t->settle(); // the render that the input asked for
expect($t->text())->toContain('you said: hello');
});
});Renders come in the next frame: settle() lets the frames that input asked for run (keys
and output may take a few). text() draws what is pending first, renders included, so it reads
what the next frame shows. Never usleep() in a coroutine: it stops the whole program, renders
included. A test that fails ends its program and comes out of fiber\run() at once.
Headless, as written. A program run by Tui::run() or Tui::inline() (the dashboard,
examples/working.php, the cookbook's screens) runs without a terminal when it is started with
--headless=COLSxROWS, or with the environment variable TUI_HEADLESS=COLSxROWS: on an
in-memory terminal of that size for a second, then it prints the screen as text and exits
(php examples/dashboard.php --headless=100x30; @SECONDS after the size runs it longer or
shorter: TUI_HEADLESS=100x30@3). For a look at a screen in CI, over a pipe or in a bug report.
The theme of such a run (of any run whose program passes none) is chosen the same way:
--theme=light or TUI_THEME=mono.
The examples with a class start on a Tty themselves and need a terminal.
examples/working.php: the one-file program at the top, inline;--fullfor full screen.examples/split-shells.php: two shells side by side with a status bar, a tiny tmux. Ctrl-B Tab or Ctrl-B and an arrow switch, Ctrl-B q quits; it runs inside itself as tmux runs in tmux.examples/host.php <command...>: any command in a window, e.g.php examples/host.php claude.examples/coroutine-pane.php: a shell next to a PHP coroutine printing progress bars and colours (withechounder ext-phasync).examples/dashboard.php: the dashboard at the top: three jobs, their events and release notes in under sixty lines without a class;--inline,--theme=light|mono.examples/graphs.php: a live graph of two series, a logo of paths and a photo onCanvaselements;--marker=braille|quarter|half|block|pixels,--inline,--theme=.examples/inline-progress.php: five progress bars and a total under the shell's output (inline mode), finished jobs printed above them;--fullscreenfor the same full screen.examples/markdown.php [file.md]: a Markdown document streamed in chunks, followed as it arrives, scrolled with the keys and the wheel.examples/form.php: a form built from elements: one-line and multi-line editors, a pick list with filtering and marks, a confirmation dialog, the keys in a footer.examples/tabs.php: tabs holding a shell, a log that fills by itself and help pages; tabs added with Ctrl-N and closed with Delete.examples/tree.php [directory]: a directory browser whose folders load as they are opened.
Canvas draws with the browser's 2D canvas API (paths, fills, strokes, gradients, transforms,
drawImage, text), in cells as units, from a closure that runs again when the State it read
changes. In kitty, WezTerm, Ghostty and Konsole (Kitty graphics), in iTerm2 and VS Code's
terminal (iTerm2 inline images) and in xterm, foot, mlterm, Windows Terminal and a tmux built with
Sixel (Sixel) it is real pixels; everywhere else, and in screen, it is Braille dots or block
characters, with labels in real text, and nothing in the program changes. The terminal is asked
at start-up which of these it has. Hosted programs get the same: kitten icat, imgcat or
img2sixel in a pane shows its picture in the pane.
examples/graphs.php printed headless (php examples/graphs.php --headless=88x20@2 --marker=braille):
graphs marker: braille cpu 54%
╭ cpu and net ───────────────────────────────────────────────╮╭ logo ──────────────────╮
│ 100% ⡄ ││ ⣠⣴⣦⣄ │
│ ⡇cpu net ││ ⢀⣤⣶⣿⣿⣿⣿⣿⣿⣶⣤⡀ │
│ ⡇ ││ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ │
│ ⡇ ││ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ │
│ ⡇ ││ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ │
│ ⡇⡰⢄ ⢠⢆ ││ ⠈⠛⠿⣿⣿⣿⣿⣿⣿⠿⠛⠁ │
│ ⣧⠃⠈⣆⠇⠈⡆ ⣀ ││ ⠙⠻⠟⠋ │
│ 50% ⡏ ⠹ ⠸⡴⠁ │╰────────────────────────╯
│ ⡇ ⠁ │╭ photo ─────────────────╮
│ ⡇ ││⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿│
│ ⡇ ││⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿│
│ ⡇ ││⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿│
│ ⡇ ││⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿│
│ ⡇ ││⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿│
│ 0% ⠧⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠄││⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿│
│ -60 s now││⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿│
╰────────────────────────────────────────────────────────────╯╰────────────────────────╯
ctrl+q quit
(The axes are hairlines, one dot wide; the photo's cells are all dots on, each in the colour of its pixels, which this text cannot show.)
symfony/console, laravel/prompts and termwind print to the terminal. symfony/tui 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, widgets are programs writing to one, and the
emulator is checked against xterm behaviour, tmux reference captures and recorded output of real
programs (ls, vim, htop, less, a symfony/console progress bar). The acceptance tests run
Claude Code in a pane next to a shell (its first-run screens in a home of its own), and split-shells.php nested three levels deep with
resizes, focus, paste, scrollback and copy working at every level.
vendor/bin/pest # the tests
php -d extension=vendor/phasync/phasync/ext/phasync-8.5-nts-x86_64-glibc.so vendor/bin/pest
composer grammar # recompile src/Surface/ansi.cpegcomposer grammar (and the test that checks AnsiParser.php is current) needs charm-compile
on the PATH, from a global install of charm/parsing; without it the test is skipped.
See CHANGELOG.md for the changes per version.
MIT.