PHP Version
8.4
CodeIgniter4 Version
4.7.4
CodeIgniter4 Installation Method
Composer (using codeigniter4/appstarter)
Which operating systems have you tested for this bug?
Linux
Which server did you use?
cli-server (PHP built-in webserver)
Environment
development
Database
MariaDB 10.11
What happened?
Description
When a command asks a question with CLI::prompt() and the user presses backspace to
correct a typo, the prompt itself is erased along with the typed characters. Keep pressing
backspace and Email : disappears from the screen entirely, leaving the cursor at the
start of an apparently blank line.
The value is not actually damaged — readline() still returns the correct string, and if
you carry on typing blind the command works. But you cannot see what you are typing, so in
practice you abandon the entry and start again.
This only happens when the readline extension is loaded. Without it, CLI::prompt() falls
through to fgets() and the terminal handles backspace correctly, which is probably why the
bug has gone unnoticed.
Steps to reproduce
On a machine with ext-readline enabled, in any command:
$email = CLI::prompt('Email', null, 'required|valid_email');
Run it, type a few characters, then hold backspace. The typed characters go, then : , then
Email.
Expected behaviour
Backspace deletes the characters the user typed and stops at the prompt, as it does in every
other readline-driven prompt.
Actual behaviour
Backspace deletes all the typed characters on first backspace and then continues over the prompt text, erasing it.
Cause
CLI::prompt() writes the prompt to STDOUT itself, and then calls the input reader without
passing it:
// system/CLI/CLI.php
static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
static::$lastWrite = 'write';
// Read the input from keyboard.
$input = trim(static::$io->input()); // <- no prefix
InputOutput::input() therefore calls readline(null).
readline() redraws the whole line every time an editing key is pressed, and it draws
prompt + buffer starting from the beginning of the line. Because it was handed an empty
prompt, it believes the line begins at column 0 — which is where Email : already is — so
each redraw paints the shrinking input over the prompt.
Capturing the raw bytes sent to the terminal for the keystrokes boX followed by three
backspaces makes it visible:
boX <CR> bo <ESC> <CR> b <ESC> <CR> <ESC> boss@example.com
^^^^ carriage return to column 0, then repaint — on top of "Email : "
Workaround
If you need working prompts today, skip CLI::prompt() and call CLI::input() yourself,
passing the prompt text as its argument. That is the one thing CLI::prompt() fails to do,
and it is enough to make backspace behave. The arrow keys start working too.
CLI::input() does no validation, so you wrap it in your own loop:
private function ask(string $label, string $field, array $rules): string
{
while (true) {
$answer = trim(CLI::input($label . ': '));
$validation = Services::validation(null, false);
$validation->setRules([$field => $rules[$field]]);
if ($validation->run([$field => $answer])) {
return $answer;
}
foreach ($validation->getErrors() as $message) {
CLI::error($message);
}
}
}
Two things to know before copying it. It is narrower than CLI::prompt() — no default
values and no option lists — so it suits free-text questions and not much else. And use a
fresh validator rather than the shared one, as above: Validation keeps the data and errors
of its last run, so a shared instance goes on reporting the mistake the user has just
corrected.
Suggested fix - Created with AI so you can stop reading this section if that offends.
Hand the prompt to the reader instead of writing it first, so readline() knows how wide it
is.
system/CLI/CLI.php, in prompt():
- static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
static::$lastWrite = 'write';
- // Read the input from keyboard.
- $input = trim(static::$io->input());
+ // Read the input from keyboard. The prompt is passed to the reader rather than
+ // written first: readline() redraws the whole line on every editing key, and can
+ // only avoid painting over a prompt it was given.
+ $input = trim(static::$io->input(
+ $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': '
+ ));
One trap worth knowing about
That change alone is not enough, and doing only half of it would swap one display bug for
another. Any prompt offering a default value or a list of options builds $extraOutput with
CLI::color(), so the prompt string contains ANSI escape sequences. readline() counts
those invisible bytes as visible columns and places the cursor too far right on every
redraw.
Measured with the prompt Colour [white]: (16 visible characters, [white] coloured),
after typing three characters and pressing backspace once — the cursor should land in
column 19:
prompt given to readline() |
cursor redraw emitted |
correct? |
| raw ANSI |
ESC[28G |
no — 9 columns adrift, exactly the escape sequences' byte count |
\001/\002 bracketed |
ESC[19G |
yes |
\001 and \002 are readline's RL_PROMPT_START_IGNORE and RL_PROMPT_END_IGNORE
markers, which tell it "these bytes take up no width". They belong only on the readline
branch — the echo $prefix fallback must receive the prompt unchanged, or the markers show
up as stray control characters.
system/CLI/InputOutput.php, in input():
public function input(?string $prefix = null): string
{
// readline() can't be tested.
if ($this->readlineSupport && ENVIRONMENT !== 'testing') {
- return readline($prefix); // @codeCoverageIgnore
+ return readline(self::markNonPrinting($prefix)); // @codeCoverageIgnore
}
echo $prefix;
/**
* Marks ANSI sequences in a prompt as non-printing.
*
* readline() counts the prompt's characters to know where the editable part of the
* line starts. Without these markers it counts the bytes of a colour escape as
* visible columns and misplaces the cursor on every redraw.
*/
private static function markNonPrinting(?string $prefix): string
{
return preg_replace('/(\033\[[0-9;]*m)/', "\001$1\002", (string) $prefix);
}
Note for whoever picks this up
The prompt now reaches STDOUT through input() rather than CLI::fwrite(). That changes
how it is captured under test, so the existing CLI tests that assert on prompt output need
checking and possibly adjusting — that is the part of this change most likely to need work,
rather than the fix itself.
CLI::promptByMultipleKeys() builds its prompt the same way and should be checked at the
same time.
Environment
- CodeIgniter 4.7.4
- PHP 8.4.24 with
ext-readline loaded
- Linux,
xterm-compatible terminal
The behaviour does not depend on the terminal emulator; it is reproducible anywhere
readline() is in use.
Steps to Reproduce
Steps to reproduce
On a machine with ext-readline enabled, in any command:
$email = CLI::prompt('Email', null, 'required|valid_email');
Run it, type a few characters, then press back backspace then backspace again. The typed characters go, then : , then
Email.
Expected Output
Backspace deletes the characters the user typed and stops at the prompt, as it does in every
other readline-driven prompt.
Anything else?
No response
PHP Version
8.4
CodeIgniter4 Version
4.7.4
CodeIgniter4 Installation Method
Composer (using
codeigniter4/appstarter)Which operating systems have you tested for this bug?
Linux
Which server did you use?
cli-server (PHP built-in webserver)
Environment
development
Database
MariaDB 10.11
What happened?
Description
When a command asks a question with
CLI::prompt()and the user presses backspace tocorrect a typo, the prompt itself is erased along with the typed characters. Keep pressing
backspace and
Email :disappears from the screen entirely, leaving the cursor at thestart of an apparently blank line.
The value is not actually damaged —
readline()still returns the correct string, and ifyou carry on typing blind the command works. But you cannot see what you are typing, so in
practice you abandon the entry and start again.
This only happens when the
readlineextension is loaded. Without it,CLI::prompt()fallsthrough to
fgets()and the terminal handles backspace correctly, which is probably why thebug has gone unnoticed.
Steps to reproduce
On a machine with
ext-readlineenabled, in any command:Run it, type a few characters, then hold backspace. The typed characters go, then
:, thenEmail.Expected behaviour
Backspace deletes the characters the user typed and stops at the prompt, as it does in every
other readline-driven prompt.
Actual behaviour
Backspace deletes all the typed characters on first backspace and then continues over the prompt text, erasing it.
Cause
CLI::prompt()writes the prompt to STDOUT itself, and then calls the input reader withoutpassing it:
InputOutput::input()therefore callsreadline(null).readline()redraws the whole line every time an editing key is pressed, and it drawsprompt + bufferstarting from the beginning of the line. Because it was handed an emptyprompt, it believes the line begins at column 0 — which is where
Email :already is — soeach redraw paints the shrinking input over the prompt.
Capturing the raw bytes sent to the terminal for the keystrokes
boXfollowed by threebackspaces makes it visible:
Workaround
If you need working prompts today, skip
CLI::prompt()and callCLI::input()yourself,passing the prompt text as its argument. That is the one thing
CLI::prompt()fails to do,and it is enough to make backspace behave. The arrow keys start working too.
CLI::input()does no validation, so you wrap it in your own loop:Two things to know before copying it. It is narrower than
CLI::prompt()— no defaultvalues and no option lists — so it suits free-text questions and not much else. And use a
fresh validator rather than the shared one, as above:
Validationkeeps the data and errorsof its last run, so a shared instance goes on reporting the mistake the user has just
corrected.
Suggested fix - Created with AI so you can stop reading this section if that offends.
Hand the prompt to the reader instead of writing it first, so
readline()knows how wide itis.
system/CLI/CLI.php, inprompt():One trap worth knowing about
That change alone is not enough, and doing only half of it would swap one display bug for
another. Any prompt offering a default value or a list of options builds
$extraOutputwithCLI::color(), so the prompt string contains ANSI escape sequences.readline()countsthose invisible bytes as visible columns and places the cursor too far right on every
redraw.
Measured with the prompt
Colour [white]:(16 visible characters,[white]coloured),after typing three characters and pressing backspace once — the cursor should land in
column 19:
readline()ESC[28G\001/\002bracketedESC[19G\001and\002are readline'sRL_PROMPT_START_IGNOREandRL_PROMPT_END_IGNOREmarkers, which tell it "these bytes take up no width". They belong only on the readline
branch — the
echo $prefixfallback must receive the prompt unchanged, or the markers showup as stray control characters.
system/CLI/InputOutput.php, ininput():public function input(?string $prefix = null): string { // readline() can't be tested. if ($this->readlineSupport && ENVIRONMENT !== 'testing') { - return readline($prefix); // @codeCoverageIgnore + return readline(self::markNonPrinting($prefix)); // @codeCoverageIgnore } echo $prefix;Note for whoever picks this up
The prompt now reaches STDOUT through
input()rather thanCLI::fwrite(). That changeshow it is captured under test, so the existing CLI tests that assert on prompt output need
checking and possibly adjusting — that is the part of this change most likely to need work,
rather than the fix itself.
CLI::promptByMultipleKeys()builds its prompt the same way and should be checked at thesame time.
Environment
ext-readlineloadedxterm-compatible terminalThe behaviour does not depend on the terminal emulator; it is reproducible anywhere
readline()is in use.Steps to Reproduce
Steps to reproduce
On a machine with
ext-readlineenabled, in any command:Run it, type a few characters, then press back backspace then backspace again. The typed characters go, then
:, thenEmail.Expected Output
Backspace deletes the characters the user typed and stops at the prompt, as it does in every
other readline-driven prompt.
Anything else?
No response