Skip to content

drevops/tui

Repository files navigation

TUI logo

Panel-based terminal forms for PHP

GitHub Issues GitHub Pull Requests Test PHP codecov GitHub release (latest by date) LICENSE Renovate


Package scaffolder panel TUI

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.

Features

  • 🧭 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, confirm, 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 when rules; a fix-up pass reconciles dependent answers
  • 🔍 Discovery - detect sensible defaults from the target directory (.env keys, 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
  • Unicode and ASCII - glyphs follow the terminal locale and colour honours NO_COLOR; both can be forced on the form

Installation

composer require drevops/tui

Quick start

Declare 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 TUI

It 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.

Widgets

Eleven widget types cover text entry, choices 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.

All widgets, one after another

Text

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 Text: Unicode + ANSI Text: Unicode + No ANSI
ASCII Text: ASCII + ANSI Text: ASCII + No ANSI

Number

Integer input: digits with an optional leading minus, accepted as an int.

$p->number('port', 'HTTP port')->default(8080);
ANSI No ANSI
Unicode Number: Unicode + ANSI Number: Unicode + No ANSI
ASCII Number: ASCII + ANSI Number: ASCII + No ANSI

Textarea

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");
ANSI No ANSI
Unicode Textarea: Unicode + ANSI Textarea: Unicode + No ANSI
ASCII Textarea: ASCII + ANSI Textarea: ASCII + No ANSI

Password

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');
ANSI No ANSI
Unicode Password: Unicode + ANSI Password: Unicode + No ANSI
ASCII Password: ASCII + ANSI Password: ASCII + No ANSI

Select

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 Select: Unicode + ANSI Select: Unicode + No ANSI
ASCII Select: ASCII + ANSI Select: ASCII + No ANSI

MultiSelect

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 MultiSelect: Unicode + ANSI MultiSelect: Unicode + No ANSI
ASCII MultiSelect: ASCII + ANSI MultiSelect: ASCII + No ANSI

Suggest

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 Suggest: Unicode + ANSI Suggest: Unicode + No ANSI
ASCII Suggest: ASCII + ANSI Suggest: ASCII + No ANSI

Search

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 Search: Unicode + ANSI Search: Unicode + No ANSI
ASCII Search: ASCII + ANSI Search: ASCII + No ANSI

MultiSearch

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 MultiSearch: Unicode + ANSI MultiSearch: Unicode + No ANSI
ASCII MultiSearch: ASCII + ANSI MultiSearch: ASCII + No ANSI

Confirm

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 Confirm: Unicode + ANSI Confirm: Unicode + No ANSI
ASCII Confirm: ASCII + ANSI Confirm: ASCII + No ANSI

Pause

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 Pause: Unicode + ANSI Pause: Unicode + No ANSI
ASCII Pause: ASCII + ANSI Pause: ASCII + No ANSI

Panels and navigation

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. 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.

Nested panels

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']);
      });
    });
  });

Nested panels demo

Display modes

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 Scaffolder: Unicode + ANSI Scaffolder: Unicode + No ANSI
ASCII Scaffolder: ASCII + ANSI Scaffolder: ASCII + No ANSI

Configuration

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), ->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.

Derived values

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'));

Conditional fields

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.

Field behaviour

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).

Discovery

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:

Discovery summary with provenance badges

Headless collection

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.php

For 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.

Self-describing answers

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.

Themes

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 terminal

When 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:

Custom ocean theme with a banner

Playground

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, and a theme auto-detection 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.

Architecture

Diagrams of the engine, the collection lifecycle and the panel TUI are in docs/architecture/.

Maintenance

composer install
composer lint
composer test

Updating

To pull the latest infrastructure from the template into this project, ask Claude Code to "update scaffold" - see AGENTS.md for details.


About

WIP! TUI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Sponsor this project

  •  

Packages

 
 
 

Contributors

Languages