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, 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 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
  • ⌨️ 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

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

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.

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

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

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

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

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

With revealable() on, pressing Tab in the editor reveals the value and the hint line shows the toggle:

Password reveal toggle: the value shown as plaintext with a tab reveal hint

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

FilePicker

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

MultiFilePicker

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 Multi file picker: Unicode + ANSI Multi file picker: Unicode + No ANSI
ASCII Multi file picker: ASCII + ANSI Multi file picker: ASCII + No ANSI

Option groups, separators and disabled options

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

A multiselect where the cursor and Space skip the separator and the disabled option, which can never be checked:

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

Toggle

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

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

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

Key bindings

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.

Per-widget-type overrides

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.

Presets and validation

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.

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

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