Search by

alexskrypnyk / prompty

alexdrevops

Zero-dependency interactive CLI prompt library

Package info

github.com/AlexSkrypnyk/prompty

Homepage

Issues

pkg:composer/alexskrypnyk/prompty

Fund package maintenance!

alexskrypnyk

Statistics

Installs: 278

Dependents: 0

Suggesters: 0

Stars: 17

1.3.2 2026-11-14 13:55 UTC

This package is auto-updated.

Last update: 2026-11-14 13:56:44 UTC


README

Prompty logo

Zero-dependency interactive CLI prompt library for PHP

GitHub Issues GitHub Pull Requests Test PHP codecov GitHub release (latest by date) LICENSE Renovate OpenSSF Scorecard OpenSSF Best Practices

Nested flow

Prompty asks questions in the terminal for PHP command-line scripts, from a single text prompt to a multi-step wizard. It's 1 file with no dependencies, so you can require it, install it with Composer or embed it in your script. This README is for PHP developers and covers installing Prompty, building prompts and flows, testing them and shipping the script that uses them.

Features

  • ๐Ÿ“ฆ Zero dependencies - drop Prompty.php into your project or embed it in your script
  • ๐Ÿงฉ Widgets - text, select, multiselect, suggest, confirm and pause
  • ๐Ÿ“ฃ Notes - info, success, warning and error messages in the tree
  • โณ Tasks - run work behind a spinner, or stream its output under the label
  • ๐Ÿ”€ Flows - declare a wizard as 1 chain, with an intro, an outro, numbering and cancellation
  • ๐ŸŒณ Nested flows - conditional children drawn as a tree, with null for each skipped step
  • ๐Ÿงฑ Steps built from earlier answers - a step's label, options or default taken from the answers before it
  • ๐Ÿงฉ Step providers - add your own step objects to a flow
  • ๐Ÿ”– Section headings - group a long flow's steps under headings in the tree
  • โšก Standalone mode - use any widget on its own, outside a flow
  • ๐ŸŒ Environment variable discovery - fill answers from environment variables
  • ๐Ÿค– Non-interactive mode - answer every prompt from its default, for CI and scripted runs
  • ๐Ÿ’ฌ Descriptions, hints and help - text under each label, each option and each input
  • โŒจ๏ธ Key legend - list the keys each widget accepts under it
  • ๐Ÿ“œ Long option lists - show a long select or multiselect a few options at a time
  • ๐Ÿ—‚๏ธ Tables and boxes - print a summary table or a titled panel in the tree
  • ๐Ÿ”ง Transform answers - normalize an answer before the widget returns it
  • โœ… Validation - reject an answer, show why and ask again
  • โœจ Unicode and ASCII - detected from the locale, or forced
  • ๐ŸŽจ ANSI colors - on by default, off under NO_COLOR or TERM=dumb
  • โš™๏ธ Configuration - symbols, colors, spacing, labels, the variable prefix and the yes/no values
  • ๐Ÿงช Test harness - PromptyTestTrait feeds keystrokes to widgets in PHPUnit
  • ๐Ÿš€ Starter script - starter.php as a template for your own scripts
  • ๐Ÿ“ฅ Embedding - minify the class and embed it in your script

Contents

Installation

Prompty is a single PHP file with no dependencies. It requires PHP 8.3 or later.

Download from releases: the 3 class variants and the payload files

Download Prompty.php from the latest release assets.

The release has 3 variants of the class:

Asset Description
Prompty.php Full source with version imprinted
Prompty.min.php Minified - comments except the license header and blank lines stripped, class body on 1 line
Prompty.compact.php Minified with no optional whitespace, plus shortened internal names, variables and parameters

For testing, also download PromptyTestTrait.php from the same release.

Each release also ships Prompty.min.payload and Prompty.compact.payload. Each holds the exact code embed.php writes between the markers in that mode. To check an embedded script against them, see Verify an embedded script.

To embed the class in your script, see Embedding.

Verifying a download: check the signed checksums and the attestations

Every release ships a SHA256SUMS file signed with the maintainer's GPG key, and a build attestation for every PHP file and payload. To check a download with GPG or the GitHub CLI, follow Verifying a release in SECURITY.md, which also lists the key's fingerprint.

Composer: install the package with Composer
composer require alexskrypnyk/prompty

PromptyTestTrait is included in the package.

Usage

Require the file and use the class directly:

require_once __DIR__ . '/Prompty.php';

use AlexSkrypnyk\Prompty\Prompty;

$dish = Prompty::text('Dish name');

To ship a single file instead, embed the minified class in your script. To start from a template, copy the starter script.

Embedding

embed.php minifies Prompty.php and writes it into your script, so the script ships as 1 file with no require_once. Minifying strips every comment except the license header, drops the static analysis tags from that header, drops blank lines and puts the class body on 1 line.

Download embed.php with Prompty.php from the latest release assets.

Setup: put the embed markers in your script

In your script, put // @embed-start and // @embed-end markers around the require_once line and the use import:

<?php

declare(strict_types=1);

// phpcs:disable
// @embed-start
require_once __DIR__ . '/Prompty.php';
use AlexSkrypnyk\Prompty\Prompty;
// @embed-end
// phpcs:enable

$dish = Prompty::text('Dish name');

Keep the use import inside the markers. The embedded class has no namespace, so an import left outside them collides with the class name and the embedded script fails to compile.

Run the embedder: embed in place or into a separate file

To embed in place, run embed.php on your script:

php embed.php my-script.php

To write the result to a separate file and leave your script unchanged, add an output path:

php embed.php my-script.php dist/my-script.php

Before it embeds, embed.php writes the code it inserts between the markers to Prompty.min.payload beside the output script. With --compact, the file is Prompty.compact.payload. Open the file to read the code your script will carry. Each release ships a file of the same name, so you can compare the 2 hashes. To skip writing the file, pass --no-payload-file.

After embedding, embed.php prints the size of each file involved:

Sizes:
  Target script before    1,799 bytes     50 lines  my-script.php
  Prompty source        216,660 bytes  6,439 lines  /path/to/Prompty.php
  Prompty embeddable    105,488 bytes     22 lines  Prompty.min.payload (48.7% of the source)
  Target script after   107,278 bytes     68 lines  my-script.php (+105,479 bytes)

To silence coding-standard warnings on the minified code, wrap the markers in // phpcs:disable and // phpcs:enable.

starter.php has the markers in place. playground/05-flows-embed.php is the demo the embedder is tested against, and CONTRIBUTING.md shows how to build and compare its embedded copy.

Options: every embed.php option

embed.php takes these options:

Option Description
--source <path> Sets the class file to embed. Defaults to Prompty.php beside embed.php.
--compact Strips optional whitespace, shortens internal names and renames local variables and parameters.
--stdout Writes the processed class as a standalone PHP file instead of embedding it.
--payload Writes only the code that embedding puts between the markers.
--check Checks an embedded script. See Verify an embedded script.
--sums <path> With --check, sets the SHA256SUMS file of the release that the start marker names.
--no-killswitch Skips adding the kill switch block on the first embed, and the verification run.
--no-verify Skips the verification run but still adds the kill switch block.
--no-payload-file Skips writing the embedded code to a .payload file beside the output script.

For example:

php embed.php --compact my-script.php
php embed.php --source /path/to/Prompty.php my-script.php
php embed.php --stdout Prompty.min.php

With --stdout or --payload, embed.php takes an output file path in place of a target script. The release workflow uses --stdout to build Prompty.min.php and Prompty.compact.php, and --payload to build the 2 .payload assets.

Re-embedding: update an embedded script to a newer Prompty

To update an embedded script to a newer Prompty, replace Prompty.php with the new version and run embed.php again. embed.php replaces the code between the markers and leaves everything outside them as it is, so it adds no kill switch:

php embed.php my-script.php

If the new Prompty.php is in another directory, point --source at it:

php embed.php --source /path/to/new/Prompty.php my-script.php
Kill switch: the block that stops the script under tests

On the first embed, embed.php looks for if (!getenv('SHOULD_PROCEED')) in your script. If the line is missing, embed.php adds this block after the embed region and prints a line saying so:

// Kill switch - stop here when running under tests.
// In production, set SHOULD_PROCEED=1 to continue past this point.
if (!getenv('SHOULD_PROCEED')) {
  return;
}

The added block sits before your prompts, so the script asks nothing and does nothing unless SHOULD_PROCEED is set. For tests that answer the prompts and stop before the real work, write the kill switch yourself after the flow, as starter.php does.

To skip adding the block on the first embed, pass --no-killswitch:

php embed.php --no-killswitch my-script.php
Verification run: how embed.php checks the file it wrote

After writing the file, embed.php checks it with php -l and then runs it to confirm the embedded class works. In a terminal, you answer any prompts that come before the kill switch. Without a terminal, embed.php pipes in a few newlines and discards the output.

The run happens only when the file has a kill switch, which stops the run before the real work unless SHOULD_PROCEED is set. The run inherits your environment, so with SHOULD_PROCEED set, unset it or pass --no-verify. --no-killswitch skips the run too, even when the script has its own kill switch.

To add the kill switch but skip the run, pass --no-verify. Use it in build steps, CI jobs and scripts that regenerate an embedded file:

php embed.php --no-verify my-script.php
Verify an embedded script: check embedded code against its marker or a release

Embedded code sits on 1 line that's too long to review in a diff, so embed.php records what it embedded and can check it later. Embedding writes 5 attributes into the start marker line:

// @embed-start class=Prompty version=1.2.0 mode=min sha256=f9a6c847...d278f embedder=46232669...8a747
Attribute Value
class The name of the embedded class.
version The class's VERSION constant: a release tag, development for an unreleased copy, or unknown when the class has none.
mode min, or compact for an embed made with --compact.
sha256 The SHA-256 of the code between the start marker line and the end marker line.
embedder The SHA-256 of the embed.php that did the embedding.

Re-embedding rewrites the whole line. The same Prompty.php, embed.php and mode always produce the same code. An embed made from a release's Prompty.php and embed.php therefore matches that release's .payload asset byte for byte.

To check that the embedded code still matches its marker:

php embed.php --check my-script.php

To also check the code and the embedder against a release, pass the SHA256SUMS of the release the marker names. --check trusts that file as given, so verify its signature first, as in Verifying a download:

php embed.php --check my-script.php --sums SHA256SUMS

--check prints OK or FAIL for each comparison, and exits with 1 when any comparison fails. Without --sums, it also prints NOT VERIFIED AGAINST A RELEASE. It reads Windows line endings as Unix ones.

After every embed into a script, embed.php prints 3 snippets for the script's repository. It fills in the script's path and the repository's GitHub remote, and lists any placeholder it couldn't fill. The --stdout, --payload and --check modes print no snippets. For my-script.php outside a git repository, the snippets look like this.

