sugarcraft / sugar-readline
PHP port of erikgeiser/promptkit — interactive line-editing prompt library. Supports text input with validation/completion/hidden-password mode, selection prompts with filtering/pagination/cursor navigation, confirmation prompts, and textarea multi-line input.
Requires
- php: ^8.3
- sugarcraft/sugar-core: @dev
- sugarcraft/sugar-forms: @dev
- sugarcraft/sugar-input: @dev
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:02:41 UTC
README
SugarReadline
sugar-readline — an interactive line-editing prompt library for terminal UIs, for PHP 8.3+.
Features
- TextPrompt — single-line input with validation, auto-completion, hidden/password mode, char limit, default value, history navigation, and an optional display-width viewport
- SelectionPrompt — filtered list with type-to-filter, cursor navigation and pagination
- MultiSelectPrompt — filtered multi-choice with min/max enforcement and FIFO rollover at the cap
- ConfirmationPrompt — yes/no with customizable labels, decoupled select-vs-submit
- TextareaPrompt — multi-line text input with line/column cursor and optional max-line cap; Enter submits, Shift+Enter and Ctrl+J break lines
- Immutable state — every handling method returns a new instance;
view()returns the ANSI string andvalue()the data, while theReadlinepump owns all terminal writes - Vim keybindings — vi-mode (Insert/Normal, plus a motion-only Visual scaffold) handled by the shared
sugar-formsVimKeyHandler— the same handler backingsugar-formsandsugar-widgets; new bindings inVimActionenum benefit all three libs. Includes text objects:ci",di(,da{,yiwand friends, with a working unnamed register (x/dd/yy/text-object yanks feedp) - NO_COLOR respected — when the
NO_COLORenvironment variable is set to a non-empty value, every decoration (colour and style alike) falls away and prompts print as plain text
Install
composer require sugarcraft/sugar-readline
Quick Start
Each prompt is a pure state machine: feed it key characters and it returns a
new prompt. Readline is the terminal loop that does the feeding — it reads
keypresses through sugar-input's InputDriver, repaints the prompt's
view() after every change, and returns the prompt once it is submitted or
aborted. Pass an output stream to run() or nothing is painted.
use SugarCraft\Readline\Readline; use SugarCraft\Readline\TextPrompt; $prompt = Readline::fromStdin()->run(TextPrompt::new('Enter your name: '), STDOUT); if ($prompt->isSubmitted()) { echo $prompt->value() . "\n"; }
You can also drive the state machine directly without a terminal — the shape the tests use:
$prompt = TextPrompt::new('> '); $prompt = $prompt->handleChar('h')->handleChar('i'); $prompt = $prompt->handleKey(\SugarCraft\Readline\Key::Enter); $prompt->value(); // 'hi'
Text Prompt
use SugarCraft\Readline\Readline; use SugarCraft\Readline\TextPrompt; $prompt = Readline::fromStdin()->run( TextPrompt::new('Enter your name: ') ->withDefault('Anonymous') ->withCompletions(['Alice', 'Bob', 'Carol']), STDOUT ); // 'Alice' after typing + Tab + Enter
The remaining prompt surface (all fluent, all returning new instances):
withValidator(callable) gates submit() on a rule, withCharLimit(int)
caps the buffer, withHidden() masks input (secrets) and disables history
pushes and completion echoes, withAutoSuggest() proposes the most recent
matching history entry as a dim row under the prompt, and withUndoManager()
attaches an UndoManager — an immutable bounded (512-state) snapshot stack
fed automatically as you type and edit, driven by Ctrl+Z/Ctrl+Y (or vi u
in normal mode). ConfirmationPrompt::withHint(string) paints a hint row
(e.g. "Press Enter to confirm") under the Yes/No choice.
Selection Prompt
Type to filter the list as you go; Backspace edits the filter, Ctrl+U clears it, Space selects (SelectionPrompt submits the match directly):
use SugarCraft\Readline\Readline; use SugarCraft\Readline\SelectionPrompt; $result = Readline::fromStdin()->run( SelectionPrompt::new('Choose a fruit:', ['Apple', 'Banana', 'Cherry', 'Date']), STDOUT ); echo $result->selectedValue(); // 'Banana' after typing 'an' + Enter
Multi-Select Prompt
use SugarCraft\Readline\Readline; use SugarCraft\Readline\MultiSelectPrompt; $result = Readline::fromStdin()->run( MultiSelectPrompt::new('Pick:', ['A', 'B', 'C']) ->withMinSelections(1), STDOUT ); print_r($result->selectedValues()); // ['A', 'B'] after Space + navigation + Enter
Textarea Prompt
use SugarCraft\Readline\Readline; use SugarCraft\Readline\TextareaPrompt; $result = Readline::fromStdin()->run( TextareaPrompt::new('Describe:')->withMaxLines(5), STDOUT ); print_r($result->lines()); // edited multi-line text
TextareaPrompt::new() takes only the label — a textarea has no choices.
Keyboard contract: Enter submits, Shift+Enter and Ctrl+J insert a
new line, Ctrl+U kills the text before the cursor on the current line
only, Up/Down move between lines, Escape/Ctrl+C abort. When a maxLines
cap is reached, further line breaks are refused and text accumulates on
the last allowed line.
Confirmation Prompt
use SugarCraft\Readline\Readline; use SugarCraft\Readline\ConfirmationPrompt; $result = Readline::fromStdin()->run( ConfirmationPrompt::new('Delete file?'), STDOUT ); echo $result->result() ? 'yes' : 'no'; // 'yes' or 'no'
Custom Key Handlers
onKey() registers additional handlers; it adds to the keymap rather
than replacing it, and registering the same key twice keeps the latest
handler for that key. ctrl_c still aborts the prompt itself.
use SugarCraft\Readline\Readline; use SugarCraft\Readline\TextPrompt; $result = Readline::fromStdin() ->onKey('ctrl_c', fn($event) => print("aborted\n")) ->onKey('ctrl_u', fn($event) => print("cleared\n")) ->run(TextPrompt::new('> '), STDOUT); echo $result->value();
onMouse(), onFocus() and onPaste() register the same way for mouse
reporting, focus in/out, and bracketed paste. The bracketed-paste enable is
gated on the output stream being a tty, so a program with piped stdin
behind a tty stdout may still receive pastes as raw bytes. Whatever the
delivery, pasted content is inserted as literal text: TextPrompt and
TextareaPrompt expose insertPastedText(string), which strips control
bytes (the textarea honours embedded newlines as line breaks) before
inserting, so a pasted payload can never forge a submit or replay editor
commands.
Long lines and repaint
view() returns one logical row; painting that row wider than the terminal
makes it wrap and the cursor-up repaint maths desyncs (the classic
staircase). TextPrompt::withDisplayWidth($cells) bounds the editable
region to a window around the cursor (clipped ends marked …, cell-width
aware, 0 = unbounded). When Readline::run() prepares a prompt it has not
been given an explicit width for, it auto-applies the COLUMNS value, so a
normal terminal session stays bounded without extra wiring; set
withDisplayWidth() yourself to override (including 0 to opt out).
Multi-line textarea output and wrapped labels can still exceed the window —
long prompts (labels) are the host's layout responsibility either way.
Terminal mode (raw vs cooked)
run() never touches termios — the library deliberately leaves terminal
settings to the host program. On a cooked TTY the line discipline
consumes Enter, Backspace, and Ctrl+ combinations, so arrow keys and
multi-key chords cannot reach the prompt. For the full editing experience put
the terminal in non-canonical mode around the loop and restore it afterwards:
$saved = trim((string) shell_exec('stty -g')); shell_exec('stty -icanon -echo -isig min 1 time 0'); try { $prompt = Readline::fromStdin()->run(TextPrompt::new('> '), STDOUT); } finally { shell_exec('stty ' . escapeshellarg($saved)); }
-isig matters: with signals left enabled, Ctrl+C raises SIGINT, which
kills the process before the finally restore runs — the shell is then
left with echo and canonical mode off. With -isig, Ctrl+C arrives as an
ordinary byte the prompt aborts on, and the restore always executes.
The captured -g string is implementation-specific — GNU stty prints it as
dense single tokens (-icanon) while BSD/macOS stty uses space-separated
words — so capture and restore on the same machine in the same run; never
store the string to replay it on another platform.
sugar-core's SugarCraft\Core\Util\RawMode and the sugar-pty library
provide the same transition as turnkey helpers if you already depend on them.
examples/interactive.php carries the full recipe with shutdown-safe
restore. Over a pipe or socket (no tty at all) no stty is needed — the bytes
arrive as written, which is exactly how the test-suite drives chords.
Input Driver
Readline accepts an optional SugarCraft\Input\InputDriver to control where input comes from. new Readline() and Readline::fromStdin() both open php://stdin themselves — with that default pump the loop also detects a true EOF (see below). For testing, inject a driver over a fixture stream:
// Testing: inject a fake stream (socket pair or pipe — the driver polls // the stream, so non-selectable php://memory is not a good fixture here) $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); fwrite($pair[1], "hello\x0d"); // \x0d = Enter fclose($pair[1]); $readline = new Readline(new SugarCraft\Input\Driver\StreamInputDriver($pair[0])); $result = $readline->run(TextPrompt::new('> ')); // $result->value() === 'hello'
An injected driver is reported "no event yet" and "stream ended" by the same
read() === null answer (InputDriver cannot distinguish them), so the loop
keeps idling until the prompt ends or stop() is called — close or drain the
fixture stream and arm stop() from your test when it runs dry.
Key Bindings
Bindings fire from both spellings: raw control bytes off a live stream and
symbolic key names (Key:: constants or plain strings like 'ctrl_a') are
normalized to one canonical language at the choke point. The Key class
lists the primary bindings; it is not an exhaustive enumeration of every
sequence a terminal can send.
Key::Left/Key::Right— move cursor (text input); also Ctrl+B / Ctrl+F, and Ctrl+H erases before the cursor (ASCII rubout behaviour)Key::Up/Key::Down— navigate selection list / history / textarea line; also Ctrl+P / Ctrl+NKey::Home/Key::End— jump within the current line / list; also Ctrl+A / Ctrl+EKey::Enter— submit text or select current choice (in the textarea, Shift+Enter and Ctrl+J break a line instead)Key::Space— selects in SelectionPrompt; toggles the mark in multi-select; toggles Yes/No in ConfirmationPrompt; inserts a literal space in text inputsKey::Tab— auto-complete or toggle confirmation value; pressing Tab again cycles the matches (first Tab extends to their longest common prefix)Key::Backspace/Key::Delete— delete characters; Ctrl+D deletes under the cursor and, on an empty buffer, aborts (like GNUdelete-char-or-eof)Key::CtrlU/Key::CtrlK— delete to start / end of line; Ctrl+W deletes the previous wordKey::CtrlR/Key::CtrlS— reverse / forward incremental history search- Ctrl+Z / Ctrl+Y — undo / redo the last edit run (a run of consecutive typing collapses into one undo step)
- Ctrl+L —
Readlineclears the screen and repaints the prompt Key::PageUp/Key::PageDown— jump the selection list by one pageKey::Escape/Key::CtrlC— abort. Inside an attachedEmacsMode, Escape is the meta prefix instead (a second Escape cancels it); use Ctrl+C to abort there.
Ctrl+[ is an ESC on every terminal and behaves as esc, not as Ctrl+C.
Editing modes (Vi vs Emacs)
TextPrompt uses a dual-engine architecture. The prompt itself owns the
buffer, cursor, history, and rendering; an optional mode object attached
via withMode() owns the key-to-operation mapping:
use SugarCraft\Readline\Mode\EmacsMode; use SugarCraft\Readline\Mode\ViMode; $prompt = TextPrompt::new('> ')->withMode(new EmacsMode()); // readline bindings $prompt = TextPrompt::new('> ')->withMode(new ViMode()); // modal vi bindings
Every key fed to TextPrompt::handleKey() is delegated to the attached
Mode\ModeInterface implementation. Typed characters are routed through the
mode too while the mode is claiming them (consumesCharacters() — an armed
Emacs meta prefix, or Vi outside insert mode), so ^W never lands in the
buffer while the prefix waits. The mode translates the key into prompt
operations by calling back into handleKeyDirect() (the mode-bypassing
entry point — using handleKey() would recurse), then re-attaches itself so
its own state survives the prompt's immutable cloning.
-
EmacsModeimplements the classic readline chords — Ctrl+A/E (line start/end), Ctrl+B/F (char motion), Alt+B/F/D (word motion/delete, via the Escape prefix or a Kitty-stylealt_b), Ctrl+W (delete word before), Ctrl+T (transpose), Ctrl+P/N (history), Ctrl+R/Ctrl+S (incremental search), Ctrl+K/U (kill to end/start). Ctrl+L reaches the mode as a no-op: screen clearing is theReadlinepump's job. Escape arms the meta prefix; a second Escape cancels it. The mode never aborts the prompt — leave it detached if Escape-quit is what you want. -
ViModeis a modal state machine (starts in insert; Escape → normal;v→ a visual mode that tracks motion only — selection is not yet rendered or operated on). Pending motions likedd/yy/ccare supported. In normal modej/kwalk history,xcuts the char under the cursor,pputs the unnamed register,uundoes the last edit,Entersubmits the line, space moves right, and Ctrl+R/Ctrl+S start incremental search just like in insert mode. It does not hardcode vi bindings: normal/visual keys are mapped through sugar-forms'VimKeyHandler, which returns aVimActionenum case that ViMode then executes as TextPrompt operations. New vi bindings therefore belong in sugar-forms (VimAction+VimKeyHandler), not in ViMode's branching. In normal mode the cursor rests ON the last character (vi semantics), so$and Escape-at-end land on — not after — the final char.Normal mode supports vi text objects: an operator (
cchange,ddelete,yyank) followed by a scope (iinner /aaround) and a target — quotes ("'`), brackets (()b,[],{}B,<>), orwfor the word under the cursor. Soci"retypes a quoted string,da{deletes braces and contents,diw/dawdelete a word (aw takes the trailing space),yi(copies the parenthesized text into the unnamed register and moves the cursor to its start. Resolution lives in sugar-forms (TextObject::resolve()viaVimKeyHandler::handleTextObject()): innermost bracket pair wins, cursor-on-delimiter counts as inside, quotes pair up left-to-right (a cursor before a quoted region jumps forward to it), and unmatched/absent delimiters are a silent no-op (where vim would beep). Escape cancels a half-typed sequence. Simplifications vs vim:a"does not absorb trailing whitespace, escaped quotes are not special, and the unnamed register is a single slot (no named registers, no linewise/charwise distinction on put).dd,ccandyystore the affected line in the register before acting;ppastes register contents as literal text after the cursor — pasted control characters never replay as commands.
With no mode attached, TextPrompt still carries the default GNU-readline
movement chords (Ctrl+A/E/B/F/P/N) plus arrows, Home/End, space,
Ctrl+U/K/W/D/H/R/S, Ctrl+Z/Ctrl+Y undo/redo, Ctrl+L screen clear (via
Readline), Tab completion with match cycling, and Escape/Ctrl+C abort.
Attached modes read the current submode with $prompt->mode(); the mode
state itself is not painted into view() — the prompt renders identically
across vi submodes, so a host that needs a visible --NORMAL-- marker must
read mode() and paint it itself.
Incremental history search
With a history attached (withHistory()), Ctrl+R enters reverse
incremental search and Ctrl+S forward search, in any mode. Typed characters
refine the query, repeated Ctrl+R/Ctrl+S steps through older/newer matches,
Enter accepts the match into the buffer (without submitting), and
Escape/Ctrl+G cancels back to the original line. The prompt renders a
(reverse-i-search)query': matchindicator, switching to(failed
reverse-i-search)` when nothing matches (including empty history).
History
Prompts start with no history attached: navigation and Ctrl+R do nothing
until you pass a store via withHistory(). InMemoryHistory keeps entries
for the process lifetime; FileHistory persists them across processes —
new FileHistory($path, $maxHistory, $tempDir, $deferWrites): the file is
created (or tightened, if it exists) at mode 0600, loaded on construction,
and flushed at the latest on destruction. With $deferWrites = true, pushes
queue in memory until flush() batch-writes them. Pass a $maxHistory and
the cap bounds the file too: bounded stores rewrite the whole file on
each push, merging anything another process appended in between.
Entries are stored one per line with backslash escapes (\\, \n, \r)
applied at the write choke and undone on load — a value that somehow
contains a raw newline survives a save/reload round-trip as one entry
instead of splitting into two. A history path that is a symlink is
refused loudly at construction and again under the write lock: without the
check, an attacker-planted link turns every append (and the 0600 chmod)
against, say, ~/.bashrc.
Dedup is stricter than the base HistoryInterface minimum: FileHistory
skips a line equal to any entry already retained (including ones loaded
from disk) — zsh HIST_IGNORE_ALL_DUPS style — while plain
InMemoryHistory collapses only adjacent duplicates (HIST_IGNORE_DUPS).
All writers serialize on a sidecar <file>.lock (flock exclusive, held
across read-write-rename), so two processes pushing at once never lose an
entry; a write that cannot happen loudly throws rather than dropping the
line. The rename makes torn reads impossible, but there is no fsync — a
kernel crash can still lose the most recent writes.
Hidden prompts (withHidden()) never push their value: a secret stays out of
both in-memory and on-disk history, and it never enters an attached
UndoManager chain either.
Submit / Abort Semantics
Each prompt is a state machine with three states: pending, submitted, aborted.
submit()finalises the prompt; forMultiSelectPromptit only succeeds whencanSubmit()is true.abort()(or feedingKey::Escape/Key::CtrlCon the default bindings; inside an attachedEmacsMode, Ctrl+C) discards the prompt;value()/selectedValues()then return empty.isSubmitted()/isAborted()report status;currentValue()(Confirmation) andselectedValue()(Selection) reflect the current cursor regardless of submission state.- True end-of-input: when
Readlineowns the stdin pump and the stream reaches EOF after the decoder drains,run()returns the prompt in its current state — neither submitted nor aborted. The pump is per-Readline-instance, not per-run(): callingrun()again feeds the next prompt from wherever the stream stands, soprintf 'a\nb\n' | php app.phpdrives two successive prompts withaandb; once EOF is seen it stays seen. Injected drivers report EOF and "nothing yet" identically, so they cannot trigger this exit; end those loops by submitting, aborting, orstop(). stop()requests the loop exit; it lands within one idle poll interval (capped at 20 ms) when the driver's read is non-blocking (min 1 time 0, or an injected driver that returnsnullpromptly). A driver blocking inread()is not interrupted — the stop takes effect on the first pass after it wakes.
Accessor deprecation (2026-10-24)
Per the house bare-accessor convention (AGENTS.md: "Bare accessors (no get)), the history navigation pair getPrevious()/getNext() was renamed to its bare twins previous()/next(), with the whole family moved together with the interface: HistoryInterface now declares only the bare pair (PHP forbids interface method bodies, so the legacy spellings could not ride the contract as default forwarders). InMemoryHistory — and FileHistory by inheritance — keep the old names as byte-identical one-line BC forwarders, each marked @deprecated with REMOVED-IN: 2.0.0 in its docblock — they will be removed in sugar-readline v2.0.0.
| Namespace | Class | Deprecated | Use instead |
|---|---|---|---|
History |
InMemoryHistory |
getPrevious() |
previous() |
History |
InMemoryHistory |
getNext() |
next() |
Navigation is stateful (each call advances the position cursor), so tests/AccessorDeprecationTest.php pins the BC legs as full walks through both spellings, plus forwarder shape, an interface-contract leg, a discovery-first src/ census, and the README table agreement. Internal call sites in src/ were switched to the bare forms in the same commit.
License
MIT
Credits & inspiration
Design antecedent: erikgeiser/promptkit; SugarCraft is developed as a native PHP project.