Field behavior
Behavior beyond a static value is declared on the field itself: a dynamic default, validation and a value transform, each a closure right in the form:
use DrevOps\Tui\Handler\Context;
$p->text('name', 'Produce name')
->default(fn(Context $c): string => basename($c->directory))
->validate(fn(mixed $v): ?string => is_string($v) && preg_match('/\s/u', $v) === 1 ? 'Use a single word.' : NULL)
->transform(fn(mixed $v): mixed => is_string($v) ? trim($v) : $v);
A dynamic default is a closure, so machine-readable output (see AI agents) resolves it against a context with no answers yet - basename($c->directory) above becomes a real string. A default computed from earlier answers has nothing to resolve from that early; declare a static stand-in with ->schemaDefault(...) and the schema advertises it instead of evaluating the closure:
$p->text('slug', 'Basket slug')
->default(fn(Context $c): string => strtolower($c->answers['name']))
->schemaDefault('weekly-box');
Reusable validators and transformers live as public static methods on a class in your code. Reference one explicitly with a first-class callable - ->validate(Ripeness::validate(...)) - or let the engine discover it: register a namespace (new Tui($form, handler_namespaces: ['App\\Handler'])) and the engine resolves the class by field id (red_apple -> RedApple), using its static validate()/transform() whenever the field declares none. When both exist, the field declaration wins.
The TUI only collects. It presents answers and never applies them - writing files, renaming directories, acting on the answers is your 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 the reusable static behavior. (This is exactly what a consumer CLI does.)
Both declaration styles are runnable in playground/06-field-behaviour-*.
Guidance texts
Three texts guide an answer, each declared on its own so a form never has to merge them into one:
$p->text('crop', 'Crop')
->description('The crop this basket was picked from.') // What is being asked.
->hint('Type a few letters to filter.') // How to answer it.
->placeholder('E.g. Golden Beetroot'); // Ghost text, empty input only.
description() says what the question is. It renders under the field row, carries the markdown subset, and widens the panel to fit.
hint() says how to answer it. It renders beneath the description in a style of its own, so guidance never reads as part of the question - the default theme italicizes it, and a theme sets its own (the dos theme colors it instead, since CGA had no italic). It stays plain text either way, because one short instruction carries no formatting of its own.
placeholder() is the ghost text an empty editor shows. It never becomes a value: it disappears at the first keystroke, and it is suppressed when color is off, where it could not be told apart from something typed. Available on the text, number, textarea, password, suggest and search types - the ones with an input buffer to ghost. Declaring one on any other type raises a FormException when the form is built, rather than being quietly ignored.
The description and hint rows are secondary chrome, so compact spacing drops both; a placeholder belongs to the editor and shows whatever the spacing. All three reach machine-readable output, so an agent reads the same guidance a person does - see AI agents.
Required fields
->required() makes an empty value an error, so a validator never has to check emptiness itself. The message is derived from the field label, or declared per field:
$p->text('item', 'Item')->required();
// -> "Item is required."
$p->select('basket', 'Basket')->multiple()
->required(message: 'Add at least one item to the basket.')
->option('apple', 'Apple')->option('carrot', 'Carrot');
Empty means an empty string, an empty list or null - so a cleared text field, a multiple choice with nothing picked, and a missing JSON value all trip it, while a false toggle and a 0 number are answers and pass.
The check runs before the field's own validator and before the type check, in both collection modes:
- Headlessly, an empty supplied input throws an
EngineExceptionnaming the field. A field nothing was supplied for keeps its default - reporting that gap belongs toTui::validate(), which lists it as a missing question. - Interactively, committing an empty value keeps the editor open with the message shown like any other validation error, and the Submit button refuses to finish the form while any active required field is empty. Cancel is never blocked, and a field hidden by its
whencondition is not asked for.
Emptying the name shows the label-derived message in the editor; leaving the basket untouched withholds the submit with that field's declared message instead:
Options from the answers
A choice field's list does not have to be fixed. Hand ->options() a callback that asks for the run context, and one field's choices narrow by another's answer:
use DrevOps\Tui\Handler\Context;
$catalog = [
'fruit' => ['apple' => 'Apple', 'banana' => 'Banana', 'cherry' => 'Cherry'],
'vegetable' => ['carrot' => 'Carrot', 'potato' => 'Potato', 'tomato' => 'Tomato'],
];
$p->select('category', 'Category')->options(['fruit' => 'Fruit', 'vegetable' => 'Vegetable']);
$p->select('item', 'Item')->options(function (Context $c) use ($catalog): array {
// An answer is whatever was supplied until it is validated, so read it
// defensively before it indexes anything.
$category = $c->answers['category'] ?? '';
return is_string($category) ? ($catalog[$category] ?? []) : [];
});
The callback's own signature says when it runs. One that asks for the context follows the answers, as above; one that asks for nothing is the loader it has always been - resolved once when the panel opens, showing a themed Loading… until it returns, and resolved up front when collection is headless and there is no panel to open. Either way it returns the same value => label map the fixed form takes, and the context carries the answers collected so far alongside the target directory, the update flag and the version.
Options are for the types that have a list - select, search, suggest, toggle and reorder. Declaring them on any other type raises a FormException when the form is built, as does declaring a resolver beside a fixed list, a loader or a query source, since the resolved set replaces them.
A resolver runs as part of the form settling - the same pass that computes derived values, evaluates when conditions and applies fix-ups - so every surface sees one narrowed list:
- Interactively, changing the category re-resolves the item list before the next frame, so the editor offers exactly what the new category holds.
- Headlessly, a supplied value is checked against the list the payload's own answers resolve to. A value outside it throws an
EngineExceptionnaming the value and what was allowed. Tui::validate()checks membership against the set the answers under validation resolve to, and the schema resolves the list against whatever context you pass it, flagging the field asoptions_dynamicso tooling can tell an empty list from one that is not fixed.
A choice the narrowed list no longer holds does not survive in the answers: it is dropped, a reorder ranking is completed back to a full permutation, and a toggle returns to its first state. Only a value supplied headlessly is left standing, so it is reported rather than disappearing without a word. A suggest field's options are hints rather than a closed set, so its value is never narrowed away.
Keep the resolver cheap. It runs for the whole form on every settle, not once per panel, and it is called again whenever the answers change - though a settle that changes nothing costs nothing, since the list already answers those answers. For a list that is expensive to build, drop the context parameter and it loads once, on panel entry; for one that lives behind a search API, ->optionsFrom() follows the query instead.
Runnable in playground/19-dynamic-options.php.
Discovery
In update mode, ->discover() rules detect defaults from an existing project directory: a .env key (new Dotenv('SEASON')), a JSON dot-path (new JsonValue('basket.json', 'name')), a path check (new PathExists('harvest.csv')), a directory scan (new Scan('baskets', type: ScanType::Dir)), or a custom fn(Context $c): mixed closure:
$p->text('name', 'Produce name')->discover(new JsonValue('basket.json', 'name'));
$p->confirm('inseason', 'In season?')->discover(new PathExists('harvest.csv'));
Update mode is off by default; the update flag turns it on and reaches both collection paths - collect($prompts, $directory, update: TRUE) headlessly, and run(update: TRUE) or interact(update: TRUE) for the panel TUI. Interactively the panels open pre-filled with the discovered values, each carrying its detected badge; editing one re-badges it edited like any other change. Without the flag every field starts from its declared default.
Discovered values are badged in the panels and the summary alike, and explicit input (prompts or environment) always beats discovery:
Every rule type runs against a bundled sample project in playground/07-discovery.php.