A README section that shows your users how to verify the script:

## Verifying my-script.php

`my-script.php` runs on your machine with your permissions, so check that the file you downloaded is the one this repository built. Each release includes a build attestation for `my-script.php`. GitHub Actions signs it and ties it to the commit and workflow run that built the file.

To verify the file with the [GitHub CLI](https://cli.github.com):

```bash
curl -fsSL https://github.com/<owner>/<repo>/releases/latest/download/my-script.php -o my-script.php
gh attestation verify my-script.php --repo <owner>/<repo>
php my-script.php
```

`gh attestation verify` fails when the file changed after the build or came from another repository's workflows.

To verify the file without the GitHub CLI, compare it with the checksum attached to the release:

```bash
curl -fsSL https://github.com/<owner>/<repo>/releases/latest/download/my-script.php -o my-script.php
curl -fsSL https://github.com/<owner>/<repo>/releases/latest/download/my-script.php.sha256 -o my-script.php.sha256
sha256sum -c my-script.php.sha256    # macOS: shasum -a 256 -c my-script.php.sha256
```

When you download `my-script.php` from a host other than GitHub, tampering with that host alone fails the check, because the checksum comes from GitHub. Both checks trust this repository: anyone who controls it can rebuild the attestation and the checksum along with the file.

### The embedded Prompty library

`my-script.php` embeds [Prompty](https://github.com/AlexSkrypnyk/prompty) as minified code that's too long to review in a diff. On every pull request, CI compares that code with the signed Prompty release named in its `// @embed-start` marker. The check fails when 1 byte differs.

A workflow, .github/workflows/publish-my-script.yml, that attaches the script, its checksum and a build attestation to each release:

name: Publish my-script.php

on:
  release:
    types:
      - published

permissions:
  contents: read

jobs:
  publish:
    name: Publish my-script.php
    runs-on: ubuntu-latest

    permissions:
      contents: write     # Upload to the release.
      id-token: write     # Sign the attestation.
      attestations: write # Record the attestation.

    steps:
      - name: Checkout code
        uses: actions/checkout@v7 # Pin to a commit SHA.
        with:
          persist-credentials: false

      # Hash the file exactly as users download it. If a build step changes
      # it after checkout, run that step first and copy its output instead.
      - name: Checksum my-script.php
        run: |
          mkdir -p .publish
          cp my-script.php .publish/my-script.php
          cd .publish
          sha256sum my-script.php > my-script.php.sha256

      - name: Attest my-script.php
        uses: actions/attest-build-provenance@v4 # Pin to a commit SHA.
        with:
          subject-path: .publish/my-script.php

      - name: Upload my-script.php to the release
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ github.event.release.tag_name }}
        run: gh release upload "${TAG}" .publish/my-script.php .publish/my-script.php.sha256 --clobber

A job for the workflow that tests your pull requests. It downloads the Prompty release named in the marker, verifies it, checks the script against it and confirms that a changed copy fails:

  check-embedded-prompty:
    name: Check embedded Prompty
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v7 # Pin to a commit SHA.
        with:
          persist-credentials: false

      - name: Setup PHP
        uses: shivammathur/setup-php@v2 # Pin to a commit SHA.
        with:
          php-version: '8.3'

      - name: Download the Prompty release named in the marker
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          version="$(sed -n 's/.*@embed-start.* version=\([^ ]*\).*/\1/p' my-script.php)"
          gh release download "${version}" --repo AlexSkrypnyk/prompty --dir .prompty --pattern embed.php --pattern SHA256SUMS --pattern SHA256SUMS.asc

      - name: Verify the Prompty release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh attestation verify .prompty/embed.php --repo AlexSkrypnyk/prompty
          curl -fsSL https://raw.githubusercontent.com/AlexSkrypnyk/prompty/main/PUBLIC_KEY.asc | gpg --batch --import
          gpg --batch --status-fd 1 --verify .prompty/SHA256SUMS.asc .prompty/SHA256SUMS | grep -q '^\[GNUPG:\] VALIDSIG 755E824E80F849135F5F4043E71EDB25C4F6D89D '
          cd .prompty
          sha256sum --ignore-missing -c SHA256SUMS

      - name: Check the embedded code
        run: php .prompty/embed.php --check my-script.php --sums .prompty/SHA256SUMS

      # Proves the check above fails on a changed copy, so it can't pass
      # without checking anything.
      - name: Check that a changed copy fails
        run: |
          cp my-script.php .prompty/changed.php
          sed -i '/@embed-start/{n;s/^/ /;}' .prompty/changed.php
          ! php .prompty/embed.php --check .prompty/changed.php --sums .prompty/SHA256SUMS

The job passes only for a script embedded from a release. Re-embed from the release you want to pin, and commit the new marker line with it.

For AI agents: commands to embed and update
Embed into starter script
BASE=https://github.com/AlexSkrypnyk/prompty/releases/latest/download

# Download Prompty.php, embed.php, and the starter template.
curl -LO $BASE/Prompty.php
curl -LO $BASE/embed.php
curl -LO $BASE/starter.php

# Rename the starter to your script name.
mv starter.php my-script.php

# Embed the minified class into the script.
php embed.php my-script.php
Embed into custom script
BASE=https://github.com/AlexSkrypnyk/prompty/releases/latest/download

# Download Prompty.php and embed.php.
curl -LO $BASE/Prompty.php
curl -LO $BASE/embed.php

# Add markers in your script around the require_once line and the import.
# Both must sit inside the markers - the embedded class has no namespace,
# so an import left outside them collides with it:
#
#   // phpcs:disable
#   // @embed-start
#   require_once __DIR__ . '/Prompty.php';
#   use AlexSkrypnyk\Prompty\Prompty;
#   // @embed-end
#   // phpcs:enable

# Embed the minified class into the script.
php embed.php my-script.php
Update Prompty
BASE=https://github.com/AlexSkrypnyk/prompty/releases/latest/download

# Download the new Prompty.php, overwriting the old one.
curl -LO $BASE/Prompty.php

# Re-run the embedder. Code outside the markers is preserved.
php embed.php my-script.php

# Or use --source if the new Prompty.php is in a different location.
php embed.php --source /path/to/new/Prompty.php my-script.php

Starter script

starter.php is a template for your own scripts. It follows the kill switch pattern, which keeps a script testable. The script does 3 things in order:

  1. Runs the flow.
  2. Returns if the flow returns null.
  3. Returns unless SHOULD_PROCEED is set, so tests stop before the real work.
Details: Starter script

The following excerpt shows the pattern:

$results = Prompty::flow()
  ->text('dish', 'Dish name', placeholder: 'pear tart')
  // ...
  ->run(intro: 'Compose an order');

if ($results === NULL) {
  return; // Cancelled, so there is nothing to act on.
}

if (!getenv('SHOULD_PROCEED')) {
  return; // Tests stop here.
}

// Real work below - only runs in production.
echo 'Dish: ' . $results['dish'] . "\n";

To start a script, copy starter.php under a new name and replace its steps with your own.

Test harness

PromptyTestTrait drives widgets from PHPUnit. It feeds keystrokes from a memory stream and captures the output, so tests run without a terminal.

Details: Test harness

To assert on what a flow returns, use promptyRun():

use AlexSkrypnyk\Prompty\Prompty;
use AlexSkrypnyk\Prompty\PromptyTestTrait;
use PHPUnit\Framework\TestCase;

require_once 'Prompty.php';
require_once 'PromptyTestTrait.php';

class MyFlowTest extends TestCase {
  use PromptyTestTrait;

  protected function tearDown(): void {
    $this->promptyTearDown();
    parent::tearDown();
  }

  public function testMyFlow(): void {
    $keystrokes = $this->promptyKeys(
      'plum compote', self::KEY_ENTER,  // type dish + submit
      self::KEY_DOWN, self::KEY_ENTER,  // select second option
    );

    $run = $this->promptyRun(fn(): mixed => Prompty::flow()
      ->text('dish', 'Dish name')
      ->select('course', 'Course', options: [
        'starter' => 'Starter',
        'main' => 'Main',
      ])
      ->run(), $keystrokes);

    $this->assertSame('plum compote', $run['result']['dish']);
    $this->assertSame('main', $run['result']['course']);
    $this->assertStringContainsString('Dish name', $run['output']);
  }
}

To test a whole script, use promptyRunScript(). Leave the script's kill switch variable unset so the script stops before its real work, then read the answers from Prompty::results():

public function testMyScript(): void {
  $keystrokes = $this->promptyKeys(
    'plum compote', self::KEY_ENTER,
    self::KEY_DOWN, self::KEY_ENTER,
  );

  $this->promptyRunScript(function (): void {
    require 'my-script.php';
  }, $keystrokes);

  $results = Prompty::results();
  $this->assertSame('plum compote', $results['dish']);
  $this->assertSame('main', $results['course']);
}

Both helpers return an array whose output key holds the output with ANSI codes stripped. promptyRun() adds a result key with the callback's return value.

Under both helpers, widgets draw ASCII symbols and wrap lines at 80 columns, so the output doesn't depend on the terminal that runs the tests. To change either in promptyRun(), pass a configuration array such as ['unicode' => TRUE, 'width' => 120] as its third argument.

A widget that reads past the last keystroke cancels, so a test with too few keystrokes gets null instead of hanging.

Test a script that configures Prompty: keep the script's own settings in a test

promptyRun() and promptyRunScript() each install a new instance, so they drop any settings passed to Prompty::configure() before the helper runs. To keep those settings, let the script configure Prompty first, then pass the keystrokes to promptyInput():

public function testTakeOrder(): void {
  // Calls Prompty::configure() with the script's own settings.
  Kitchen::bootstrap();

  $this->promptyInput($this->promptyKeys(
    'pear tart', self::KEY_ENTER,
    self::KEY_DOWN, self::KEY_ENTER,
  ));

  $order = Kitchen::takeOrder();

  $this->assertSame('pear tart', $order['dish']);
}

promptyInput() sets the keystrokes as the input of the current instance and changes no other setting. With no instance yet, it creates one that draws ASCII symbols and wraps lines at 80 columns. A later Prompty::configure() call changes that same instance. Calling promptyInput() again replaces the keystrokes.

promptyInput() captures no output. Widgets write to the script's output target, or with echo when the script sets none. promptyTearDown() closes the keystroke stream and resets the instance.

Test a rejected answer: assert on a validation message

A widget whose validate callback rejects an answer asks again. In a test, it then reads past the last keystroke and cancels, so the test gets null. To assert on the message instead, call promptyThrowOnInvalid() first:

