sugarcraft / sugar-prompt
PHP port of charmbracelet/huh — interactive form library (Note, Input, Confirm, Select, MultiSelect, Text, FilePicker, Date, Slider, Color) with multi-page Group support, 7 stock themes, and form-level KeyMap override per binding.
Requires
- php: ^8.3
- sugarcraft/candy-core: dev-master
- sugarcraft/candy-forms: dev-master
- sugarcraft/candy-fuzzy: dev-master
- sugarcraft/sugar-bits: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/candy-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-14 01:57:29 UTC
README
SugarPrompt
composer require sugarcraft/sugar-prompt
sugar-prompt — an interactive form library for terminal UIs, built on SugarCraft + SugarBits, for PHP 8.3+.
use SugarCraft\Prompt\Form; use SugarCraft\Prompt\Field\{Input, Confirm, Select, Note}; $form = Form::new( Note::new('welcome')->title('Onboarding')->desc('A few quick questions.'), Input::new('name')->title('Your name?')->placeholder('Ada Lovelace'), Confirm::new('newsletter')->title('Subscribe to the newsletter?'), Select::new('lang')->title('Favorite language?')->options('PHP', 'Go', 'Rust', 'Python'), ); // $form is a SugarCraft Model — drop it into a Program.
Every field exposes short-form aliases (
title,desc,placeholder,width,height,validator,options,min,max, …). The long forms (withTitle,withDescription, …) work identically — pick whichever reads better at the call site.
Namespace note:
SugarCraft\Prompt\*is a maintained back-compat facade overSugarCraft\Forms\*. New code may use either namespace;SugarCraft\Forms\*is canonical. The@deprecateddoc-comments on sugar-prompt shims exist to signal the primary namespace and are not accompanied by PHPE_USER_DEPRECATEDtriggers (no removal timeline).
Shared foundations
sugar-prompt is a thin facade over four direct dependencies:
| Package | Role |
|---|---|
sugarcraft/candy-forms |
The canonical interactive form engine — every Form, field, and validator symbol in this package is a class_alias re-export of its SugarCraft\Forms\* twin |
sugarcraft/candy-core |
TUI runtime — the Model/Update program loop Form::run() stands on, plus Core\Util\Proc\BoundedShutdown for the spinner child reaper |
sugarcraft/sugar-bits |
Components facade whose widgets the underlying fields wrap (SugarCraft\Bits\TextInput, SugarCraft\Bits\ItemList, SugarCraft\Bits\FilePicker) |
sugarcraft/candy-fuzzy |
Smith-Waterman local-alignment fuzzy matcher — powers withFuzzySuggestions() on Input and Select fields and backs the Fuzzy\FuzzyMatcher alias |
Vim keybindings ride candy-forms' VimKeyHandler: TextInput vim mode
(Insert/Normal/Visual) is shared ecosystem-wide, so bindings added to the
VimAction enum benefit sugar-prompt automatically. sugarcraft/candy-testing
is a dev-only dependency (test harness helpers).
Field types
| Field | Description | Notable knobs |
|---|---|---|
Input |
Single-line text (wraps SugarCraft\Bits\TextInput) |
withPlaceholder, withCharLimit, withWidth, withPrompt, withValidator(\Closure), withTitleFunc / withDescriptionFunc, withPassword(bool, string $echo = '*'), withSuggestions(list<string>), withSuggestionsFunc(\Closure(string):list<string>), withFuzzySuggestions(list<string>), withAsyncSuggestions(callable $fetcher, int $debounceMs = 150) |
Text |
Multi-line text editor | withCharLimit, withMaxLines, withShowLineNumbers, withValidator |
Confirm |
Yes/no boolean | withAffirmative/withNegative, withValidator(\Closure(bool):?string), withTitleFunc, withDescriptionFunc |
Select |
Single-choice list (wraps SugarCraft\Bits\ItemList) |
withOptions(...), withTitleFunc, withDescriptionFunc, withFuzzySuggestions(list<string>), withAsyncSuggestions(callable $fetcher, int $debounceMs = 150), withEnum(\BackedEnum::class) |
MultiSelect |
Multi-choice list (j/k vim keys + space to toggle) | withOptions(...), withLimit(int) |
Note |
Read-only paragraph; skipped by tab navigation | withTitle, withDescription, withHeight(int), withNext(bool), withNextLabel(string) (turns it into an interactive button page) |
FilePicker |
Filesystem picker (wraps SugarCraft\Bits\FilePicker) |
withCwd, withAllowDirs, withAllowFiles, withShowSize, withShowHidden |
Date |
Calendar-grid date picker (YYYY-MM-DD value, arrow-walked grid) |
withValue(?string), withWidth, withValidator(\Closure) |
Slider |
Numeric range slider (keyboard drag, snapping step) | withMin / withMax / withStep, `withValue(int |
Color |
Hex color picker with swatch preview | withValue(string $hex), withTruecolor(bool), withValidator |
All fields share a common navigation contract: Tab / ↓ advances,
Shift+Tab / ↑ retreats, Enter on the last interactive field
submits, Esc / Ctrl+C aborts. Skippable fields (e.g. plain
Notes) are passed over silently.
Forms and groups
Form::new(...$fields) is a single-page form. For multi-page flows
build with Form::groups(Group::new(...$fields), …). Each group
carries its own title / description / hide-predicate / theme override:
Form::groups( Group::new( Input::new('name')->withTitle('Your name?'), Confirm::new('proceed')->withTitle('Continue?'), )->withTitle('Step 1'), Group::new( Note::new('done')->withTitle('Thanks!')->withNext()->withNextLabel('Finish'), ) ->withTheme(Theme::dracula()) ->withShowHelp(false) ->withHideFunc(fn (array $v) => $v['proceed'] !== true), );
Form-level chainables
| Method | What it does |
|---|---|
withTheme(Theme) |
Switch the colour palette. |
withAccessible(bool) |
Render plain label: value text — for screen readers / dumb terminals. |
withShowHelp(bool) |
Toggle the help footer. |
withShowErrors(bool) |
Toggle the inline ! error line on validation failures. |
withWidth(int), withHeight(int) |
Pin the rendered geometry. |
withTimeout(int $ms) |
Auto-abort after $ms of wall clock. |
withKeyMap(KeyMap) (or the Form constructor's $keyMap prop) |
Override the bindings for Next / Prev / Submit / Quit (and per-field nav) on a single form. |
validateAll(): array<string,string> |
Run all field validators and return [fieldKey => errorMessage] for fields that failed. Use after Form::run() to collect cross-field validation failures that cannot be expressed per-field. |
Reading values after submit
values() returns every visible field keyed by key(). For typed
access call getString, getInt, getBool, getArray. For
inspecting validation state during a run use errors(),
hasErrors(), getFocusedField(), keyBinds(), help().
Themes & accessibility
Stock themes ship as static factories on SugarCraft\Prompt\Theme:
ansi() (default), plain(), charm(), dracula(), catppuccin(),
base16(), base(). Pass one to Form::withTheme(...). The accessibility
mode flips the entire form to plain-text rendering — useful when you
detect NO_COLOR=1 or TERM=dumb.
Validators and dynamic labels
Every value-producing field supports withValidator(\Closure). The
closure runs on every keystroke (or every value flip for Confirm)
and returns null for valid or an error string. The error renders
inline beneath the field and shows up in Form::errors().
Use withTitleFunc(\Closure(): string) / withDescriptionFunc(...)
on any field to compute labels lazily — handy when the label depends
on values from a previous group.
Built-in validators
SugarCraft\Prompt\Validator provides five ready-made validators that
cover the most common input constraints. All five implement the
Validator interface (which returns true on valid input, an error
string on invalid). Pass one or more to Input::withValidator() —
multiple calls chain validators together, each running in sequence with
the first error message winning.
use SugarCraft\Prompt\Form; use SugarCraft\Prompt\Field\Input; use SugarCraft\Prompt\Validator\{Required, Email, MinLength, MaxLength, Pattern}; $form = Form::new( Input::new('name') ->withTitle('Full name') ->withPlaceholder('Ada Lovelace') ->withValidator(new Required()) ->withValidator(new MinLength(2)), Input::new('email') ->withTitle('Email address') ->withPlaceholder('you@example.com') ->withValidator(new Required()) ->withValidator(new Email()), Input::new('username') ->withTitle('Username') ->withPlaceholder('ada_lovelace') ->withValidator(new Required()) ->withValidator(new MinLength(3)) ->withValidator(new MaxLength(20)) ->withValidator(new Pattern('/^[a-z0-9_]+$/i', 'Only letters, numbers, and underscores')), );
| Class | Error message | Notes |
|---|---|---|
Required |
Value is required |
Fails on empty string only |
Email |
Must be a valid email address |
Skipped when empty; uses filter_var |
MinLength(int $min) |
Must be at least N characters |
Uses mb_strlen (UTF-8 safe) |
MaxLength(int $max) |
Must be no more than N characters |
Uses mb_strlen (UTF-8 safe) |
Pattern(string $pattern, string $message) |
$message |
Skipped when empty; uses preg_match |
To create a custom validator, implement Validator yourself:
use SugarCraft\Prompt\Validator\Validator; final class NoSpaces implements Validator { public function validate(string $input): true|string { if (str_contains($input, ' ')) { return 'Spaces are not allowed'; } return true; } }
Fuzzy suggestions
Input and Select fields support withFuzzySuggestions() for
fuzzy substring matching via Smith-Waterman local alignment scoring.
Candidates are ranked by score and filtered to only matches with a
positive score.
use SugarCraft\Prompt\Form; use SugarCraft\Prompt\Field\{Input, Select}; $form = Form::new( Input::new('language') ->withTitle('Pick a language') ->withFuzzySuggestions(['PHP', 'Python', 'Go', 'Rust', 'JavaScript', 'TypeScript']), Select::new('framework') ->withTitle('Pick a framework') ->withOptions(['Laravel', 'Symfony', 'Rails', 'Django', 'FastAPI', 'Fiber']) ->withFuzzySuggestions(['Laravel', 'Symfony', 'Rails', 'Django', 'FastAPI', 'Fiber']), );
The fuzzy() short alias is equivalent:
->fuzzy(['PHP', 'Python', 'Go', 'Rust'])
For fine-grained control, use FuzzyMatcher directly:
use SugarCraft\Prompt\Fuzzy\FuzzyMatcher; $matcher = FuzzyMatcher::new(); // Score a single candidate (higher = better match) $score = $matcher->score('js', 'JavaScript'); // 3 // Align a query against one candidate (null when there is no local match) $result = $matcher->match('py', 'Python'); $result?->score; // 11 // Rank a candidate pool — returns list<MatchResult> sorted by score desc $ranked = $matcher->matchAll('py', ['Python', 'PHP', 'Ruby', 'JavaScript']); $ranked[0]->haystack; // 'Python'
FuzzyMatcher is an alias of SugarCraft\Fuzzy\Matcher\SmithWatermanMatcher
(candy-fuzzy). Scoring defaults (SugarCraft\Fuzzy\ScoringProfile): match=+3,
mismatch=-3, gap open=-5, gap extend=-1, adjacent bonus=+5 for
consecutive matches.
Async suggestions
Input and Select support withAsyncSuggestions() for suggestions
fetched asynchronously with a debounce delay. The $fetcher callable
receives the current query string and must return list<string>.
The default debounce is 150 ms — tuned to avoid firing on every keystroke
while still feeling responsive. A SuggestionsReadyMsg is dispatched
via the event loop when fresh suggestions are available.
use SugarCraft\Prompt\Form; use SugarCraft\Prompt\Field\Input; use React\Async\defer; $form = Form::new( Input::new('language') ->withTitle('Pick a language') ->withAsyncSuggestions( defer(fn($query) => fetchFromApi($query)), 150, ), );
The async() short alias is equivalent:
->async(fn($query) => fetchFromApi($query))
Validation
Field-level validation with withValidation
Input and Text fields support withValidation() for predicate-based
validation — a cleaner alternative to withValidator:
$input = Input::new('email') ->withTitle('Email address') ->withPlaceholder('you@example.com') ->withValidation(fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL), 'Must be a valid email'); $text = Text::new('bio') ->withTitle('Biography') ->withValidation(fn($v) => mb_strlen($v) >= 10, 'Must be at least 10 characters');
The predicate receives the field value and must return true for valid
or false for invalid. The error message renders inline beneath the field
and is collected into Form::errors().
Error summary with withErrorSummary
Enable withErrorSummary() on a Form to display all validation errors
at the end when submission fails:
$form = Form::new( Input::new('name') ->withTitle('Name') ->withValidation(fn($v) => !empty(trim($v)), 'Name is required'), Input::new('email') ->withTitle('Email') ->withValidation(fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL), 'Must be a valid email'), )->withErrorSummary(true);
When enabled and the form is submitted with errors, an error summary renders above the form listing every failed field and its error message.
Spinner
A standalone blocking spinner for scripts and CLIs that need a visible "working…" indicator without a full Model–Update–View program:
use SugarCraft\Bits\Spinner\Style as SpinnerStyle; use SugarCraft\Prompt\Spinner; Spinner::new() ->withTitle('Crunching numbers...') ->withStyle(SpinnerStyle::dot()) ->withAction(static function () { // ... long-running work ... // Communicate results out-of-band: the action runs in a forked // child on pcntl hosts; in-memory mutations are not visible to // the parent after run() returns. }) ->run();
Fork semantics on pcntl hosts: the action is executed in a forked child process. This means:
- Return values and in-memory mutations are not visible to the parent.
- If the action throws,
run()throws a\RuntimeException(the original exception type/message cannot cross the fork boundary). - The child is bounded by a wall-clock ceiling (default 600s; tune with
withMaxRuntimeSeconds()). On expiry the reaper escalates TERM→KILL andrun()throws a\RuntimeExceptionwith one of two outcomes: the action "was terminated" (child reaped), or it "could not be terminated" and the child is abandoned with its pid named (escalation ladder exhausted). - Communicate results via tempfile, pipe, database, or other out-of-band mechanism.
On hosts without pcntl_fork, the action runs inline (no animation, no
fork) and exceptions propagate normally.
Test
cd sugar-prompt && composer install && vendor/bin/phpunit
Demos
Confirm
Multi-page form
Input
Multi-select
Select
Text (multi-line)
Themes
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.






