drevops/tui is a PHP library for panel-based terminal forms: keyboard-driven questionnaires that collect a set of answers and hand them back to your code as typed values.
- Declarative form model. A form is declared with a fluent builder (
Form/PanelBuilder/FieldBuilder): panels of typed fields, each with its own options, conditions, derivation rules and behavior. - Two collection modes, one declaration. The same form runs as a full-screen interactive TUI on a terminal, or resolves non-interactively from a JSON payload, per-field environment variables, discovery rules and defaults.
- Application-agnostic. The library doesn't know (or care) what application it serves; questions and handlers live in your code, and applying the collected answers is your job. It collects; you apply.
- Dependency-light. The runtime dependency surface is a single string-transform package.
The padded rounded border above is the default look. The same form explicitly opted out of the frame (border none, normal spacing):
Full documentation lives at phptui.dev. The in-development build, rebuilt from main ahead of each release, is previewed at tui-docs.netlify.app.
A screen is built from four levels, and each owns a fixed set of capabilities. When something does not obviously fit, the question is never "where does this go" but which level owns the capability it needs.
Screen the root; occupies the terminal, or fits its contents
ββ Layout arranges; reusable by name
ββ Region holds blocks and flows them; declares whether it scrolls
ββ Block drawn in a region
One kind of block - a panel - contains a layout, which starts the chain again. That is where depth comes from, rather than from a fifth level. Seven kinds of block exist: Panel, Field, Markup, Breadcrumb, Legend, Actions and Progress. Only a field collects, so only a field reaches the answers; everything else shows, focuses or activates.
Three things follow, and they are what the rest of the library is shaped by:
- One tree. The builder writes blocks directly, so
$form->root()is what the interactive screen draws, what the headless collector reads, and what the JSON schema describes. - Blocks say what they can do. Each declares its capabilities as interfaces, so a driver asks "does this bind keys, does it collect" rather than "which class is this".
- Themes say how it looks. A block asks the theme for one element at a time and hands it a plain string; order and spacing belong to the block, color and glyph to the theme.
The whole model is written out at phptui.dev/specification.
Every feature has a reference page and a runnable, self-contained example in playground/:
| Feature | Summary | Docs | Example |
|---|---|---|---|
| π§ Full-screen TUI | Scrollable panel browser: hubs drill into sub-panels to any depth, contextual key-hint footer, ? help overlay | panels | 03-panels-* |
| πͺ Modal panels | A panel marked ->modal() opens as a centered dialog over its dimmed parent, with its own submit/cancel buttons |
panels | 03-panels-* |
| π§± Panel grids | ->layout(1, 2) arranges sub-panels as a grid of side-by-side preview columns - rows of any width, recursively per level, with spatial arrow navigation |
panels | 03-panels-* |
| π§ Layouts | ->layout('two-column') arranges a screen or a panel into named regions, each with its own size, flow and scrolling; register a layout class of your own and pick it by name |
layouts | 20-layouts-* |
| π₯οΈ Fullscreen mode | ->fullscreen() stretches the frame to the whole terminal; halign/valign anchor the content and min/max size options guard small or very wide terminals |
panels | 03-panels-* |
| β‘ Inline editing | A field's editor opens in place on the panel row; ->standalone() opts a field out to full-screen |
panels | 04-inline-editing |
| π§© Fields | 15 field types: text, template, number, rating, calendar, textarea, password, select, reorder, suggest, search, file picker, confirm, toggle, pause - plus markup and progress blocks, which collect nothing | fields | 02-fields-* |
| ποΈ Builder-driven | The form is declared in PHP with a fluent builder; the common cases need no code | configuration | 01-quickstart |
| ποΈ Interactive or unattended | run() picks the mode: keyboard on a terminal, otherwise JSON payload + TUI_<ID> environment variables |
headless collection | 08-headless-* |
| π Derived values | Fields computed from other answers via {{field}} templates and str2name transforms, settling to a fixpoint |
configuration | 05-form-logic-* |
| π Conditional fields | ->when() conditions (eq/ne/in/contains, composable with all/any/not) drive visibility; form-level fix-ups reconcile answers, and an opt-in indent steps each field in from the answer that reveals it |
configuration | 05-form-logic-* |
| βοΈ Declared behavior | ->required() rejects an empty value with a label-derived or declared message; dynamic defaults, validation and transforms as field closures, or as per-field handler classes resolved by naming convention |
field behavior | 06-field-behaviour-* |
| π Discovery | Update mode detects defaults from an existing directory: dotenv keys, JSON dot-paths, path checks, directory scans | discovery | 07-discovery |
| β³ Progress | A progress() primitive wraps slow work: a spinner when the length is unknown, a determinate bar when known - theme-drawn, animating on a TTY, degrading to a plain line when piped or headless |
progress | 15-progress-* |
| π― Answer-driven options | An ->options() callback that asks for the run context resolves a choice field's list from the answers collected so far, so one field narrows by another - re-resolved as they change, honored by the panel, headless collection, the schema and the validator alike, and a choice the narrowed list drops does not survive in the answers, while one supplied headlessly is reported rather than dropped |
options from the answers | 19-dynamic-options |
| π Remote-backed options | ->optionsFrom() resolves a search or suggest field's candidates from the live query - a themed Loadingβ¦ while it runs, a typing burst settling into one call, a per-query cache, and ->minQuery() holding it back until the query is worth sending |
options from a query | 17-query-options |
| π§Ύ Output | An output() primitive draws the chrome around a form: boxes and cards, tables, five status lines, definition lists, wrapped text, rules and a banner - theme-drawn, dropping their color when piped or redirected |
output | 18-output-* |
| π¦ Self-describing answers | Answers carry provenance; toSummary() renders a badged, panel-grouped report and toJson() the machine result; schema(), validate() and agentHelp() describe the form itself |
self-describing answers | 08-headless-* |
| π¨ Themes | Six built-ins selected by name; a custom theme is a DefaultTheme subclass repainting a handful of voices, and ->theme(fn(ThemeBuilder $t) => ...) patches individual elements with no class at all |
themes | 09-themes-* |
| β¨οΈ Key bindings | Presets (default, vim, or a class) plus per-binding overrides scoped to navigation or a field type; conflicts throw at setup |
key bindings | 10-key-bindings-* |
| β¨ Display modes | Dark/light follows the terminal background, glyphs follow the locale, color honors NO_COLOR; all three can be forced |
display modes | 11-display-modes-* |
| π§ͺ Test harness | TuiTester drives a whole form from scripted keystrokes and ScreenTester one screen frame by frame, no TTY; assert on answers, output and every frame drawn |
testing | 13-testing |
| π Translations | Bundled chrome catalogs load automatically; a directory, a single catalog file or an inline map layers your own strings and chrome overrides on top, falling back to English | translations | 12-translations |
composer require drevops/tuiDeclare a form with the Form builder, then drive it through the Tui facade - the one class that wires up collection, the input resolver, the schema tools and the interactive screen for you:
use DrevOps\Tui\Builder\Form;
use DrevOps\Tui\Builder\PanelBuilder;
use DrevOps\Tui\Tui;
$form = Form::create('Quick start')
->panel('order', 'New order', function (PanelBuilder $p): void {
// A required single-line text field.
$p->text('name', 'Order name')->required();
// A single choice, starting on "Banana".
$p->select('fruit', 'Fruit')->default('banana')->options([
'apple' => 'Apple',
'banana' => 'Banana',
'cherry' => 'Cherry',
]);
// A multi-select, with one option pre-checked.
$p->select('veg', 'Vegetables')->multiple()->default(['carrot'])->options([
'carrot' => 'Carrot',
'tomato' => 'Tomato',
'spinach' => 'Spinach',
]);
// An integer bounded to a sensible quantity.
$p->number('quantity', 'Quantity')->min(1)->max(99)->default(6);
// A yes/no gate.
$p->confirm('organic', 'Organic only?')->default(FALSE);
});
$tui = new Tui($form, handler_namespaces: ['App\\Handler']);
$answers = $tui->run();The facade's surface:
| Call | Purpose |
|---|---|
run($prompts, $version, $directory, $interactive) |
Collect answers; interactive on a TTY, headless otherwise (or forced via $interactive) |
collect($prompts, $directory, $update, $version) |
Headless collection from JSON + environment; $update enables discovery |
interact() |
The interactive panel TUI, explicitly |
progress($total, $caption, $work) |
Show slow work running around the form: a spinner with no total, a determinate bar with one - a theme-drawn primitive |
output() |
Draw the chrome around the form: boxes and cards, tables, status lines, definition lists, text, rules and a banner - theme-drawn primitives |
schema() / validate($answers) / agentHelp() |
Describe the questions as structured metadata, validate an answer payload, emit the agent-facing answer schema |
theme($theme, $options) |
Select the theme by name or class, or pass a closure to patch individual elements |
layout($layout) / keys($preset, $overrides) |
Arrange the screen into named regions; select the key bindings |
color($bool) / unicode($bool) / markdown($bool) / fullscreen($bool) / footer($bool) / clearOnExit($bool) / translator($t) |
Display and runtime switches |
root() / registry() |
The declared block tree, and the handler registry - for finer control |
Read the full guide at phptui.dev, and browse playground/ for complete, runnable examples - the numbered scripts for each feature in the table above.
There's a field for most things you'd want to ask: text entry, numbers and dates, single and multiple choice, fuzzy search, filesystem browsing, and simple gates. Each one links to its full reference on phptui.dev, and every card below plays back the real interaction in whichever color scheme - light or dark - your reader is using.
| Calendar A month calendar returning a normalized ISO YYYY-MM-DD; arrows move by day and week. |
|
| Confirm Yes/No toggle; arrows or Space switch, y/n set the choice directly, Enter accepts. |
|
| File picker Browse the filesystem for a path; arrows move, β enters a directory and β returns to its parent. Add ->multiple() for several paths. |
|
| Number Integer entry (digits with an optional leading minus) accepted as an int, with optional min, max and step. |
|
| Password Text rendered as a mask in the editor, the field row and the summary; the accepted value stays plain for your code, and can be made revealable. |
|
| Pause An acknowledgment gate; Enter or Space accepts. Unattended runs auto-acknowledge it, so it never blocks automation. |
|
| Progress A panel row that runs its work when activated, filling a bar or ticking a spinner in the row itself; it collects no value. |
|
| Rating A graded answer picked from a scale of points, accepted as an int; arrows walk the scale, a digit jumps to its point, and each point can carry a caption. |
|
| Reorder Rank a list by moving items into the order you want; Space picks an item up, arrows carry it through the list, Enter accepts. |
|
| Search Single choice with a visible filter line; typing fuzzy-matches and ranks the labels, exact and prefix matches leading. |
|
| Select Single choice from a list; arrows move, Enter accepts the highlighted option, long lists page around the cursor. |
|
| Suggest Free text with autocomplete over a fixed option set: as you type, suggestions are fuzzy-matched and ranked by relevance. |
|
| Template Fill the named slots of a fixed shape; the fixed text is context, Tab steps between slots and each one validates on its own. |
|
| Text Single-line input with a movable caret; type to insert, arrows move, Backspace deletes, Enter accepts. |
|
| Textarea Multi-line input; Enter inserts a newline, arrows move between lines, Tab accepts, with an external-editor handoff. |
|
| Toggle An inline switch between two labeled values; arrows or Space flip, the first letter of each label sets it directly. |
Six themes ship built-in, selected by name on the Tui facade. Dark or light is a separate mode display option auto-detected from the terminal background, so every adaptive theme serves both:
$tui = (new Tui($form))->theme('midnight');| Name | Palette |
|---|---|
default |
Cyan accents on an auto-detected dark or light base - the out-of-the-box look. |
midnight |
Violet accents, green values, pink highlights. |
frost |
Arctic frost-blue accents, sage values, sand highlights. |
ember |
Burnt-orange accents, olive values, gold highlights. |
mono |
Hue-free - bold weight, gray levels and reverse video for maximum compatibility. |
dos |
Retro MS-DOS: the bright white/cyan/yellow CGA palette in a double-line window, made for a blue terminal background. |
Each renders across every field and degrades to plain text without ANSI. Here the dark palette (left) and the light palette (right); the themes docs also show every theme inside the rounded border frame:
midnight
frost
ember
mono
dos - the CGA blue screen, painted regardless of the terminal background
Write your own by subclassing DefaultTheme and repainting just the voices a palette needs - see the theming guide and the playground's OceanTheme. To change a handful of glyphs and nothing else, skip the class: ->theme(fn(ThemeBuilder $t) => $t->field(fn(FieldOverrides $f) => $f->selector('βΆ', '=>'))) patches the selected theme in place, and anything it does not name keeps that theme's own answer.
See the Contributing guide for the development workflow, quality gates and how the documentation and SVG assets are built.
This repository was created using the Scaffold project template