public function testRejectsSingleWord(): void {
  $this->promptyThrowOnInvalid();

  $this->expectException(PromptyValidationException::class);
  $this->expectExceptionMessage('Name the whole dish');

  $this->promptyRun(fn(): mixed => Prompty::text('Dish name',
    validate: fn(string $dish): ?string => str_contains($dish, ' ')
      ? NULL : 'Name the whole dish, such as "pear tart".',
  ), $this->promptyKeys('pear', self::KEY_ENTER));
}

The first rejected answer throws AlexSkrypnyk\Prompty\PromptyValidationException, which PromptyTestTrait.php defines. Its label, value and reason properties hold the widget label, the rejected answer and the callback's message. Its message reads Value "pear" for "Dish name" is not valid., followed by the callback's message.

A rejected discovered: value or non-interactive default throws the same class. The class extends InvalidArgumentException, which those rejections throw without the switch.

The switch lasts across promptyRun() calls until promptyTearDown() resets it. To switch it off sooner, call promptyThrowOnInvalid(FALSE).

Available key constants: the keys promptyKeys() accepts

Pass these constants to promptyKeys():

Constant Key
KEY_ENTER Enter
KEY_SPACE Space
KEY_BACKSPACE Backspace
KEY_TAB Tab
KEY_ESCAPE Escape
KEY_CTRL_C Ctrl+C
KEY_CTRL_U Ctrl+U
KEY_UP โ†‘
KEY_DOWN โ†“
KEY_LEFT โ†
KEY_RIGHT โ†’
KEY_HOME Home
KEY_END End
KEY_DELETE Delete

Widgets

Prompty has 6 widgets that ask a question, and 2 that report between questions: notes and tasks. Each question widget returns the answer, or null when the user presses Escape or Ctrl+C. Expand a widget's Details for its usage and examples.

Widgets draw only printable text. A newline or tab in a value, label or option label becomes a space. Escape sequences and other control characters are removed, so a value can't break the tree or control the terminal. text and suggest return the same printable text they drew. A description also keeps its text styling.

Text, select, multiselect and confirm widgets in sequence

Text

ANSI No ANSI
Unicode Text: Unicode + ANSI Text: Unicode + No ANSI
ASCII Text: ASCII + ANSI Text: ASCII + No ANSI

Free-form text input, with an optional default and placeholder.

Details: Text
$dish = Prompty::text('Dish name',
  default: 'pear tart',
  placeholder: 'e.g. pear tart',
  description: "Written on the order ticket and under\n"
    . '"Specials" on the board.',
);

default pre-fills the input with text the user can edit. Prompty draws it dim while the input still matches it. placeholder is the gray text shown while the input is empty.

The user edits a pre-filled value in place with these keys:

Key Effect
โ† / โ†’ Moves the cursor 1 character.
Home / End Moves the cursor to the start or end.
Backspace Deletes the character before the cursor.
Delete Deletes the character under the cursor.
Any printable character Inserts it at the cursor.

To draw the default dim and italic, override the input_default color:

Prompty::configure(colors: ['input_default' => "\e[2;3m"]);

Submitting an empty input returns the placeholder string itself, so a placeholder also works as a fallback answer. With a default set, submitting without typing returns the default. To let an empty answer stay empty, leave both unset.

A discovered value follows the same rule. Prompty trims it, and an empty result takes the default, or the placeholder when no default is set:

// All 3 answer 'pear tart'.
// Submitted without typing:
Prompty::text('Dish name', placeholder: 'pear tart');
// Discovered as empty or blank:
Prompty::text('Dish name', placeholder: 'pear tart', discovered: '');
Prompty::text('Dish name', placeholder: 'pear tart', discovered: '  ');

In a flow with a dish step, an empty PROMPTY_DISH= takes the fallback without prompting. An unset PROMPTY_DISH opens the prompt, where submitting without typing gives the same fallback.

A placeholder such as e.g. pear tart is an example, so it makes a poor answer. To keep it out of the results, pass placeholder_fallback: FALSE to Prompty::configure(). To set it for 1 flow, pass it to the flow's run(). An empty submit then returns '', and so does a blank discovered value when no default is set:

Prompty::configure(placeholder_fallback: FALSE);

// Returns '' when the user clears the input and presses Enter.
Prompty::text('Dish name', default: 'pear tart', placeholder: 'e.g. pear tart');

The option covers text and suggest, and both still draw the placeholder while the input is empty. To make text or suggest re-prompt on an empty answer, pass required.

Select

ANSI No ANSI
Unicode Select: Unicode + ANSI Select: Unicode + No ANSI
ASCII Select: ASCII + ANSI Select: ASCII + No ANSI

Pick 1 option from a list. Press the arrow keys to move and Enter to confirm. To focus an option other than the first, pass its key as default.

Details: Select
$course = Prompty::select('Course',
  options: [
    'starter' => 'Starter',
    'main' => 'Main',
    'dessert' => 'Dessert',
  ],
  default: 'main',
  description: 'Where the dish sits in the meal.',
  hints: [
    'starter' => 'Served first, in a small portion.',
    'main' => 'The centre of the meal.',
    'dessert' => 'Sweet, served last.',
  ],
);

options must hold at least 1 option. An empty options throws an InvalidArgumentException before the widget draws anything, even when the answer comes from discovered or an environment variable:

No options declared for "Course". Provide at least one option.

Multiselect

ANSI No ANSI
Unicode Multiselect: Unicode + ANSI Multiselect: Unicode + No ANSI
ASCII Multiselect: ASCII + ANSI Multiselect: ASCII + No ANSI

Pick any number of options from a list. Press Space to toggle an option and Enter to confirm. To pre-check options, pass a list of their keys as default. Pre-checking every option makes an opt-out list.

Details: Multiselect
$extras = Prompty::multiselect('Extras',
  options: [
    'bread' => 'Bread',
    'olives' => 'Olives',
    'herbs' => 'Herbs',
  ],
  default: ['bread', 'olives', 'herbs'],
  description: 'Anything served alongside.',
  help: 'Space to toggle, enter to confirm.',
);

As with select, an empty options throws an InvalidArgumentException that names the widget.

The answer lists the checked keys in option order. To answer a saved list in its own order, pass order:

$extras = Prompty::multiselect('Extras',
  options: [
    'bread' => 'Bread',
    'olives' => 'Olives',
    'herbs' => 'Herbs',
  ],
  default: ['herbs', 'bread'],
  order: 'default',
);

Accepting that default gives ['herbs', 'bread']. Pick the order that matches how you use the answer:

  • 'options', the default, lists the keys in option order.
  • 'default' lists the default keys that are still checked, in default order. Newly checked keys follow in option order.
  • 'selection' lists the keys in the order they were checked. The default keys count as checked first, in default order. A key checked again moves to the end.
  • A closure sorts the keys. It's a comparator, as for usort(): it receives 2 option keys and returns an int.

A comparator starts from the 'selection' order, so keys it ranks equal stay in the order they were checked. To answer the checked keys sorted by their labels:

$options = [
  'bread' => 'Bread',
  'olives' => 'Olives',
  'herbs' => 'Herbs',
];

$extras = Prompty::multiselect('Extras',
  options: $options,
  order: fn(string $a, string $b): int => strcmp($options[$a], $options[$b]),
);

Pass a function as a closure, such as strcmp(...). A string order is read as 1 of the 3 names. A comparator that returns anything other than an int throws an UnexpectedValueException.

The same order applies to a discovered value and an environment variable. With 'selection', those keep the order they give. An unknown order throws an InvalidArgumentException before the widget draws anything.

Suggest

ANSI No ANSI
Unicode Suggest: Unicode + ANSI Suggest: Unicode + No ANSI
ASCII Suggest: ASCII + ANSI Suggest: ASCII + No ANSI

Free-form text input with suggestions. As you type, the options that contain the typed text show under the input, ignoring case. Press โ†‘ or โ†“ to focus a suggestion and Enter to take it. With no suggestion focused, Enter takes the typed text as is, so the answer can be anything.

Details: Suggest
$dish = Prompty::suggest('Dish',
  options: [
    'Pear Tart',
    'Apple Crumble',
    'Onion Soup',
    'Lentil Stew',
    'Herb Omelette',
    'Barley Salad',
    'Plum Compote',
    'Walnut Loaf',
  ],
  placeholder: 'Pear Tart',
  description: 'Pick from the menu, or type a dish that is not on it.',
  scroll: 4,
);

After typing a and pressing โ†“:

โ—‡  Dish
โ”‚  Pick from the menu, or type a dish that is not on it.
โ”‚
โ”‚  aโ–ˆ
โ”‚โฏ Pear Tart
โ”‚  Apple Crumble
โ”‚  Barley Salad
โ”‚  Walnut Loaf
โ”‚

Pressing โ†“ on the last suggestion, or โ†‘ on the first, unfocuses it and leaves the typed text as the answer. Typing or deleting a character also clears the focus.

default, placeholder and discovered values work as they do for text. A discovered value can be any text, in options or not. To show a long list a few suggestions at a time, pass scroll, as described in Long option lists.

An empty options list throws an InvalidArgumentException, as with select. The keys of options are ignored; the widget returns the suggestion text.

To compute the suggestions yourself, pass a closure as options. It receives the typed text and returns the suggestions to draw, which Prompty draws without filtering:

$menu = ['Pear Tart', 'Plum Compote', 'Walnut Loaf'];

// Suggests only the dishes that start with the typed text.
$starts_with = fn(string $typed): array => array_values(array_filter(
  $menu,
  fn(string $dish): bool => stripos($dish, $typed) === 0,
));

$dish = Prompty::suggest('Dish', options: $starts_with);

options takes a closure, so pass a named function as find_dishes(...). Prompty calls the closure each time the typed text changes, and never for a discovered value. A return value that isn't an array suggests nothing, and entries that aren't strings or numbers are skipped.

Confirm

ANSI No ANSI
Unicode Confirm: Unicode + ANSI Confirm: Unicode + No ANSI
ASCII Confirm: ASCII + ANSI Confirm: ASCII + No ANSI

A yes/no toggle. Press the arrow keys or Tab to switch, y or n to pick directly and Enter to confirm.

Details: Confirm
$send = Prompty::confirm('Send order?',
  description: 'Passes the order to the kitchen.',
);

To end a flow when the answer is No, pass cancel_on_no: TRUE. A No then draws its answer line and returns null, so a flow draws its cancelled message and run() returns null, as on Escape. The No can come from a key press, a discovered value or an environment variable.

Pause

ANSI No ANSI
Unicode Pause: Unicode + ANSI Pause: Unicode + No ANSI
ASCII Pause: ASCII + ANSI Pause: ASCII + No ANSI

