A dependency-light PHP engine for building panel-based terminal forms: interactive, keyboard-driven questionnaires that collect answers and hand them to your code. Describe the questions in PHP with a fluent builder, add a handler class wherever a question needs real behaviour, and the engine renders a scrollable, themeable TUI - or runs headless from a JSON payload.
It powers the Vortex project installer, but knows nothing about Vortex: the engine is generic, the project-specific questions and handlers live in the consumer, and applying the collected answers is the consumer's job, not the TUI's.
- 🧭 Panel TUI - a full-screen, scrollable, keyboard-driven form: panels hold fields, sub-panels drill in to any depth
- 🧩 Widgets -
text,number,textarea,password,select,multiselect,suggest,search,multisearch,filepicker,multifilepicker,confirm,toggle,pause - 🏗️ Builder-driven - panels and fields are declared in PHP with a fluent builder; the common cases need no code
- 🤖 Interactive or headless - drive the panel TUI by keyboard, or collect answers non-interactively from a JSON payload and environment variables (and emit a JSON schema for agents and forms)
- 🔗 Derived values - compute one field from others with str2name transforms; chains settle to a fixpoint
- 🔀 Conditional fields - show or hide fields with
whenrules; a fix-up pass reconciles dependent answers - 🔍 Discovery - detect sensible defaults from the target directory (
.envkeys, JSON paths, path existence, directory scans) - ⚙️ Declared behaviour - validation, transforms and dynamic defaults as closures on the field; per-field handler classes remain as a fallback
- 📦 Self-describing answers - each answer carries a snapshot of its question and its provenance; summaries need no form config
- 🎨 Themes - the whole visual representation (colours, glyphs, layout) is a theme class; ships with dark and light
- ⌨️ Key bindings - remap navigation, edit, accept and cancel keys per widget type; ships a vim-style preset, and a bad binding fails loudly at build time
- ✨ Unicode and ASCII - glyphs follow the terminal locale and colour honours
NO_COLOR; both can be forced on the form
composer require drevops/tuiDeclare a form with the fluent Form builder, then drive it with the Tui facade - one class that wires the engine, resolver, schema tools and TUI for you:
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Tui;
$form = Form::create('My form')
->panel('general', 'General', fn(PanelBuilder $p) => $p->text('name', 'Your name')->required());
$tui = new Tui($form, ['App\\Handler']);
// Interactive panel TUI on a terminal, headless otherwise.
$answers = $tui->run();
// Or call a mode directly:
echo $tui->collect('{"name":"Ada"}')->toJson(); // headless: JSON + environment
$answers = $tui->interact(); // interactive panel TUIIt also exposes schema(), agentHelp() and validate(), and - when you want finer control - the internals via config(), engine() and registry(). See playground/ for complete, runnable examples.
Fourteen widget types cover text entry, choices, filesystem browsing and gates. Every field of the form opens its widget in an editor; the same widgets also run standalone (see playground/3-widgets/). Widgets pull their glyphs and colours from the theme, so each one below is shown in all four display modes.
Single-line text input with a movable caret. Type to insert, Left/Right move the caret, Backspace deletes, Enter accepts.
$p->text('name', 'Site name')->default('Acme Site')->required();| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Integer input: digits with an optional leading minus, accepted as an int.
$p->number('port', 'HTTP port')->default(8080);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Declare optional min, max and step to bound the value and enable keyboard adjustment. Up and Down then adjust the value by the step (defaulting to 1), clamped to the range, and an entry outside the range is rejected inline with a clear message. The bounds are enforced headlessly too - a --prompts or environment value outside the range is rejected - and are reflected in the JSON schema as min, max and step on the prompt. With none declared the field stays a plain integer entry with the arrow keys inert.
$p->number('port', 'HTTP port')->min(1)->max(65535)->step(1)->default(8080);Multi-line text input: Enter inserts a newline, Up/Down move between lines keeping the column, Tab accepts.
$p->textarea('notes', 'Provisioning notes')->default("Redis for cache\nSolr for search");Call ->externalEditor() to let the field hand off to the user's $EDITOR (or $VISUAL) for composing long or structured text. Pressing Ctrl-E while editing suspends the TUI, opens the editor seeded with the current value, and captures the saved buffer on return - saving and exiting commits it as the field value, while an aborted edit (a non-zero editor exit) keeps the inline value. When no editor is available the option is silently ignored and the field behaves as a plain inline textarea.
$p->textarea('notes', 'Provisioning notes')->externalEditor();| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Text input rendered as a mask - in the editor, on the panel row and in the summary; the accepted value stays plain for the consumer.
$p->password('api_key', 'API key');Two opt-in options, both off by default so the behaviour above is unchanged:
revealable()adds a reveal toggle. Press Tab in the editor to cycle the display between hidden (nothing shown), masked and plaintext. This only changes what is drawn - the stored value is never affected, and the panel row and summary stay masked.confirm()prompts for the value a second time and rejects a mismatch with a clear message before accepting.
$p->password('api_key', 'API key')->revealable()->confirm();| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
With revealable() on, pressing Tab in the editor reveals the value and the hint line shows the toggle:
Single choice from a list. Up/Down move, Enter accepts the highlighted option. Pass default (an option key) to start on an option other than the first.
$p->select('profile', 'Install profile')->default('minimal')->options(['standard' => 'Standard', 'minimal' => 'Minimal', 'demo_umami' => 'Demo Umami']);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Multiple choice from a checkbox list. Space toggles the highlighted option, typing narrows the list, Right selects and Left deselects everything visible, Enter accepts. Pass default (a list of option keys) to pre-check options - ideal for opt-out lists.
$p->multiselect('services', 'Services')->default(['redis'])->options(['redis' => 'Redis', 'solr' => 'Solr', 'clamav' => 'ClamAV']);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Free text with autocomplete over a fixed option set: type anything, Up/Down highlight a matching suggestion, Enter accepts the highlighted suggestion or the typed text as-is.
$p->suggest('php_version', 'PHP version')->default('8.4')->options(['8.1' => '8.1', '8.2' => '8.2', '8.3' => '8.3', '8.4' => '8.4']);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Single choice with a visible type-to-filter line above the options: typing narrows the labels, Up/Down move, Enter accepts the highlighted option's key.
$p->search('timezone', 'Timezone')->default('london')->options(['utc' => 'UTC', 'london' => 'Europe/London', 'paris' => 'Europe/Paris', 'sydney' => 'Australia/Sydney']);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
A multi-select whose type-to-filter query is shown as a search line: type to narrow, Space toggles, Enter accepts the checked set.
$p->multisearch('services', 'Services')->default(['redis'])->options(['redis' => 'Redis', 'solr' => 'Solr', 'clamav' => 'ClamAV', 'memcached' => 'Memcached']);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Browse the filesystem for a single path. Arrows move, → enters the highlighted directory and ← returns to its parent (never above the start directory), typing filters the current directory and Tab reveals hidden entries; Enter selects the highlighted entry. Restrict what may be chosen with filesOnly() or directoriesOnly(), limit files with extensions(), and choose where browsing starts with start(). It collects the chosen path.
$p->filePicker('config', 'Config file')->start(getcwd())->filesOnly()->extensions(['yml', 'yaml']);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Like the file picker, but several paths can be chosen: Space toggles the highlighted entry and selections accumulate as you browse between directories, Enter accepts them all. It collects the list of chosen paths.
$p->multiFilePicker('assets', 'Asset files')->start(getcwd())->extensions(['png', 'svg']);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
select, multiselect, search and multisearch accept more than a flat list. Alongside the ->options(['value' => 'Label']) map shorthand, ->option() declares one option at a time and can mark it disabled, and ->heading() and ->separator() add non-selectable structure:
$p->select('profile', 'Install profile')
->heading('Recommended')
->option('standard', 'Standard')
->option('minimal', 'Minimal')
->separator()
->heading('Advanced')
->option('demo_umami', 'Demo Umami', disabled: TRUE, disabled_reason: 'requires PHP 8.4');Headings, separators and disabled options are visual only: navigation skips them, so the cursor lands only on selectable options, and they can never be highlighted or selected. A disabled option shows its reason beside the label, dimmed. Every kind is theme-driven - override heading(), divider() or disabled() on a theme to restyle it.
Non-selectable rows never leak into the answer: a disabled value is dropped from a multiselect default, absent from the collected value, and excluded from the JSON schema (Tui::schema() lists selectable options only). Supplying a disabled - or otherwise unknown - option value non-interactively (via --prompts JSON or an environment override) fails collection with a clear error naming the value.
A single-choice select with a group heading, a separator and a disabled option (its reason shown beside the dimmed label):
| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
A multiselect where the cursor and Space skip the separator and the disabled option, which can never be checked:
| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
Yes/No toggle. Arrows or Space switch, y/n set the choice directly, Enter accepts.
$p->confirm('cdn', 'Serve via CDN?')->default(TRUE);| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
An inline switch between two labeled values. Arrows or Space flip, the first letter of each label sets the choice directly, Enter accepts. Pass default (an option value) to start on the other value.
$p->toggle('telemetry', 'Telemetry')->options(['enabled' => 'Enabled', 'disabled' => 'Disabled'])->default('enabled');| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
An acknowledgement gate: Enter (or Space) accepts TRUE. Headless runs auto-acknowledge it, so it never blocks automation.
$p->pause('ready', 'Review the summary above');| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
The interactive TUI is a full-screen panel browser: the root hub lists the form's panels with live value summaries, and each panel lists its fields with their current values and provenance badges. Up/Down move the cursor, Enter edits a field (or drills into a sub-panel), Esc goes back, q quits, and the mouse wheel scrolls long panels without moving the cursor - all of these keys are configurable (see Key bindings). Submit and Cancel buttons live on the root panel - ->buttons(FALSE) hides them, ->buttons(TRUE, 'Save', 'Discard') relabels them.
A form-level ->banner() shows a start screen (with an optional version) before the panels, and ->clearOnExit(FALSE) keeps the final frame on screen after the TUI exits.
Panels nest to any depth: a sub-panel renders as a drillable row with a one-line summary of its values, and the breadcrumb header tracks where you are. A ->fixup() rule reconciles dependent answers on every settle pass - here, CDN is forced off outside production, whatever was answered:
$form = Form::create('Site settings')
->buttons(TRUE, 'Save', 'Discard')
->fixup(new Fixup(set: 'cdn', to: FALSE, when: new Condition('environment', ne: 'prod')))
->panel('stack', 'Stack', function (PanelBuilder $p): void {
$p->select('environment', 'Environment')->default('dev')->option('dev', 'Development', 'Local containers')->option('stage', 'Staging', 'Shared preview')->option('prod', 'Production', 'Live traffic');
$p->confirm('cdn', 'Serve via CDN?')->default(TRUE);
$p->panel('services', 'Services', function (PanelBuilder $sp): void {
$sp->multiselect('services', 'Enabled services')->options(['solr' => 'Solr', 'redis' => 'Redis', 'clamav' => 'ClamAV']);
$sp->panel('tuning', 'Tuning', function (PanelBuilder $tp): void {
$tp->suggest('php_memory', 'PHP memory limit')->default('256M')->options(['128M' => '128M', '256M' => '256M', '512M' => '512M']);
});
});
});The TUI adapts to the terminal: glyphs follow the locale (a UTF locale in LC_ALL, LC_CTYPE or LANG enables Unicode, anything else falls back to ASCII) and colour honours NO_COLOR and TERM=dumb. Force either on the form with ->unicode(TRUE|FALSE) and ->color(TRUE|FALSE). Here is the same scaffolder in each combination:
| ANSI | No ANSI | |
| Unicode | ||
| ASCII |
A form is a tree of panels, each holding fields, built fluently. Rules are named-argument spec objects, so the IDE completes them and a typo fails at declaration time:
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Condition\Condition;
use DrevOps\Tui\Derive\Derive;
$form = Form::create('My form')
->panel('general', 'General', function (PanelBuilder $p): void {
// text | select | multiselect | suggest | confirm
// number | textarea | password | search | multisearch | pause
$p->text('name', 'Project name')->required();
// Compute one field from others.
$p->text('machine_name', 'Machine name')->derive(new Derive('{{name}}', transform: 'machine'));
$p->select('profile', 'Profile')
->default('standard')
->options(['standard' => 'Standard', 'custom' => 'Custom']);
// Shown only when the condition holds; compose with Condition::all()/any()/not().
$p->text('profile_custom', 'Custom profile')->when(new Condition('profile', eq: 'custom'));
});Each field builder chains ->description(), ->default(), ->required(), ->weight(), ->options() / ->option() (with per-option descriptions and optional disabled state), ->heading() / ->separator() (non-selectable option-list structure), ->when(new Condition(...)), ->derive(new Derive(...)), ->discover(...), ->validate(...) and ->transform(...).
Form-level methods tune the interactive TUI: ->theme() names a theme, auto-detected from the terminal background when unset (see Themes), ->banner() sets a start banner, ->buttons() controls the submit/cancel buttons, ->clearOnExit() keeps or clears the final frame, and ->color() / ->unicode() force a display mode.
A Derive computes a field from other answers: a template with {{field}} placeholders plus a transform. A transform is any str2name conversion (machine, kebab, pascal, ...) plus host, lower, upper and initials - an unknown name throws when the form is declared. Chains of derives settle to a fixpoint:
$p->text('machine_name', 'Machine name')->derive(new Derive('{{name}}', 'machine'));
$p->text('package', 'Composer package')->derive(new Derive('{{vendor}}/{{machine_name}}', 'lower'));
$p->text('namespace', 'PHP namespace')->derive(new Derive('{{name}}', 'pascal'));A ->when() rule shows or hides a field based on other answers, with operators eq / ne / in / contains, composable with Condition::all(), Condition::any() and Condition::not():
$p->text('docker_image', 'Docker base image')->default('php:8.4-cli')->when(new Condition('features', contains: 'docker'));
$p->confirm('docker_compose', 'Generate a docker-compose.yml?')->when(Condition::all(new Condition('features', contains: 'docker'), new Condition('type', eq: 'application')));A form-level ->fixup(new Fixup(set: ..., to: ..., when: ...)) reconciles dependent answers on every settle pass, so an answer that no longer makes sense after another change is corrected instead of leaking through.
Behaviour beyond a static value is declared on the field itself - a dynamic default, validation and a value transform as closures, right in the form:
use DrevOps\Tui\Handler\Context;
$p->text('name', 'Project name')
->default(fn(Context $c): string => basename($c->directory))
->validate(fn(mixed $v): ?string => is_string($v) && trim($v) !== '' ? NULL : 'A name is required.')
->transform(fn(mixed $v): mixed => is_string($v) ? trim($v) : $v);Reusable validators and transformers live as public static methods on a consumer class. Reference one explicitly with a first-class callable - ->validate(Webroot::validate(...)) - or let the engine discover it: registering a namespace (new Tui($form, ['App\\Handler'])) resolves the class by field id (machine_name -> MachineName) and uses its static validate()/transform() whenever the field declares none. The field declaration always wins.
The TUI only collects: it presents answers and never applies them. Applying answers - writing files, renaming directories - is the consumer's job. A consumer that processes answers defines its own processor interface, keeping the form for collection and the processors for side effects - one class per field can carry both its process() and its reusable static behaviour (this is exactly what the Vortex CLI does).
In update mode, ->discover() rules detect defaults from an existing project directory: a .env key (new Dotenv('KEY')), a JSON dot-path (new JsonValue('composer.json', 'name')), a path check (new PathExists('docker-compose.yml')), a directory scan (new Scan('modules', type: 'dir')), or a custom fn(Context $c): mixed closure:
$p->text('name', 'Project name')->discover(new JsonValue('composer.json', 'name'));
$p->confirm('docker', 'Uses Docker?')->discover(new PathExists('docker-compose.yml'));Discovered values are badged in the summary, and explicit input (prompts or environment) always wins over discovery:
Without a TTY - or when prompts are supplied - the same form collects non-interactively: answers come from a JSON payload (a string or a path to a file), per-question environment overrides and the declared defaults, with derives, conditions and fix-ups settled exactly as in the TUI.
$answers = $tui->collect('{"name":"Ada"}');
$answers = $tui->run($prompts, '1.0.0'); // TUI on a terminal, headless otherwise.Environment overrides are named <PREFIX><FIELD_ID> (the uppercased field id). ->envPrefix('MYAPP_') declares that namespace on the form, a new Tui($config, [], 'MYAPP_') constructor argument overrides it, and without either the prefix is TUI_:
MYAPP_TIMEZONE=UTC php my-installer.phpFor automation and AI agents, the form describes itself: schema() emits a JSON schema of the questions, agentHelp() a plain-text guide for driving the form non-interactively, and validate($answers) checks a payload against the schema before collection.
The returned Answers set needs no form configuration to present or process: each answer carries a snapshot of its question (label, kind, weight, panel trail) taken at collection time, plus its provenance - default, detected, edited, derived, or override (a user value pinning a derived one). $answers->toSummary() prints the provenance-badged, panel-grouped summary, $answers->toJson() the raw values, and $answers->items exposes the per-answer snapshots.
A theme is a self-contained class that owns the entire visual representation - the palette (per-role ANSI style codes), the glyphs (marker, caret, scroll indicators, separators - each a Unicode/ASCII pair) and how every row is composed. AbstractTheme implements all of it with a neutral base, and a concrete theme overrides only what it colours. The ThemeManager turns a theme name into an instance; two themes are built in:
use DrevOps\Tui\Theme\ThemeManager;
ThemeManager::create('dark'); // bright foregrounds for a dark terminal
ThemeManager::create('light'); // higher-contrast foregrounds for a light terminalWhen a form sets no theme (or the explicit 'auto' sentinel), the interactive TUI picks dark or light from the actual terminal background: it queries the background colour over OSC 11, falls back to the COLORFGBG environment variable, and settles on dark when neither answers. An explicit ->theme('dark') or ->theme('light') opts out of detection.
A custom theme subclasses a built-in theme (e.g. DarkTheme) or AbstractTheme, overrides the styles or glyphs it changes and merges the rest from the parent - roles it does not mention keep working:
use DrevOps\Tui\Theme\DarkTheme;
class OceanTheme extends DarkTheme {
protected function defineStyles(): array {
return ['title' => '1;96', 'value' => '96', 'marker' => '1;96'] + parent::defineStyles();
}
}Override any render* method to change how an element is laid out. Lowest friction: a form names the class directly, with no registration:
$form = Form::create('My form')->theme('\App\OceanTheme')/* ... */;Or register a short alias with ThemeManager::register('ocean', OceanTheme::class), then ->theme('ocean') - an unknown theme name fails loudly instead of silently falling back. Here is the playground's ocean theme with a start banner:
Navigation, edit, accept and cancel keys are configurable. A widget asks for a semantic action - MoveUp, Accept, Toggle, NewLine and so on - rather than a fixed key, and a key map binds each action to one or more keys. Set it on the form with ->keys(...), mirroring ->theme(...):
$form = Form::create('My form')->keys('vim'); // built-in vim navigation (h/j/k/l)Two presets ship: default (the bindings described in Panels and navigation) and vim, which adds h/j/k/l alongside the arrow keys - only where a letter is not typed input, so text and filter fields keep the arrows.
Bindings are layered by scope: a base layer shared by every widget, a navigation layer for the panel browser, and one layer per widget type that overrides the base only where it differs (Enter inserts a newline in a textarea, Space toggles a checkbox option). Retune individual bindings by passing overrides on top of a preset - each names a scope, an action and its keys:
use DrevOps\Tui\Config\FieldType;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Binding;
use DrevOps\Tui\Input\KeyName;
use DrevOps\Tui\Input\Scope;
$form = Form::create('My form')->keys('default', [
// Quit with x as well as q.
new Binding(Scope::navigation(), Action::Quit, 'x'),
// In the single-choice list, Tab accepts too.
new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab, KeyName::Enter),
]);A binding's keys accept a KeyName for a named key or a single-character string for a printable one. The panel and editor hints are drawn from the live bindings, so they always reflect the active keys.
A preset is a class listing its bindings. Subclass DefaultKeyMap to ship your own, name it directly with ->keys('\App\MyKeyMap'), or register a short alias with KeyMapManager::register('mine', MyKeyMap::class) and then ->keys('mine').
Bindings are validated when the form is built, so a bad key map is caught at declaration time, not mid-session:
- a key bound to two different actions in the same scope is a conflict;
- a printable character bound in the base scope, or in a scope whose widget consumes typed input (text, search, checkbox), would be un-typeable and is rejected;
- an unknown preset name, or a character binding that is not exactly one character, is rejected.
See playground/8-key-bindings for the default map, the vim preset and a custom override side by side.
Runnable, self-contained examples are in playground/: a minimal form, a full "package scaffolder", a custom-theme demo, per-widget demos, nested panels with fix-ups, update-mode discovery, theme auto-detection, theme options, and a configurable key-bindings demo. Each is independent - copy one as a starting point.
The SVG demos on this page are generated from the playground scripts with php docs/util/update-assets.php (requires asciinema, expect, node and npm), which records each demo through a scripted terminal session and renders the recordings to SVG.
Diagrams of the engine, the collection lifecycle and the panel TUI are in docs/architecture/.
composer install
composer lint
composer test
To pull the latest infrastructure from the template into this project, ask Claude Code to "update scaffold" - see AGENTS.md for details.