A message that waits for a key, such as "Press any key to continue" after a welcome screen. Any key continues and returns true.

Details: Pause
Prompty::pause('Press any key to start the order...',
  description: 'The kitchen opens at noon.',
);

To continue only on Enter and ignore every other key, pass enter_only: TRUE. After the key, a completed line replaces the message. To erase the message instead, pass erase: TRUE.

In non-interactive mode, pause() returns true without reading a key. Inside a flow, a pause is a keyed step that stores true.

Notes

ANSI No ANSI
Unicode Notes: Unicode + ANSI Notes: Unicode + No ANSI
ASCII Notes: ASCII + ANSI Notes: ASCII + No ANSI

note() prints a message between your questions: an informational note, a success, a warning or an error. The type's symbol takes the place of the bar, and the message is drawn in the type's color.

Details: Notes
Prompty::note('The kitchen opens at 5pm.');
Prompty::note('Order sent to the kitchen.', type: 'success');
Prompty::note('The pear tart is running low.', type: 'warning');
Prompty::note("The oven did not reach temperature.\nCheck the fan and try again.", type: 'error');
โ—  The kitchen opens at 5pm.
โ”‚
โœ“  Order sent to the kitchen.
โ”‚
โ–ฒ  The pear tart is running low.
โ”‚
โœ•  The oven did not reach temperature.
โ”‚  Check the fan and try again.
โ”‚

The type defaults to info. A newline starts another line, and a long line wraps with the bar and indent kept. The continuation rows of a line keep its leading spaces, so an indented list stays together. Each type has a note_<type> symbol and a note_<type> color that you can configure. A note waits for no answer, so it also prints in non-interactive mode.

Inside a flow, ->note() adds a note without a key. It sits in the tree where you declare it, among top-level steps or children, and adds nothing to the results. Follow it with ->when() to show it only for some answers:

$results = Prompty::flow()
  ->note('The kitchen opens at 5pm.')
  ->select('course', 'Course', options: ['main' => 'Main', 'dessert' => 'Dessert'])
  ->note('The pear tart is running low.', type: 'warning')
    ->when(fn(array $r): bool => $r['course'] === 'dessert')
  ->confirm('send', 'Send order?')
  ->run();

To mention an earlier answer, pass a closure as the message or the type. The closure receives the answers so far and runs when the flow reaches the note:

$results = Prompty::flow()
  ->multiselect('stations', 'Stations', options: ['grill' => 'Grill', 'pastry' => 'Pastry'])
  ->note(fn(array $r): string => 'Next: ' . (in_array('grill', $r['stations'], TRUE) ? 'light the grill' : 'warm the pastry oven'))
  ->run();

A closure that returns anything other than a string throws an UnexpectedValueException. A type closure that returns a name other than info, success, warning or error throws an InvalidArgumentException. A note hidden by ->when() calls neither closure.

Prompty::note() called while the flow runs, such as from a task's action or the outro callable, prints straight away.

Tasks

ANSI No ANSI
Unicode Tasks: Unicode + ANSI Tasks: Unicode + No ANSI
ASCII Tasks: ASCII + ANSI Tasks: ASCII + No ANSI

task() runs work between your questions, such as a download or a build step, and reports the result. While the action runs, the label shows a spinner. After the action returns, the label, the hint and a result line replace the spinner.

Details: Tasks
$ready = Prompty::task('Preheating the oven',
  action: fn(): bool => $oven->preheat(180),
  hint: 'Fan oven, 180ยฐC',
  success: 'Oven ready',
  failure: 'Oven did not reach temperature',
);

The finished task looks like this:

โ—†  Preheating the oven
โ”‚  Fan oven, 180ยฐC
โ”‚  โœ“ Oven ready
โ”‚

The action fails when it returns false and succeeds with any other value. task() returns the action's return value. hint, success and failure each take a string, or a callable that receives the return value:

$degrees = Prompty::task('Preheating the oven',
  action: fn(): int => $oven->preheat(180),
  success: fn(int $degrees): string => sprintf('Oven at %dยฐC', $degrees),
);

An empty success or failure draws the done or failed label. When the action returns a list of strings, each string is drawn as a dim detail line under the label.

An exception from the action draws a failure line, and then task() rethrows it. The failure line is the exception message, unless you pass failure. A failure callable receives the exception, so it can reword the message:

Prompty::task('Lighting the grill',
  action: fn(): bool => $grill->light(),
  failure: fn(\Throwable $e): string => 'Grill not lit: ' . $e->getMessage(),
);

A hint callable isn't called after an exception, because the action returned no value.

The spinner animates in a forked child process, which needs the pcntl and posix extensions. Without them, it shows 1 still frame. It also shows a still frame while your script buffers output with ob_start() or writes to a callable output target.

A task redraws only on a live terminal: interactive, with ANSI on and output going to a terminal. Otherwise, the label is printed before the action runs and the result after it.

Inside a flow, ->task() adds a keyed step that takes ->when() and ->children() like any other. The flow stores true when the action succeeded and false when it failed:

$results = Prompty::flow()
  ->text('dish', 'Dish name', placeholder: 'pear tart')
  ->task('oven', 'Preheating the oven', action: fn(): bool => $oven->preheat(180))
  ->confirm('send', 'Send order?')
    ->when(fn(array $r): bool => $r['oven'])
  ->run();

In a flow, the action receives the answers collected so far, with null for a skipped step:

$results = Prompty::flow()
  ->text('dish', 'Dish name', placeholder: 'pear tart')
  ->task('order', 'Preparing the order',
    action: fn(array $r): bool => $kitchen->prepare($r['dish']),
    success: 'Order ready',
  )
  ->run();

Wrap a built-in function such as time in a closure. A built-in function that takes no arguments throws an ArgumentCountError when the flow passes it the answers.

Streamed output

Some work prints its own output while it runs, such as a build or a package install. To draw that output under the task in place of a spinner, pass stream: TRUE.

Details: Streamed output
Prompty::task('Baking the tart',
  action: function (): bool {
    echo "Oven at 180ยฐC\n";
    passthru('bake --timer 40m 2>&1', $code);

    return $code === 0;
  },
  success: 'Tart baked',
  failure: 'The tart did not bake',
  stream: TRUE,
);

On a live terminal, the last 5 lines stay on screen under the label, with a count of the earlier lines. A line still being written, such as a progress meter, updates in place:

โ—‡  Baking the tart
โ”‚  โ†‘ 2 more
โ”‚    Rising 100%
โ”‚    Crust golden at the edges
โ”‚    Tray 2 of 2 in the top rack
โ”‚    Fan cycling, temperature held
โ”‚    Both trays out, resting on the rack

When the action succeeds, the window collapses into the result line:

โ—†  Baking the tart
โ”‚  โœ“ Tart baked (7 lines)
โ”‚

When the action fails or throws, the window expands into every line it wrote, because the cause is usually among them. Without a live terminal, each line is printed once as it arrives.

Each line is drawn dim and indented on the tree's bar, with its colors kept. A long line wraps with its indent and its own leading spaces kept. In the window, it's cut to 1 row with an ellipsis. A line redrawn with carriage returns shows only its last state.

The stream captures output that reaches PHP's output layer:

  • echo and print.
  • Child processes run with passthru() or system(). Their stderr goes straight to the terminal, so add 2>&1 to the command to stream it too.
  • Prompty's own output, such as Prompty::output().

Output written straight to a file descriptor reaches the terminal unstyled. That covers fwrite(STDOUT, ...) and a process started with proc_open() that inherits the terminal.

A streamed task draws on the output target it started on, even when its action calls Prompty::configure(output: ...). Everything the action writes after that call stays in the stream, including Prompty's own output. The new target takes over after the task.

A streamed action can run other tasks, streamed or behind a spinner, at any depth. Each nested task prints its label, output and result line once, indented under the task that runs it. After a nested task returns, the outer action keeps streaming. On a live terminal, the nested lines scroll through the outer window:

Prompty::task('Baking the tart',
  action: function (): bool {
    echo "Oven at 180ยฐC\n";

    Prompty::task('Making the pastry',
      action: function (): bool {
        echo "Flour and butter rubbed together\n";

        return TRUE;
      },
      success: 'Pastry made',
      stream: TRUE,
    );

    echo "Tart in the top rack\n";

    return TRUE;
  },
  success: 'Tart baked',
  stream: TRUE,
);

Without a live terminal, the tasks print:

โ—‡  Baking the tart
โ”‚    Oven at 180ยฐC
โ”‚    โ—‡  Making the pastry
โ”‚    โ”‚    Flour and butter rubbed together
โ”‚    โ”‚
โ”‚    โ”‚  โœ“ Pastry made
โ”‚    โ”‚
โ”‚    Tart in the top rack
โ”‚
โ”‚  โœ“ Tart baked
โ”‚

Common arguments

The 5 question widgets, text, select, multiselect, suggest and confirm, share these arguments. pause also takes description and help:

Argument Widgets Effect
description Question widgets, pause Draws text under the label.
hints select, multiselect Draws text under the focused option.
help Question widgets, pause Draws a usage tip under the input.
default Question widgets Sets the starting answer.
transform Question widgets Rewrites the answer before the widget returns it.
required Question widgets Rejects an empty answer.
validate Question widgets Rejects an answer with a message and asks again.
Descriptions, hints and help: text under the label, the options and the input

Every widget takes a description, shown under the label. A description can span several lines:

Prompty::text('Dish name',
  description: "Written on the order ticket and under\n"
    . '"Specials" on the board.',
);

A description draws dim and italic. It keeps the SGR sequences it carries, so bold, italic, underline and colors written as \e[...m still show. Prompty removes every other escape sequence and control character. After a sequence that switches an attribute off, such as \e[0m or \e[22m, the text returns to dim italic. Each line ends with a full reset, so styling stops at the end of its line:

Prompty::select('Course',
  options: [
    'starter' => 'Starter',
    'main' => 'Main',
    'dessert' => 'Dessert',
  ],
  description: "Where the dish sits in the meal.\n"
    . "\e[1mDessert\e[22m goes out \e[4mafter the mains\e[24m.",
);

With ANSI output off or NO_COLOR set, Prompty removes the styling and draws the description as plain text.

select and multiselect also take hints, keyed by option. The focused option's hint shows under the list and can span several lines:

Prompty::select('Method',
  options: [
    'baked' => 'Baked',
    'poached' => 'Poached',
    'grilled' => 'Grilled',
  ],
  hints: [
    'baked' => 'Dry heat, all the way through.',
    'poached' => "Gently, in barely moving liquid.\n"
      . 'Keeps delicate things whole.',
    'grilled' => 'Over the flame for colour. Fast, hot, and unforgiving.',
  ],
);

Every widget also takes help, a usage tip drawn dim under the input or the option list. Put how to answer in help and what the question means in description:

Prompty::multiselect('Extras',
  options: [
    'bread' => 'Bread',
    'olives' => 'Olives',
    'herbs' => 'Herbs',
  ],
  description: 'Anything served alongside.',
  hints: [
    'bread' => 'Warm, cut at the table.',
    'olives' => 'Marinated, served in oil.',
    'herbs' => 'Picked the same morning.',
  ],
  help: 'Space to toggle, enter to confirm.',
);

The help shows only while the widget waits for an answer. The line drawn after an answer or a cancellation leaves it out. A widget that takes a discovered value draws no help. On select and multiselect, the focused option's hint stays under that option, and the help follows the whole list:

โ—‡  Extras
โ”‚  Anything served alongside.
โ”‚
โ”‚โฏ โ—ป Bread
โ”‚    โ†ณ Warm, cut at the table.
โ”‚  โ—ป Olives
โ”‚  โ—ป Herbs
โ”‚
โ”‚  Space to toggle, enter to confirm.
โ”‚

Like a description, the help can span several lines.

Default values: the starting answer of each widget

Every widget takes a default that sets its starting state:

Widget default type Effect
text string Pre-fills the editable input buffer.
select string (option key) Focuses that option instead of the first.
multiselect list<int|string> (keys) Pre-checks those options.
suggest string Pre-fills the editable input buffer.
confirm bool Starts on Yes (true) or No (false).

A discovered: value or a PROMPTY_* variable wins over default and skips the prompt. The exception is a blank value for text or suggest, which takes the default. In non-interactive mode, default is the answer when neither is set.

For select and multiselect, every key in default must be a key of options. See Discovered and default values must be valid answers.

Transform answers: normalize an answer before the widget returns it

Every widget takes a transform callback that rewrites the answer before the widget returns it. Use it to normalize free input, such as a dish name typed in any case:

// Typing '  PEAR tart ' returns 'Pear Tart'.
$dish = Prompty::text('Dish name',
  transform: fn(string $value): string => ucwords(strtolower(trim($value))),
);

The callback receives the answer alone, so a built-in function such as trim(...) works as a transform. Its return value is the answer: the completed line shows it, and a flow stores it, so a later step's condition receives it. A discovered: value, a PROMPTY_* variable and the default answered in non-interactive mode go through transform too. Canceling a widget skips the callback.

The callback returns the type its widget answers with:

Widget Receives and returns Completed line shows
text string The returned text, with control characters removed.
select string (option key) The option's label for a key of options, otherwise the string.
multiselect list<string> (keys) Each entry as for select, joined by commas. None for no entries.
suggest string The returned text, with control characters removed.
confirm bool Yes or No.

A return value of any other type throws an UnexpectedValueException that names the widget:

Transform for "Dish name" returned null. A transform returns a string, the type the widget answers with.
Validation: reject an answer, show why and ask again

Every widget takes a validate callback that can reject an answer. The callback receives the answer and returns null for a valid answer, or an error message:

$dish = Prompty::text('Dish name',
  placeholder: 'pear tart',
  validate: fn(string $dish): ?string => str_contains($dish, ' ')
    ? NULL : 'Name the whole dish, such as "pear tart".',
);

When the callback returns a message, the widget stays open and draws the message in red under the input. The message clears when the answer changes. Each Enter runs the callback again, and the widget completes only on an answer that passes.

The answer has the type the widget returns:

Widget validate receives
text The string, including a default or placeholder taken on empty input
select The option key
multiselect The list of option keys
suggest The focused suggestion, or the string as for text
confirm true or false

On a widget with a transform, validate receives the transformed answer, so the rule checks what the widget returns.

Inside a flow, the second argument holds the answers collected so far, so a rule can depend on an earlier step. Outside a flow, it's an empty array:

$results = Prompty::flow()
  ->select('course', 'Course', options: $courses)
  ->multiselect('extras', 'Extras', options: $extras,
    validate: fn(array $extras, array $r): ?string =>
      $r['course'] === 'dessert' && in_array('olives', $extras, TRUE)
        ? 'Olives are not served with dessert.' : NULL,
  )
  ->run();

An empty message counts as valid, like null. Any other return type throws an UnexpectedValueException, so a callback written to return a boolean fails at the first answer:

Validator for "Dish name" returned bool. A validator returns NULL for a valid answer or an error message string.

A discovered: value or a PROMPTY_* variable goes through validate too. With nobody to ask again, the widget draws the rejected value and the message:

โ—‡  Dish name
โ”‚  pear
โ”‚  โ–ฒ Name the whole dish, such as "pear tart".
โ”‚

Then it throws an InvalidArgumentException. Prompty::isDrawn() returns true for it, because the message is already on screen:

Discovered value "pear" for "Dish name" is not valid. Name the whole dish, such as "pear tart".

In non-interactive mode, the default that answers a widget goes through validate the same way, and the message opens with Default value.

The message shows a multiselect answer comma-separated and a confirm answer as true or false. The checks against options and the truthy and falsy lists run first, so validate receives only answers the widget accepts.

Required answers: reject an empty answer

To reject an empty answer, pass required to any widget. TRUE shows the required label, which reads Required. by default. A string shows that message instead:

$guest = Prompty::text('Guest name',
  required: 'Enter a name for the ticket.',
);
$extras = Prompty::multiselect('Extras', options: $extras, required: TRUE);

An empty answer is a blank string, including one of only spaces, or a multiselect with nothing selected. A text or suggest answer that takes its default or placeholder isn't empty. select and confirm always have an answer, so required rejects only a select whose transform returns ''.

The check runs after transform and before validate, so validate receives only answers that aren't empty. required also checks discovered: values, PROMPTY_* variables and the default in non-interactive mode. It throws for an empty one, as validate does for a rejected one:

Discovered value "" for "Guest name" is not valid. Enter a name for the ticket.

Flows

Prompty::flow() starts a step-by-step wizard. You add each step to 1 chain under its own key. run() then asks the steps in order.

Details: Flows

Each widget method takes the step key first, then the same arguments as the widget on its own:

$results = Prompty::flow()
  ->text('dish', 'Dish name', placeholder: 'pear tart')
  ->select('course', 'Course', options: [
    'starter' => 'Starter',
    'main' => 'Main',
    'dessert' => 'Dessert',
  ])
  ->multiselect('extras', 'Extras', options: [
    'bread' => 'Bread',
    'olives' => 'Olives',
    'herbs' => 'Herbs',
  ])
  ->confirm('send', 'Send order?')
  ->run(intro: 'Compose an order', outro: 'Order sent!');

The chain has a method for each widget: ->text(), ->select(), ->multiselect(), ->suggest(), ->confirm(), ->pause() and ->task(). ->note() and ->section() add a note and a heading, and take no key. Each key can appear once in the flow, so a reused key throws an InvalidArgumentException where you add it.

run() returns the answers keyed by step, or null when the user cancels any step. A No on a ->confirm() step with cancel_on_no: TRUE cancels the flow too.

An input stream with nothing left to read counts as a cancellation. A flow run from a pipe, from /dev/null or in CI without a terminal returns null at the first prompt it can't read. To answer every step from its default instead, use non-interactive mode.

run() takes the flow's options as named arguments. intro, outro, cancelled and failed each take a string or a callable. A callable receives the answers collected so far and returns the message to draw:

$results = Prompty::flow()
  // ...
  ->run(
    intro: 'Compose an order',
    outro: fn(array $results): string => $results['dish'] . ' is on its way.',
    cancelled: 'Order cancelled.',
    numbering: TRUE, // Renders (1), (2), nested as (1.1), (1.2), etc.
  );

When the callable returns null, the flow draws no message, so the callable can print its own output:

outro: function (array $results): void {
  Prompty::note('Plate the ' . $results['dish'] . ' warm.');
  Prompty::outro('Order sent!');
},

Any other return value throws an UnexpectedValueException.

When a step throws, the flow closes the tree with the failed message in red, and then run() rethrows the exception. failed takes a string or a callable, as outro does. It defaults to the failed label. A task draws the exception message as its failure line, so your catch block only sets the exit code:

try {
  Prompty::flow()
    ->text('dish', 'Dish name', placeholder: 'pear tart')
    ->task('grill', 'Lighting the grill', action: fn(): bool => $grill->light())
    ->run(intro: 'Kitchen order', failed: 'Kitchen order not sent.');
}
catch (\RuntimeException $e) {
  exit(1);
}
โ”Œ  Kitchen order
โ”‚
โ—†  Dish name
โ”‚  pear tart
โ”‚
โ—‡  Lighting the grill
โ”‚  โ–ฒ The gas supply is off.
โ”‚
โ””  Kitchen order not sent.

The flow draws no failed message when the step throws before the flow has drawn anything.

A rejected discovered: value, PROMPTY_* variable or non-interactive default also draws its message in the tree. Other exceptions draw only the failed message, such as one thrown by a closure argument. To print the message only when the tree lacks it, check Prompty::isDrawn():

catch (\Throwable $e) {
  if (!Prompty::isDrawn($e)) {
    fwrite(STDERR, $e->getMessage() . PHP_EOL);
  }

  exit(1);
}

->intro('Compose an order') sets the intro inside the chain instead. An intro passed to run() replaces it.

Standalone mode: use any widget on its own, outside a flow

Called outside a flow, a widget sets up the terminal, asks its question and returns the answer:

require_once 'Prompty.php';

use AlexSkrypnyk\Prompty\Prompty;

$dish = Prompty::text('Dish name');
$course = Prompty::select('Course', options: [
  'starter' => 'Starter',
  'main' => 'Main',
]);
$send = Prompty::confirm('Send order?');

echo "Sending $dish for the $course course...\n";

A script can mix standalone widgets and flows:

// Standalone widgets can sit between flows.
$dish = Prompty::flow()->text('dish', 'Dish name')->run(intro: 'Step 1: The dish');
$note = Prompty::text('Kitchen note');
$extras = Prompty::flow()->multiselect('extras', 'Extras', options: $extras)->run(intro: 'Step 2: Extras');
Environment variable discovery: fill answers from environment variables

Inside a flow, each step can take its answer from an environment variable named after the step key, uppercased, with the PROMPTY_ prefix. A widget called outside a flow reads no variables:

PROMPTY_DISH='pear tart' PROMPTY_COURSE=main php your-script.php

The flow draws dish and course as completed steps and prompts only for the rest.

To change the prefix for 1 flow, pass env_prefix. To change it for every flow, pass it to Prompty::configure():

// Reads KITCHEN_DISH, KITCHEN_COURSE, etc.
$results = Prompty::flow()
  // ...
  ->run(env_prefix: 'KITCHEN_');

A multiselect reads its variable as a comma-separated list, so PROMPTY_EXTRAS=bread,olives selects both options. The result is deduplicated and follows the widget's order. With the default 'options', PROMPTY_EXTRAS=olives,bread,olives also gives ['bread', 'olives']. A trailing comma is ignored.

Every widget also takes a discovered: argument for an answer the script already holds, such as a parsed --extras=bread,olives flag. A widget given a discovered: value skips its prompt, inside a flow or outside one. In a flow, the discovered: value wins over the environment variable.

A multiselect reads a discovered: string as a comma-separated list too, so a string from getopt() and a list give the same answer:

Prompty::multiselect('Extras',
  options: $extras, discovered: 'bread,olives');
Prompty::multiselect('Extras',
  options: $extras, discovered: ['bread', 'olives']);

The list form takes each entry as is. Use it to select an option key that contains a comma.

A confirm matches its value against the truthy and falsy lists, after trimming and ignoring case. The defaults are 1, true and yes for yes, and 0, false and no for no, so YES and True both count as yes. A boolean discovered: value is taken as is.

A step key of dish_name reads PROMPTY_DISH_NAME. A name a shell can export holds only letters, digits and underscores. It doesn't start with a digit. Only a parent process can set a name such as PROMPTY_DISH-NAME, as in env 'PROMPTY_DISH-NAME=x' php your-script.php. export rejects it in bash, zsh and sh. Most .env parsers reject it too. run() throws an InvalidArgumentException for such a step before the first prompt:

Step "dish-name" is looked up as the environment variable "PROMPTY_DISH-NAME", which cannot be exported. A variable name may hold only letters, digits and underscores, and may not start with a digit.

Prompty checks the full variable name, so an env_prefix that can't be exported fails the same way. With an empty prefix, a key that starts with a digit fails too.

Each key must also be unique across the whole flow, children included. A flow stores every answer in 1 flat array keyed by step, so a reused key would overwrite an earlier answer. The chain rejects a reused key where you add it:

Step "dish" appears more than once in the flow. Each answer is stored under its step key, so a later step would overwrite the earlier answer in the results.
Discovered and default values must be valid answers: an invalid value throws before the widget draws

A discovered value must be an answer the widget accepts interactively:

  • For select and multiselect, a key of options
  • For confirm, a value in the truthy or falsy list
  • For any widget with a validate callback, an answer the callback accepts

Any other value throws an InvalidArgumentException that names the widget, the value and the accepted values:

Discovered value "pudding" for "Course" is not a valid option. Available options: starter, main, dessert.

The check covers both the discovered: argument and the PROMPTY_* variable, with or without a terminal. text accepts any value, and an empty one falls back to the default or placeholder.

select and multiselect check default against options too, and the message names the parameter:

Default value "pudding" for "Course" is not a valid option. Available options: starter, main, dessert.

An empty default is always valid. '' makes select focus the first option, and [] makes multiselect pre-check nothing. Both checks run when the widget starts, before it draws, so a mistyped key in a CI variable or a script fails at the widget that reads it.

A blank value means no answer was supplied: how each widget treats an empty value

A blank value is empty or holds only whitespace. An exported variable that was never filled gives one, and so does DISH="$(some-command)" when the command prints nothing. A widget with an answer that means "nothing supplied" takes that answer, and the other widgets reject a blank value:

Widget A blank value resolves to
text The default, or the placeholder when no default was seeded
suggest The default, or the placeholder when no default was seeded
multiselect An empty selection, []
select Rejected - one choice has no answer that means "nothing chosen"
confirm Rejected - yes and no are the only answers

A blank value differs from an unset variable. An unset PROMPTY_COURSE opens the prompt. PROMPTY_COURSE= fails the same check as any other invalid value, and the message shows the trimmed value:

Discovered value "" for "Course" is not a valid option. Available options: starter, main, dessert.

The rule covers both the discovered: argument and the PROMPTY_* variable.

Non-interactive mode: answer every prompt from its default, for CI and scripted runs

A script run in CI or with a --no-interaction flag has nobody to answer its prompts. Pass interactive: FALSE, and every widget answers without reading a key:

// For every widget and flow.
$opts = getopt('', ['no-interaction']);
Prompty::configure(interactive: !isset($opts['no-interaction']));

// For 1 flow.
$results = Prompty::flow()
  // ...
  ->run(interactive: FALSE);

Each widget takes the first answer it finds, in this order:

  1. The discovered: argument
  2. The PROMPTY_* variable, inside a flow
  3. The default

The following table lists the answer each widget gives when its default is left empty:

Widget Answer
text An empty string, ''
select The first option
multiselect An empty selection, []
suggest An empty string, ''
confirm Yes, true

A placeholder is an example of what to type, so text and suggest never answer with it in this mode. A blank discovered value takes the default, or '' when no default is set.

The checks in Discovered and default values must be valid answers still run, so an invalid default throws an InvalidArgumentException.

Each widget still draws its completed lines, so the output logs every answer. To write that log elsewhere, set the output target. The help is left out, and Prompty leaves the terminal settings as they are.

Input is on by default, so a flow without a terminal still cancels at its first prompt.

Step numbering

numbering: TRUE adds the step number to each label: (1) and (2) at the top level, (2.1) for a child of step 2. To show how many top-level steps are due as well, pass numbering: 'total'. To leave a step unnumbered, such as a task, follow it with ->unnumbered().

Details: Step numbering
$results = Prompty::flow()
  // ...
  ->run(numbering: 'total');
โ—†  Dish name (1/5)
โ”‚  pear tart
โ”‚
โ—†  Course (2/5)
โ”‚  Dessert
โ”‚
โ—†  Finishes (3/6)
โ”‚  Dusted

The total counts the completed top-level steps, the current step and each remaining top-level step whose condition passes against the answers so far. An answer can make a later step due or not due, so the next step shows the new total. A step keeps the total it was answered with. In the preceding output, the Dessert answer made Finishes due. Children keep the (2.1) form, and sections aren't counted.

Prompty tests each remaining condition before the answers it reads exist. While counting, a condition that raises a warning or throws counts as not due. When the flow reaches the step, Prompty calls the condition again, and any error surfaces there.

To change the format, set the step_total label. It's a sprintf() format that receives the step number and then the total, (%d/%d) by default:

Prompty::flow()
  // ...
  ->run(numbering: 'total', labels: ['step_total' => '[%d of %d]']);

A step_total format that needs more than 2 values, or holds an unknown conversion, throws an InvalidArgumentException. So does a numbering string other than 'total'.

To leave a step out of the numbering, follow it with ->unnumbered(). The step draws no number, the next step takes the number it would have had, and 'total' leaves it out of the count. Use it for a task that acts on the answers:

$results = Prompty::flow()
  ->text('dish', 'Dish name', default: 'pear tart')
  ->select('table', 'Table', options: ['4' => 'Table 4', '7' => 'Table 7'], default: '7')
  ->confirm('send', 'Send order?')
  ->task('oven', 'Preheating the oven', action: fn(): bool => $oven->preheat(180), success: 'Oven ready')
    ->unnumbered()
  ->run(numbering: 'total');
โ—†  Dish name (1/3)
โ”‚  pear tart
โ”‚
โ—†  Table (2/3)
โ”‚  Table 7
โ”‚
โ—†  Send order? (3/3)
โ”‚  Yes
โ”‚
โ—‡  Preheating the oven
โ”‚  โœ“ Oven ready

The children of an unnumbered step draw no numbers either. ->unnumbered() after a section or a note, or with no step before it, throws a LogicException.

Nested flows with conditions

->children() opens a scope under the step added before it, and ->end() closes it. Prompty draws the steps in between as an indented tree under that step, joined by vertical bars. ->when() gives the step, note or section added before it a condition, which receives the answers collected so far. When the condition returns false, the flow skips the step and its children.

Details: Nested flows with conditions

Indent the chain the way the tree draws: the steps inside ->children() 1 level under their parent, and each ->when() under the step it applies to:

$results = Prompty::flow()
  ->select('course', 'Course', options: ['main' => 'Main', 'dessert' => 'Dessert'])
    ->children()
      ->select('method', 'Method', options: [
        'baked' => 'Baked',
        'poached' => 'Poached',
        'grilled' => 'Grilled',
      ])
        ->when(fn(array $r): bool => $r['course'] === 'main')
        ->children()
          ->confirm('rest', 'Rest before serving?')
        ->end()
      ->multiselect('finishes', 'Finishes', options: [
        'glazed' => 'Glazed',
        'dusted' => 'Dusted',
        'piped' => 'Piped',
      ])
        ->when(fn(array $r): bool => $r['course'] === 'dessert')
    ->end()
  ->run();

Scopes nest to any depth. An ->end() with no open scope throws a LogicException that names the step before it. So does run() while a scope is still open, naming the step whose children are open.

Every step the flow declares gets an answer. A skipped step answers null, and so does every step under it, in the answers each condition receives and in the array run() returns. Picking Dessert and then Glazed in the example gives:

['course' => 'dessert', 'method' => NULL, 'rest' => NULL, 'finishes' => ['glazed']]

A step the flow hasn't reached yet has no key in the answers, so null always means the step was skipped. A later condition can read a skipped step's key without a ?? guard.

Steps built from earlier answers

A widget argument can be a closure that receives the answers collected so far. The flow calls it when it reaches the step, so a step's label, options, default or description can come from an earlier answer.

Details: Steps built from earlier answers
$results = Prompty::flow()
  ->text('dish', 'Dish name', placeholder: 'pear tart')
  ->select('course', 'Course', options: [
    'main' => 'Main',
    'dessert' => 'Dessert',
  ])
  ->select('method', 'Method',
    options: fn(array $r): array => $r['course'] === 'main'
      ? ['grilled' => 'Grilled', 'poached' => 'Poached']
      : ['baked' => 'Baked', 'poached' => 'Poached'],
    description: fn(array $r): string => 'How the ' . $r['dish'] . ' is cooked.',
  )
    ->children()
      ->confirm('rest', 'Rest before serving?')
    ->end()
  ->text('note', 'Kitchen note', default: fn(array $r): string => $r['dish'] . ', ' . $r['method'])
    ->when(fn(array $r): bool => $r['course'] === 'dessert')
  ->run();

A skipped step never calls its closures. A step built this way is numbered, drawn in the tree and filled from its environment variable like any other step.

Any argument that takes a value can take a closure instead, including label, default, options, placeholder, description, help, hints, scroll, required and discovered. Arguments that already take a callable keep their meaning:

  • transform and validate receive the answer, as on the widget.
  • A suggest's options closure receives the typed text and returns the suggestions.
  • A multiselect's order closure compares 2 option keys.
  • A task's action runs the work, and its hint, success and failure closures receive the action's return value.

A note takes a closure for its message and its type in the same way.

Step providers

An app that already describes its questions as objects can pass them into the chain with ->add(). Each object implements PromptyStepInterface, which names the step's key, its widget and the widget's arguments, and decides whether the flow asks the step.

Details: Step providers

PromptyStepInterface has 4 methods:

Method Returns
key(): string The step key the answer is stored under.
widget(): string 'text', 'select', 'multiselect', 'suggest' or 'confirm'.
arguments(array $results): array The widget's named arguments for the answers so far, including label.
condition(array $results): bool false to skip the step and its children.

To ask for the dessert of an order, implement the interface:

use AlexSkrypnyk\Prompty\Prompty;
use AlexSkrypnyk\Prompty\PromptyStepInterface;

class DessertStep implements PromptyStepInterface {

  public function key(): string {
    return 'dessert';
  }

  public function widget(): string {
    return 'select';
  }

  public function arguments(array $results): array {
    return [
      'label' => 'Dessert after the ' . $results['dish'],
      'options' => ['tart' => 'Tart', 'compote' => 'Compote'],
    ];
  }

  public function condition(array $results): bool {
    return $results['course'] === 'main';
  }

}

Then add an instance to the chain. Providers and widget methods mix freely, and ->children() and ->end() work after ->add() as after any step:

$results = Prompty::flow()
  ->text('dish', 'Dish name')
  ->select('course', 'Course', options: ['main' => 'Main', 'dessert' => 'Dessert'])
  ->add(new DessertStep())
    ->children()
      ->confirm('cream', 'Cream on the side?')
    ->end()
  ->run();

The flow calls key() when you add the step, and the other 3 methods when it reaches the step. A ->when() after ->add() replaces the provider's condition(). A widget() outside the 5 names, or arguments() holding a ctx key, throws an UnexpectedValueException when the flow reaches the step.

Section headings

A long flow can group its steps under headings. Add ->section() between top-level steps. The flow draws its title in bold, with an optional dim description under it.

Details: Section headings
$results = Prompty::flow()
  ->section('The dish', description: 'Name the dish and place it in the meal.')
  ->text('dish', 'Dish name', placeholder: 'pear tart')
  ->select('course', 'Course', options: [
    'main' => 'Main',
    'dessert' => 'Dessert',
  ])
  ->section('Service')
  ->select('table', 'Table', options: [
    '4' => 'Table 4',
    '7' => 'Table 7',
    '12' => 'Table 12',
  ])
  ->multiselect('extras', 'Extras', options: fn(array $r): array => $r['course'] === 'dessert'
    ? ['honey' => 'Honey', 'almonds' => 'Almonds']
    : ['bread' => 'Bread', 'olives' => 'Olives'])
  ->run(intro: 'Compose an order');

The headings sit in the tree among the steps:

โ”Œ  Compose an order
โ”‚
โ”œ  The dish
โ”‚  Name the dish and place it in the meal.
โ”‚
โ—†  Dish name
โ”‚  pear tart
โ”‚
โ—†  Course
โ”‚  Dessert
โ”‚
โ”œ  Service
โ”‚
โ—‡  Table

A section adds nothing to the results. Every step after it still receives every earlier answer in its conditions and closures, and numbering continues across sections. A newline in the title or the description starts a new line.

A section's group is the steps up to the next section. The flow draws a heading only when at least 1 step in its group renders. In a flow with no intro, the first heading opens the tree with โ”Œ. To hide a heading by itself, follow it with ->when(). The steps under it keep their own conditions.

->section() throws an exception in 2 cases:

  • Its title is blank. It throws an InvalidArgumentException.
  • It sits inside a ->children() scope. It throws a LogicException.

Step back to an earlier step

In a flow, press Ctrl+U to go back. The widget you're on disappears, and the previous step opens again with your answer filled in, as you gave it before any transform. Its children don't count as previous steps, so from a later sibling the key reopens the parent. From a step's first child, it reopens that parent. When the parent asked nothing, the key reopens the last of its children that did.

Details: Step back to an earlier step

Answers you gave after the reopened step become those steps' defaults, so you only press Enter to move forward again. A step starts from its declared default instead in 2 cases:

  • The new answer makes the step's condition false. The flow skips it, and it answers null. When a later change shows the step again, it starts from the answer it had before it was skipped.
  • A closure argument builds a widget the old answer doesn't fit, such as options that no longer include it.

Steps answered from a discovered value or an environment variable asked nothing, so the key skips them. task() and pause() steps ask nothing either. A task after the reopened step runs its action again when you move forward, and a task before it keeps its outcome. At a pause, the back key steps back instead of continuing. On the first asked step, and outside a flow, the key does nothing.

To use another key, pass back_key to Prompty::configure() or to the flow's run(), from ctrl-a to ctrl-z. ctrl-c, ctrl-h, ctrl-i, ctrl-j and ctrl-m already mean cancel, backspace, tab and enter, so they throw an InvalidArgumentException. To turn the key off, pass '':

Prompty::configure(back_key: 'ctrl-b');

The flow erases the rows it drew since the reopened step. Rows that scrolled off the top of the terminal stay in the scrollback, so on a very long flow the reopened step draws at the top of the screen.

Configuration

Prompty::configure() sets options for the rest of the process. A flow's run() takes the same options as named arguments and merges them over the global ones for that flow. Every option is optional, so pass only what you change.

Details: Configuration

A per-flow option lasts until the flow returns, whether the flow completes, is canceled or throws. The next flow gets the global settings again.

The following example sets options globally, then for 1 flow:

// Global.
Prompty::configure(
  unicode: FALSE,
  env_prefix: 'KITCHEN_',
  labels: ['yes' => 'Yep', 'no' => 'Nope'],
);

// Per-flow (merges on top).
$results = Prompty::flow()
  // ...
  ->run(
    env_prefix: 'ORDER_',
    truthy: ['1', 'true', 'yes', 'on'],
    falsy: ['0', 'false', 'no', 'off'],
  );

Available options

The following table lists every option and its default:

Parameter Type Description
unicode bool Forces Unicode or ASCII symbols. Detected from the locale by default.
ansi bool Forces ANSI colors on or off. On unless NO_COLOR or TERM=dumb is set.
env_prefix string Prefix for environment variable discovery. Default PROMPTY_.
labels array<string, string> UI labels. The keys follow this table.
truthy list<string> Values a confirm reads as yes. Default 1, true, yes.
falsy list<string> Values a confirm reads as no. Default 0, false, no.
interactive bool If false, every widget answers without reading input. Default true.
scroll int Options select, multiselect and suggest show at once. Default 0 shows all.
placeholder_fallback bool If false, text and suggest answer an empty submit with ''. Default true.
width int Columns that lines wrap to. Default 0 detects the terminal width.
legend bool If true, each widget draws the keys it accepts. Default false.
back_key string Key that steps back in a flow, ctrl-a to ctrl-z. Default ctrl-u; '' turns it off.
symbols_unicode array<string, string> Unicode symbol overrides.
symbols_ascii array<string, string> ASCII symbol overrides.
colors array<string, string> ANSI color escape overrides.
spacing array<string, string> Indentation strings.
output resource|callable Where Prompty writes: a writable stream or a callable. Default echo.
Option keys: the keys of labels, symbols, colors and spacing

The map-valued options take these keys:

  • symbols_unicode, symbols_ascii: bar, completed, active, intro, outro, section, pointer, radio_on, radio_off, check_on, check_off, hint_arrow, error, scroll_up, scroll_down, success, spinner, ellipsis, box_top_left, box_top_right, box_bottom_left, box_bottom_right, box_horizontal, box_vertical, box_cross, box_top_tee, box_bottom_tee, box_left_tee, box_right_tee, legend_vertical, legend_horizontal, legend_separator, note_info, note_success, note_warning, note_error. Each character of spinner is 1 frame.
  • colors: reset, dim, dim_italic, cyan, green, red, gray, bold, white, yellow, blue, italic, underline, note_info, note_success, note_warning, note_error, input_default, input_cursor
  • spacing: indent, hint_indent, hint_cont
  • labels: yes, no, cancelled, none, separator, more, required, done, failed, line, lines, step_total, legend_move, legend_toggle, legend_enter, legend_space, legend_type, legend_yes_no, legend_any_key, legend_continue, legend_back
Unknown keys are rejected: a misspelled key throws at the call

The map-valued options accept only the keys in the preceding list, which Prompty::config() also returns. An unknown key throws an InvalidArgumentException that lists the valid keys, so a misspelling fails at the call:

Configuration key "bogus" for "labels" is not valid. Available keys: yes, no, cancelled, none, separator, more.

truthy and falsy must each hold at least 1 value, and neither accepts a blank value. Values match after trimming, so a blank entry would accept the empty value that confirm otherwise rejects.

configure() validates every argument before it applies any, so a rejected call changes nothing:

// Throws, and neither the labels nor the prefix are changed.
Prompty::configure(
  labels: ['yes' => 'Yep'],
  env_prefix: 'KITCHEN_',
  truthy: [],
);

An empty env_prefix makes discovery read the uppercased step key alone. A step named path, home or user then reads PATH, HOME or USER, so keep a prefix unless you mean that.

Unicode and ASCII: detected from the locale, or forced

Prompty detects Unicode support from the locale in LANG, LC_ALL or LC_CTYPE. A UTF-8 locale gets symbols such as โ—†, โ—‡, โ”‚ and โ—. Any other locale gets their ASCII forms: +, o, | and (*).

To force a mode for every widget and flow:

Prompty::configure(unicode: FALSE); // Always ASCII
Prompty::configure(unicode: TRUE);  // Always Unicode

To force it for 1 flow:

$results = Prompty::flow()
  // ...
  ->run(unicode: FALSE);
ANSI colors: on by default, off under NO_COLOR or TERM=dumb

Prompty uses ANSI colors unless the NO_COLOR environment variable is set to a non-empty value or TERM is dumb.

To force colors on or off for every widget and flow:

Prompty::configure(ansi: FALSE); // Suppress all color codes
Prompty::configure(ansi: TRUE);  // Force ANSI colors

To force them for 1 flow:

$results = Prompty::flow()
  // ...
  ->run(ansi: FALSE);
Long option lists: show a long list a few options at a time

select, multiselect and suggest draw every option by default. For a long list, pass scroll with the number of options to show at once:

$dish = Prompty::select('Menu',
  options: [
    'tart' => 'Pear Tart',
    'crumble' => 'Apple Crumble',
    'soup' => 'Onion Soup',
    'stew' => 'Lentil Stew',
    'omelette' => 'Herb Omelette',
    'salad' => 'Barley Salad',
    'compote' => 'Plum Compote',
    'loaf' => 'Walnut Loaf',
  ],
  default: 'compote',
  scroll: 3,
);

The window opens with the focused option in view. Moving the focus past the top or bottom row shifts the window by 1 option. The rows above and below the window count the options hidden on each side:

โ—‡  Menu
โ”‚
โ”‚  โ†‘ 4 more
โ”‚  โ—‹ Herb Omelette
โ”‚  โ—‹ Barley Salad
โ”‚โฏ โ— Plum Compote
โ”‚  โ†“ 1 more
โ”‚

In a scrolled multiselect, focus starts on the first pre-checked option, so the window opens with it in view. A list drawn in full keeps focus on the first option. The answer line shows the chosen options, the same as in a list drawn in full.

In suggest, the window holds the matches for the typed text. It opens at the top of the matches each time the typed text changes.

A scroll of 0, or one at or above the number of options, draws every option. A negative scroll throws an InvalidArgumentException before the widget draws anything:

Scroll size -1 for "Menu" is not valid. Use 0 to show every option, or the number of options to show at once.

To set the size for every select, multiselect and suggest, pass scroll to Prompty::configure(). To set it for 1 flow, pass it to the flow's run(). A widget's own scroll takes precedence over both:

Prompty::configure(scroll: 10);
Long lines: how lines wider than the terminal wrap

Prompty wraps every line that's wider than the terminal. Labels, descriptions, options, hints, help, errors and answers break between words, and each continuation row starts with the same bar and indent as its first row. In labels, notes, descriptions, hints, help and task lines, a line that starts with spaces keeps them on its continuation rows too. A word wider than the row breaks at the row's edge:

โ—†  Extras
โ”‚  Sourdough bread, warm, cut at the table, Marinated olives, served in oil,
โ”‚  Herbs picked the same morning
โ”‚

Prompty measures the terminal width at most once a second, so frames draw at the new width within 1 second of a resize. It reads a positive COLUMNS environment variable first, then the width stty size reports when standard input is a terminal, and falls back to 80 columns. Wide characters such as emoji count as 2 columns.

To wrap at a fixed width, pass width to Prompty::configure(). To set it for 1 flow, pass it to the flow's run(). A width of 0 detects the terminal width again, and a negative width throws an InvalidArgumentException:

Prompty::configure(width: 100);

Lines you print with Prompty::output() keep your layout, so Prompty doesn't wrap them. To fit them to the terminal yourself, measure them with Prompty::width() and Prompty::terminalWidth().

Key legend: list the keys each widget accepts under it

To show the keys each widget accepts, pass legend: TRUE to Prompty::configure(). To show them for 1 flow, pass it to the flow's run(). Each widget then draws a dim legend after its help, so help can hold tips about the answer itself:

โ—‡  Extras
โ”‚
โ”‚โฏ โ—ป Bread
โ”‚  โ—ป Olives
โ”‚  โ—ป Herbs
โ”‚
โ”‚  โ†‘โ†“ move ยท space toggle ยท enter confirm
โ”‚

Each widget type lists its own keys:

Widget Legend
text enter confirm
select โ†‘โ†“ move ยท enter confirm
multiselect โ†‘โ†“ move ยท space toggle ยท enter confirm
suggest type to filter ยท โ†‘โ†“ move ยท enter confirm
confirm โ†โ†’ toggle ยท y/n choose ยท enter confirm
pause any key continue
pause with enter_only: TRUE enter continue

In ASCII mode, the arrows read up/down and left/right, and a - separates the keys. Like the help, the legend shows only while the widget waits for an answer.

In a flow, a step that can step back adds the back key at the end, such as โ†‘โ†“ move ยท enter confirm ยท ctrl+u back. The first asked step leaves it out.

To reword the legend, override its legend_* labels. The arrows come from the legend_vertical and legend_horizontal symbols, and the separator from legend_separator. A label set to '' removes its key from the legend:

Prompty::configure(legend: TRUE, labels: [
  'legend_enter' => 'enter to send',
  'legend_type' => '',
]);
Output target: send the output to a stream or a callable

Prompty writes with echo by default. To send its output to your own writer, pass output a writable stream or a callable. The callable receives each chunk as a string:

// A stream.
Prompty::configure(output: STDERR);

// A callable.
$buffer = '';
Prompty::configure(output: function (string $chunk) use (&$buffer): void {
  $buffer .= $chunk;
});

The target receives everything Prompty writes: widget frames, redraws, the cursor show and hide sequences, intro(), outro(), table(), box() and output(). A redraw moves the cursor up with escape sequences, so an interactive widget draws correctly only when those bytes reach the terminal.

A filename, a closed stream and a read-only stream each throw an InvalidArgumentException. To write to standard output again, pass STDOUT.

Display modes

unicode and ansi combine into 4 display modes. The following table shows a flat flow in each mode:

ANSI No ANSI
Unicode Unicode + ANSI Unicode + No ANSI
ASCII ASCII + ANSI ASCII + No ANSI

The following table shows a nested flow in each mode:

ANSI No ANSI
Unicode Nested Unicode + ANSI Nested Unicode + No ANSI
ASCII Nested ASCII + ANSI Nested ASCII + No ANSI

Other methods

Besides the 5 widgets, flow() and configure(), the class has these methods:

Method Returns Description
Prompty::results() array The answers collected by the last flow.
Prompty::config() array The resolved configuration, including detected values.
Prompty::version() string The release version, or development when running from source.
Prompty::isDrawn($e) bool Whether Prompty drew the failure before throwing the exception.
Prompty::intro($message) void Prints an intro banner outside flow().
Prompty::outro($message) void Prints an outro banner outside flow().
Prompty::output($lines) int Prints an array of lines, 1 per line. Returns the count.
Prompty::style($text, ...) string Styles each line of the text with the named colors.
Prompty::strip($text) string Removes ANSI escape sequences from the text.
Prompty::width($text) int Display width of the widest line of the text.
Prompty::terminalWidth() int The terminal width Prompty wraps its lines to.
Prompty::table($rows) void Prints a table, with optional headers.
Prompty::box($content) void Prints a box, with an optional title.
Details: Other methods

The following example reads the answers 2 ways and prints a banner:

// run() returns the collected answers.
$results = Prompty::flow()
  // ...
  ->run();

// Or read them later.
$results = Prompty::results();

// Print a banner yourself, such as from an outro callable that draws its
// own output.
Prompty::outro('Order sent to the kitchen!');
Tables and boxes: print a summary table or a titled panel

Prompty::table() and Prompty::box() print a summary or a panel in the same tree, symbols and colors as the widgets. Both print straight away and return nothing.

To print a table, pass its rows and optional header cells. A string in rows becomes a section row that spans every column:

Prompty::table([
  'The dish',
  ['Dish name', 'Pear tart'],
  ['Course', 'Dessert'],
  'Service',
  ['Table', 'Table 12'],
  ['Kitchen note', 'No nuts, and the cream on the side rather than poured over the tart'],
], headers: ['Field', 'Answer']);
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โ”‚ Field        โ”‚ Answer                                                     โ”‚
โ”‚  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  โ”‚ The dish                                                                  โ”‚
โ”‚  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  โ”‚ Dish name    โ”‚ Pear tart                                                  โ”‚
โ”‚  โ”‚ Course       โ”‚ Dessert                                                    โ”‚
โ”‚  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  โ”‚ Service                                                                   โ”‚
โ”‚  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  โ”‚ Table        โ”‚ Table 12                                                   โ”‚
โ”‚  โ”‚ Kitchen note โ”‚ No nuts, and the cream on the side rather than poured over โ”‚
โ”‚  โ”‚              โ”‚ the tart                                                   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚

Each column is as wide as its widest cell. When the table is wider than the terminal, the widest column shrinks 1 column at a time and its cells wrap. A cell is a string, an integer or a float, and a newline in a cell starts another line. A row with fewer cells than the others gets empty cells. Any other row or cell type throws an InvalidArgumentException. A table with no rows and no headers prints nothing.

To print a box, pass its content and an optional title. The box is as wide as its widest line, up to the terminal width:

Prompty::box("Plate the tart, then:\n  ring the bell\n  call the table", title: 'Order ready');
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โ”‚ Order ready           โ”‚
โ”‚  โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€           โ”‚
โ”‚  โ”‚ Plate the tart, then: โ”‚
โ”‚  โ”‚   ring the bell       โ”‚
โ”‚  โ”‚   call the table      โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚

A newline in the content starts another line. A line wider than the box wraps, and its continuation rows keep the line's leading spaces.

Borders are gray, and header cells, section rows and the box title are bold. Bold, underline and color sequences in a cell or a line stay as you wrote them. In ASCII mode the borders use +, - and |. To draw other borders, override the box_* symbols.

Style your own lines: style and measure the lines you print

To print lines of your own between widgets, style and measure them with the helpers Prompty uses for its own layout. Each one follows the ansi setting and NO_COLOR. To line up a column of styled dishes:

$dishes = ['Pear tart' => 'Table 4', 'Plum compote' => 'Table 12'];
$lines = [];

foreach ($dishes as $dish => $table) {
  $name = Prompty::style($dish, 'bold');
  $pad = str_repeat(' ', 14 - Prompty::width($name));
  $lines[] = $name . $pad . Prompty::style($table, 'dim');
}

Prompty::output($lines);

The class has 4 helpers:

  • Prompty::style($text, ...$styles) wraps each line of $text in the named styles and ends it with a reset, so the next line starts unstyled. Inside a line, a nested style() call ends its own style and the outer style continues. With ANSI off, it returns $text unchanged.
  • Prompty::strip($text) removes ANSI escape sequences.
  • Prompty::width($text) returns the columns the widest line of $text takes. Escape sequences count as 0, and wide characters such as emoji count as 2.
  • Prompty::terminalWidth() returns the width Prompty wraps its lines to, as described in Long lines.

style() takes every key of the colors configuration except reset: dim, dim_italic, cyan, green, red, gray, bold, white, yellow, blue, italic and underline. A colors override changes the codes style() writes. An unknown name throws an InvalidArgumentException that lists the available names.

Contributing

For local setup, the lint and test commands, the playground scripts and the release process, see CONTRIBUTING.md.

Security

Prompty reads keystrokes from a stream and writes ANSI escape sequences to stdout. It makes no network requests, writes no files and runs no input as code. It runs stty through the shell to switch the terminal into raw mode and back.

embed.php is a build-time tool. It runs php -l and git, and runs the embedded script only when the script has a kill switch.

To report a vulnerability, verify a downloaded release or read the full scope, see SECURITY.md.

This repository was created using the Scaffold project template