# [RFC] Io\Terminal

- Thread: https://externals.io/message/132663
- Original archive: https://news-web.php.net/php.internals/132663
- Started by Pratik Bhujel on 2026-09-27T17:19:48+00:00
- Messages: 24
- RFC: Io\Terminal (Under discussion) — https://externals.io/rfc/io_terminal.md
- Reply by email to internals@lists.php.net
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

---

## #132663 — Pratik Bhujel — 2026-09-27T17:19:48+00:00

URL: https://externals.io/message/132663#132663

Hi internals,

I would like to formally propose the Io\Terminal API for PHP 8.7:

https://wiki.php.net/rfc/io_terminal

This follows the earlier php-internals discussion about native
terminal helpers and the API work that came out of it.

The RFC proposes a small Io\Terminal API in ext/standard for terminal
sessions, native terminal size queries, raw mode with safe
restoration, normalized key input, and hidden input.

The existing reference implementation is ext-terminal, distributed
through PIE/Packagist as prateekbhujel/php-terminal:
https://github.com/prateekbhujel/php-terminal

A draft php-src implementation is available here:
https://github.com/php/php-src/pull/23941

It follows the reduced core API rather than copying the full
ext-terminal 1.x surface.

The proposed core API uses Time\Duration for timeout parameters, does
not expose a public constructor, and leaves compatibility aliases,
environment-based size fallbacks, and convenience APIs out of the
initial core proposal.

The earlier internals discussion and the Symfony Console/TUI
integration have helped narrow the proposal to the parts that require
native cross-platform support. Some of the earlier replies were
accidentally split into separate archive threads, so the RFC collects
the full prior discussion history in one place.

I would appreciate feedback on the proposed API and semantics before voting.

Thanks,
Pratik

---

## #132672 — tim@bastelstu.be — 2026-09-28T09:46:54+00:00

URL: https://externals.io/message/132663#132672 · In reply to: #132663

Hi


> I would like to formally propose the Io\Terminal API for PHP 8.7:
> 
> https://wiki.php.net/rfc/io_terminal

Thank you for the RFC. Some questions to start of the discussion:

1. Should TerminalSize have a regular constructor? It seems to be safe 
to allow constructing it from userland, e.g. for testing purposes.

2. It would help readability if the stub would indicate the 
non-serializability (and strict properties) instead of mentioning it in 
the prose. Basically you can just take the stub file from your PR and 
include it in the RFC.

3. Terminal::create() should probably be ::fromStdio() or similar.

4. I'm not sure about false vs Exception for the various methods. 
`enableRawMode()` should probably be Exception, for `readKey()` the 
`false` return is not explained. Also the behavior of what happens when 
a timeout strikes is not explained.

5. Should ModeToken have a property that points back to the 
corresponding Terminal? Overall the interaction between the destructors 
and ModeToken and restoreMode should be explained more. As an example, 
what happens if the Terminal object dies before the ModeToken object? 
What if I create two Terminal objects for the same terminal?

Best regards
Tim Düsterhus

---

## #132675 — Nicolas Grekas — 2026-09-28T13:00:09+00:00

URL: https://externals.io/message/132663#132675 · In reply to: #132672

Hi Pratik,

thanks for the RFC, PHP definitely needs native terminal support, calling
stty is a workaround we've been carrying since way too long.

Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus <tim@bastelstu.be> a écrit :

> Hi
>
> 
> > […]
>
> Thank you for the RFC. Some questions to start of the discussion:
>
> 1. Should TerminalSize have a regular constructor? It seems to be safe
> to allow constructing it from userland, e.g. for testing purposes.
>
> 2. It would help readability if the stub would indicate the
> non-serializability (and strict properties) instead of mentioning it in
> the prose. Basically you can just take the stub file from your PR and
> include it in the RFC.
>
> 3. Terminal::create() should probably be ::fromStdio() or similar.
>
> 4. I'm not sure about false vs Exception for the various methods.
> `enableRawMode()` should probably be Exception, for `readKey()` the
> `false` return is not explained. Also the behavior of what happens when
> a timeout strikes is not explained.
>
> 5. Should ModeToken have a property that points back to the
> corresponding Terminal? Overall the interaction between the destructors
> and ModeToken and restoreMode should be explained more. As an example,
> what happens if the Terminal object dies before the ModeToken object?
> What if I create two Terminal objects for the same terminal?
>

Related to ModeToken, what about a refcount instead? One record per
terminal, shared by all its tokens: the first enableRawMode() saves the
original termios and dup()s the fd into the record, the next ones only
increment a counter, and releasing any token decrements it. At zero, the
original state is restored through the record's own fd. That's
order-independent by construction and immune to `fclose()`. The RFC could
then spec it in one sentence: raw mode stays on as long as one token for
that terminal is alive, and the state from before the first token is
restored when the last one goes away. Today it says a token "restores its
saved terminal mode when it is destroyed", which isn't what happens out of
order. If more modes are added later, the record can keep the set of active
requests and recompute the target state from the original on each change.

Cheers,
Nicolas

---

## #132679 — Pratik Bhujel — 2026-09-28T17:15:25+00:00

URL: https://externals.io/message/132663#132679 · In reply to: #132675

Hi Tim, Nicolas,

Thanks both for the feedback. I went through the API and the
implementation again and updated the RFC and PR to reflect it.

For Tim's points:

- TerminalSize now has a normal public constructor, with positive
dimensions enforced.
- The RFC now shows the actual stub, including strict-properties /
non-serializable declarations.
- Terminal::create() has been renamed to Terminal::fromStdio().
- enableRawMode() now returns ModeToken and uses TerminalException for
operational/native failures.
- readKey() now has a defined contract: false means timeout/no
complete key before the deadline; non-TTY input, EOF and
native/polling failures are exceptions. The timeout and
sequenceTimeout behavior is now described explicitly in the RFC.
- ModeToken lifetime and restoreMode() behavior are now described in detail.

For the ownership question, I kept ModeToken opaque rather than adding
a public Terminal back-reference.

Nicolas, I also changed the implementation to the shared-per-terminal
model you suggested. The first active token captures the original mode
and owns the restoration descriptor/handle, subsequent tokens share
that state, and the original mode is restored when the last active
token is released. This makes release order irrelevant and also means
closing the original PHP stream does not prevent restoration.

I also reworked the terminal identity handling from the earlier
st_rdev-only approach so different descriptors for the same logical
terminal can coordinate without conflating unrelated PTYs.

The RFC has been updated here:
https://wiki.php.net/rfc/io_terminal

Implementation:
https://github.com/php/php-src/pull/23941

These are substantive API/semantic changes made during discussion, so
this reply is also the announcement of those RFC changes.

Thanks again for taking the time to review this. The
lifecycle/ownership feedback in particular made the model much
cleaner.

Best regards,
Pratik

On Mon, 28 Sep 2026 15:00:09 +0200, Nicolas Grekas
<nicolas.grekas+php@gmail.com> wrote:
> Hi Pratik,
>
> thanks for the RFC, PHP definitely needs native terminal support, calling stty is a workaround we've been carrying since way too long.
>
> Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus <tim@bastelstu.be> a écrit :
>
> Hi
>
> 
>
> > […]
>
> > […]
>
> > […]
>
> Thank you for the RFC. Some questions to start of the discussion:
>
> 1. Should TerminalSize have a regular constructor? It seems to be safe
>
> to allow constructing it from userland, e.g. for testing purposes.
>
> 2. It would help readability if the stub would indicate the
>
> non-serializability (and strict properties) instead of mentioning it in
>
> the prose. Basically you can just take the stub file from your PR and
>
> include it in the RFC.
>
> 3. Terminal::create() should probably be ::fromStdio() or similar.
>
> 4. I'm not sure about false vs Exception for the various methods.
>
> `enableRawMode()` should probably be Exception, for `readKey()` the
>
> `false` return is not explained. Also the behavior of what happens when
>
> a timeout strikes is not explained.
>
> 5. Should ModeToken have a property that points back to the
>
> corresponding Terminal? Overall the interaction between the destructors
>
> and ModeToken and restoreMode should be explained more. As an example,
>
> what happens if the Terminal object dies before the ModeToken object?
>
> What if I create two Terminal objects for the same terminal?
>
> Related to ModeToken, what about a refcount instead? One record per terminal, shared by all its tokens: the first enableRawMode() saves the original termios and dup()s the fd into the record, the next ones only increment a counter, and releasing any token decrements it. At zero, the original state is restored through the record's own fd. That's order-independent by construction and immune to `fclose()`. The RFC could then spec it in one sentence: raw mode stays on as long as one token for that terminal is alive, and the state from before the first token is restored when the last one goes away. Today it says a token "restores its saved terminal mode when it is destroyed", which isn't what happens out of order. If more modes are added later, the record can keep the set of active requests and recompute the target state from the original on each change.
>
> Cheers,
>
> Nicolas

---

## #132680 — Pratik Bhujel — 2026-09-28T17:17:44+00:00

URL: https://externals.io/message/132663#132680 · In reply to: #132675

Hi Tim, Nicolas,

One quick follow-up after my earlier mail: I've now finished the
corresponding cleanup in the php-src PR as well.

Tim, I went through the implementation/test review comments and
addressed the remaining points there.

Nicolas, the shared raw-mode record and terminal identity changes are
now in place too, including restoration through the record's own
descriptor and keeping unrelated PTYs separate.

The RFC text and the implementation should now describe the same
lifetime/error semantics:

https://wiki.php.net/rfc/io_terminal
https://github.com/php/php-src/pull/23941

I also fixed the Linux PTY EIO test expectation on the current head.

I don't want to keep adding noise to the list, so I'll leave it here
unless I've missed something. Thanks again for the detailed review.

Best,
Pratik

On Mon, 28 Sep 2026 15:00:09 +0200, Nicolas Grekas
<nicolas.grekas+php@gmail.com> wrote:
> Hi Pratik,
>
> thanks for the RFC, PHP definitely needs native terminal support, calling stty is a workaround we've been carrying since way too long.
>
> Le lun. 28 sept. 2026 à 11:49, Tim Düsterhus <tim@bastelstu.be> a écrit :
>
> Hi
>
> 
>
> > […]
>
> > […]
>
> > […]
>
> Thank you for the RFC. Some questions to start of the discussion:
>
> 1. Should TerminalSize have a regular constructor? It seems to be safe
>
> to allow constructing it from userland, e.g. for testing purposes.
>
> 2. It would help readability if the stub would indicate the
>
> non-serializability (and strict properties) instead of mentioning it in
>
> the prose. Basically you can just take the stub file from your PR and
>
> include it in the RFC.
>
> 3. Terminal::create() should probably be ::fromStdio() or similar.
>
> 4. I'm not sure about false vs Exception for the various methods.
>
> `enableRawMode()` should probably be Exception, for `readKey()` the
>
> `false` return is not explained. Also the behavior of what happens when
>
> a timeout strikes is not explained.
>
> 5. Should ModeToken have a property that points back to the
>
> corresponding Terminal? Overall the interaction between the destructors
>
> and ModeToken and restoreMode should be explained more. As an example,
>
> what happens if the Terminal object dies before the ModeToken object?
>
> What if I create two Terminal objects for the same terminal?
>
> Related to ModeToken, what about a refcount instead? One record per terminal, shared by all its tokens: the first enableRawMode() saves the original termios and dup()s the fd into the record, the next ones only increment a counter, and releasing any token decrements it. At zero, the original state is restored through the record's own fd. That's order-independent by construction and immune to `fclose()`. The RFC could then spec it in one sentence: raw mode stays on as long as one token for that terminal is alive, and the state from before the first token is restored when the last one goes away. Today it says a token "restores its saved terminal mode when it is destroyed", which isn't what happens out of order. If more modes are added later, the record can keep the set of active requests and recompute the target state from the original on each change.
>
> Cheers,
>
> Nicolas

---

## #132685 — Larry Garfield — 2026-09-28T22:18:58+00:00

URL: https://externals.io/message/132663#132685 · In reply to: #132680

> Hi Tim, Nicolas,
>
> One quick follow-up after my earlier mail: I've now finished the
> corresponding cleanup in the php-src PR as well.
>
> Tim, I went through the implementation/test review comments and
> addressed the remaining points there.
>
> Nicolas, the shared raw-mode record and terminal identity changes are
> now in place too, including restoration through the record's own
> descriptor and keeping unrelated PTYs separate.
>
> The RFC text and the implementation should now describe the same
> lifetime/error semantics:
>
> https://wiki.php.net/rfc/io_terminal
> https://github.com/php/php-src/pull/23941
>
> I also fixed the Linux PTY EIO test expectation on the current head.
>
> I don't want to keep adding noise to the list, so I'll leave it here
> unless I've missed something. Thanks again for the detailed review.
>
> Best,
> Pratik

Please remember to bottom-post. :-)

I am in favor of this RFC in general.  It addresses the sort of problem that does belong in stdlib.

- getSize()'s error return is null.  Not false.  False-on-error is an anti-pattern we should be exterminating with extreme prejudice.  Or possibly an exception, but not false.

- As I'm not familiar with the underlying OS tools... what is raw mode?  That seems to be just glossed over.  It looks like the only useful API method (readKey() ) requires going into raw mode, so I wonder what its purpose is.

- That said, raw mode looks like a textbook case for a context manager. :-)

- Again, readKey() should return null, not false, for all the same reasons.

- Why does readSecret() not need the same duration/timeout controls as readKey()?

- I understand all of the usual arguments for making the Terminal class final.  However, it also has no interface.  That means it's basically impossible to mock for testing purposes.  That strikes me as a problem, because any IO boundary should be mockable.  I don't know that multiple non-testing implementations makes sense (maybe alternatives to the static constructors?), but we do need some straightforward mechanism to mock a Terminal object.  (I assume someone is going to respond with "it's an implementation detail of something else," which is only partially true; I don't want to have to create a pass-through wrapper for something just for testing purposes, and even then, it would be the same API in name only, since it cannot share a type.  That hurts interoperability.)

--Larry Garfield

---

## #132686 — Pratik Bhujel — 2026-09-29T04:13:11+00:00

URL: https://externals.io/message/132663#132686 · In reply to: #132685

> Please remember to bottom-post. :-)

Yep, noted. Thanks.

> I am in favor of this RFC in general. It addresses the sort of problem
> that does belong in stdlib.
>
> - getSize()'s error return is null. Not false. False-on-error is an
> anti-pattern we should be exterminating with extreme prejudice. Or
> possibly an exception, but not false.

That makes sense. The original extension used false more broadly, but
for the core API the operational failures are already separate and
throw TerminalException.

I've changed getSize() to return ?TerminalSize, with null meaning that
a usable native size couldn't be obtained.

> - As I'm not familiar with the underlying OS tools... what is raw mode?
> That seems to be just glossed over. It looks like the only useful API
> method (readKey() ) requires going into raw mode, so I wonder what its
> purpose is.

Fair point. The RFC was assuming too much terminal background there.

In canonical mode the terminal normally buffers input until a line is
complete, may echo it, and handles some control characters itself. Raw
mode turns off that line-oriented processing so the application can
react to individual key presses and terminal sequences directly.

One thing that also wasn't clear is that callers don't need to call
enableRawMode() before readKey() or readSecret(). Both handle the
temporary mode change internally.

enableRawMode() is there for longer-running interactive code that
wants to keep the terminal raw across multiple reads/redraws.

I've clarified that in the RFC.

> - That said, raw mode looks like a textbook case for a context manager. :-)

Conceptually, yes. That's what I was trying to model with ModeToken:
it represents the active lease, and restoreMode() gives an explicit
way to release it in a try/finally.

PHP doesn't have a general language-level context-manager construct,
and I didn't want to add a terminal-specific callback abstraction just
for this, so I kept the primitive explicit.

> - Again, readKey() should return null, not false, for all the same reasons.

Changed that as well. readKey() now returns Key|string|null, with null
meaning the timeout expired before a complete input value was
available. Operational failures still throw TerminalException.

> - Why does readSecret() not need the same duration/timeout controls as
> readKey()?

There wasn't a good reason for the overall timeout to be missing, so
readSecret() now accepts an optional Time\Duration too.

It returns null when that timeout expires, while "" still means the
user actually submitted an empty secret.

I didn't add sequenceTimeout to readSecret(). Unlike readKey(), it
doesn't expose terminal escape sequences to the caller, so that
ambiguity handling can stay internal.

> - I understand all of the usual arguments for making the Terminal class
> final. However, it also has no interface. That means it's basically
> impossible to mock for testing purposes. That strikes me as a problem,
> because any IO boundary should be mockable. I don't know that multiple
> non-testing implementations makes sense (maybe alternatives to the
> static constructors?), but we do need some straightforward mechanism to
> mock a Terminal object. [...]

I agree with the testing concern. I've added TerminalInterface while
keeping the native Terminal final, so application and library code can
type against something that can be replaced by a userland fake.

I also added ModeTokenInterface for fake implementations. The native
Terminal still only accepts a native token belonging to the same
logical terminal; foreign, stale, or unrelated tokens are rejected
with ValueError.

I've updated the RFC to match these changes and expanded the
raw-mode/testability sections as well.

Thanks for the review.

Best,
Pratik

---

## #132696 — Larry Garfield — 2026-09-29T14:35:07+00:00

URL: https://externals.io/message/132663#132696 · In reply to: #132686

> 

> > […]
>
> Fair point. The RFC was assuming too much terminal background there.
>
> In canonical mode the terminal normally buffers input until a line is
> complete, may echo it, and handles some control characters itself. Raw
> mode turns off that line-oriented processing so the application can
> react to individual key presses and terminal sequences directly.
>
> One thing that also wasn't clear is that callers don't need to call
> enableRawMode() before readKey() or readSecret(). Both handle the
> temporary mode change internally.
>
> enableRawMode() is there for longer-running interactive code that
> wants to keep the terminal raw across multiple reads/redraws.
>
> I've clarified that in the RFC.

Thanks, that does make it clearer!  So the reason to use raw mode yourself would be, for instance, for a game where you're capturing the arrow keys and WASD, or something like that?  (Concrete examples would help.). 

This also makes me think that a `readLine()` method makes sense in this base tool, not at a higher level.  (I haven't done much console-GUI works so I am not familiar with the typical patterns, but it seems like the natural complement to readKey().)

> > […]
>
> Conceptually, yes. That's what I was trying to model with ModeToken:
> it represents the active lease, and restoreMode() gives an explicit
> way to release it in a try/finally.
>
> PHP doesn't have a general language-level context-manager construct,
> and I didn't want to add a terminal-specific callback abstraction just
> for this, so I kept the primitive explicit.

Yes, that's more of an aside for the audience.  Arnaud and I have an RFC out (currently on hold) for context managers, and this would be another very good use case for them.

> > […]
>
> I agree with the testing concern. I've added TerminalInterface while
> keeping the native Terminal final, so application and library code can
> type against something that can be replaced by a userland fake.
>
> I also added ModeTokenInterface for fake implementations. The native
> Terminal still only accepts a native token belonging to the same
> logical terminal; foreign, stale, or unrelated tokens are rejected
> with ValueError.

Conventions for Internals say to not use a *Interface suffix.  It's unnecessary.  I would suggest either 

interface Terminal {
  public function readKey();
  // ...
}

class SystemTerminal implements Terminal {
  public static function fromStdIo(): self {}
  public static function fromStreams(): self {}
}

or possibly:

class StdIoTerminal implements Terminal {
  // .. No factories.
}

class StreamsTerminal implements Terminal {
  public function __construct($in, $out = null) {}
}

For ModeToken, I'm not sure if it makes sense to have separate classes for each core terminal.  It's just an opaque value object, really, so I don't know what pattern we'd want here.

--Larry Garfield

---

## #132697 — Pratik Bhujel — 2026-09-29T16:04:05+00:00

URL: https://externals.io/message/132663#132697 · In reply to: #132696

> Thanks, that does make it clearer! So the reason to use raw mode yourself would be, for instance, for a game where you're capturing the arrow keys and WASD, or something like that? (Concrete examples would help.)

Yep, exactly. Games are one example, but interactive TUIs are probably
the more common case here: menus, search/select UIs, editors, anything
that needs to react to individual key presses and redraw without
waiting for Enter.

> This also makes me think that a `readLine()` method makes sense in this base tool, not at a higher level.

I did think about that. My hesitation is that normal PHP streams
already handle line-oriented reads pretty well, while this proposal is
mainly trying to cover the terminal-specific bits that aren't exposed
portably today.

So for the first core version I'd rather keep `readLine()` out instead
of duplicating `fgets()`-style behavior. The extension can still be
useful as a backport/reference implementation and carry convenience
APIs that don't necessarily need to be in core.

> Conventions for Internals say to not use a *Interface suffix. It's unnecessary.

Thanks, I hadn't considered that core naming convention when I added
the interfaces.

I used the *Interface names mainly so I could add the mockable
contract without renaming the existing Terminal and ModeToken classes.
I see the naming issue though. I want to check whether changing the
class/interface split is worth the additional public API churn before
changing it again.

> For ModeToken, I'm not sure if it makes sense to have separate classes for each core terminal. It's just an opaque value object, really, so I don't know what pattern we'd want here.

Yeah, I agree. I don't see much value in separate token classes
either. The main thing I need to preserve is validating native tokens
against the logical terminal they belong to, while still keeping
userland implementations mockable.

Best regards,
Pratik

---

## #132711 — Pratik Bhujel — 2026-09-30T08:42:49+00:00

URL: https://externals.io/message/132663#132711 · In reply to: #132697

Hi internals, Tim, Nicolas and Larry,

Sorry, I sent this update separately by mistake and left the list off
Cc. Posting it here to keep the discussion in the RFC thread.

I've updated Io\Terminal to version 0.3 and pushed the corresponding
implementation changes:

https://wiki.php.net/rfc/io_terminal
https://github.com/php/php-src/pull/23941

Thanks for the feedback. I revisited line input and naming after my
last reply, and wanted to explain the decisions together here.

1. Naming and testability

Terminal and ModeToken are now the interfaces, with final
SystemTerminal and SystemModeToken as the native implementations. This
follows the naming convention Larry pointed out and lets libraries use
userland implementations for testing.

I kept one native terminal class with fromStdio() and fromStreams().
Selecting the streams does not need a separate public class when the
operations are otherwise the same. There is also one native token
class.

TerminalSize has a public constructor requiring positive dimensions,
so tests can construct sizes directly. The RFC includes the actual
stub and its strict-properties and non-serializable annotations.

2. Why `readLine()` is included

My earlier reasoning focused too much on the overlap with `fgets()`.
Having line input on Terminal gives libraries the same interface for
keys, lines and hidden input, including native Windows console
handling and coordination with raw-mode leases.

 `readLine()` removes LF/CRLF, preserves other whitespace, returns null
on immediate EOF, and returns a final unterminated line at EOF. POSIX
uses the existing line discipline; Windows uses native console line
input and restores the previous mode. Redirected PHP streams retain
their buffering, blocking and read-timeout behavior.

It throws TerminalException while a managed raw-mode lease is active
for that terminal, including one acquired through another wrapper.
Silently overriding the mode would interfere with the code holding
that lease.

I kept the timeout parameter out because enforcing a portable
whole-line deadline while preserving native editing would require more
control over the editing loop. History, completion and richer editing
remain userland concerns.

3. Raw-mode ownership and token lifetime

The implementation uses the shared record model Nicolas suggested. The
first lease saves the original mode and holds an independent
restoration descriptor or handle. Later leases share that record, and
the last release restores the saved mode, regardless of release order.
Closing the original stream does not by itself prevent restoration.

Wrappers for the same logical terminal coordinate, while unrelated
PTYs remain separate. The token is opaque and can outlive its wrapper.
SystemTerminal retains its latest token, so unsetting only the
caller's variable may leave that lease active.

Destruction and request shutdown attempt cleanup; explicit
restoreMode() reports restoration failures. Native restoration rejects
foreign, consumed or unrelated tokens with ValueError. Without an
argument, restoreMode() returns false when the wrapper has no active
retained token, including one already released through another
wrapper.

4. When explicit raw mode is useful

Games, arrow-key menus, search/select UIs and editors need input
without waiting for Enter. An explicit lease keeps the terminal raw
between reads and redraws.

readKey() and readSecret() manage their own temporary mode, so
individual reads do not require enableRawMode() first. Longer sessions
use a lease with try/finally and restoreMode().

5. Return values, timeouts and secret input

getSize() returns null when no usable size is available. Key and
secret reads return null on timeout. Operational failures, key-input
EOF and secret cancellation throw TerminalException.

readSecret() accepts an overall Time\Duration timeout. Echo is
disabled during entry; the method does not display the typed
characters. An empty submitted secret returns "", while a timeout
returns null. Escape, Ctrl+C and Ctrl+D cancel with TerminalException.

For timeout arguments, null means no finite deadline, zero makes a
non-blocking attempt, and negative durations throw ValueError.
readSecret() keeps escape-sequence handling internal.

6. Partial input and regression coverage

The RFC now distinguishes incomplete UTF-8 bytes retained across
key-read timeouts from incomplete POSIX escape sequences, which may
return a consumed prefix. The POSIX sequence ambiguity window cannot
extend the overall timeout.

Regression tests cover cross-wrapper restoration and subsequent line
reads, token lifetime, unrelated PTYs, and native POSIX line reads
encountering EAGAIN/EWOULDBLOCK. Temporary unavailability now causes
those native line reads to wait for input rather than being treated as
EOF.

I'd like to keep the feature set steady now and give version 0.3 the
full 14-day cooldown. Once that has elapsed and substantive discussion
is settled, I'll post a separate Intent to Vote. Implementation review
and platform testing can continue alongside that.

Best Regards,
Pratik

---

## #132755 — tim@bastelstu.be — 2026-10-01T15:49:27+00:00

URL: https://externals.io/message/132663#132755 · In reply to: #132685

Hi


> - I understand all of the usual arguments for making the Terminal class 
> final.  However, it also has no interface.  That means it's basically 
> impossible to mock for testing purposes.  That strikes me as a problem, 
> because any IO boundary should be mockable.  I don't know that multiple 
> non-testing implementations makes sense (maybe alternatives to the 
> static constructors?), but we do need some straightforward mechanism to 
> mock a Terminal object.  (I assume someone is going to respond with 
> "it's an implementation detail of something else," which is only 
> partially true; I don't want to have to create a pass-through wrapper 
> for something just for testing purposes, and even then, it would be the 
> same API in name only, since it cannot share a type.  That hurts 
> interoperability.)

I very strongly disagree with the interface suggestions for the exact 
“implementation detail” reason you mentioned and the introduction of the 
interface has made the API much worse:

The (System)Terminal class *is* an implementation detail of something 
else and it is only exercised as part of the glue code within an 
integration test. It is not the IO boundary, the “TTY stream” you 
provide to the Terminal is. You need the TTY stream separately anyway, 
because the (System)Terminal does not provide any mechanism to write to 
the output (and the underlying stream is not exposed either). In your 
tests you would then attach an appropriate stream to the terminal (e.g. 
using `proc_open()` with `pty` descriptors, which is what the RFC’s own 
implementation already uses for testing).

This is very much like the final `Random\Randomizer` where you would 
have unit tests for the “number consumer”, passing hardcoded values. And 
then you have an integration test where you provide a class implementing 
the `Random\Engine` interface as the IO boundary. How the glue code 
turns a “Random\Engine” into a “random number” that is passed to the 
unit-tested “number consumer” is an implementation detail that is not 
worth testing.

There are additional indicators that the `SystemTerminal` class is more 
akin to a “helper” class:

1. Large parts of the `Terminal`’s API surface don’t need to be instance 
methods, because they don’t really care about the object state (beyond 
the stream). They could also be bare functions and they were in the 
initial pre-RFC implementation. Having them as instance methods makes 
the API a little more convenient to use and enables the RAII reset.

2. The interface’s contract is “What `SystemTerminal` does”. As you say 
yourself, you are not sure if there can be multiple non-testing 
implementations, and I can't think of any either: The `Terminal` 
interface does not define an “abstract concept” with multiple equally 
legal implementations.

This is contrary to, say, Time\Clock where the Clock *is* the IO 
boundary and you can have SystemClock, GpsClock (when a GPS receiver is 
attached to your computer), HttpClock (fetching an HTTP service to 
obtain the current time, not useful, but theoretically meaningful), ….

3. The interface is humongous and not well-defined: Terminals expose a 
broad API surface and this is reflected by the `SystemTerminal` class 
and the RFC even cut out some of the methods that are there in the 
pre-RFC implementation. It is likely that the API surface will grow in 
future PHP versions with additional helper functionality. This also 
means that the interface needs to grow, which is an obvious breaking 
change. Or new non-well-defined interfaces need to be added, which is 
bad API design. In fact v0.3 of the RFC added a `readLine()` method 
which (at least on POSIX) is effectively redundant with `fgets()`, but 
allows consistent access to the “input side” of the terminal. It is not 
unlikely that a future PHP version might want to add `readUntilEof()` or 
similar.

4. The interface being a direct mapping of the Terminal API surface also 
resulted in the `ModeToken` interface being added, which is an interface 
for something that is an opaque “token” (value) object. And then due to 
interface constraints / the lack of generics, the `Terminal` interface 
requires any `Terminal` to accept any `ModeToken` in the signature of 
`restoreMode()` just to validate that it is the `ModeToken` 
implementation that belongs to the `Terminal`. The RFC specifies a 
`ValueError` here, but it really is a `TypeError` that cannot be 
expressed by the type system.

5. This has also resulted in the naming weirdness: The obvious name is 
“Terminal”, the “System” part in “SystemTerminal” carries no additional 
information.

Best regards
Tim Düsterhus

---

## #132770 — Larry Garfield — 2026-10-02T14:19:25+00:00

URL: https://externals.io/message/132663#132770 · In reply to: #132755

> Hi
>
> 
> > […]
>
> I very strongly disagree with the interface suggestions for the exact 
> “implementation detail” reason you mentioned and the introduction of the 
> interface has made the API much worse:
>
> The (System)Terminal class *is* an implementation detail of something 
> else and it is only exercised as part of the glue code within an 
> integration test. It is not the IO boundary, the “TTY stream” you 
> provide to the Terminal is. You need the TTY stream separately anyway, 
> because the (System)Terminal does not provide any mechanism to write to 
> the output (and the underlying stream is not exposed either). In your 
> tests you would then attach an appropriate stream to the terminal (e.g. 
> using `proc_open()` with `pty` descriptors, which is what the RFC’s own 
> implementation already uses for testing).
>
> This is very much like the final `Random\Randomizer` where you would 
> have unit tests for the “number consumer”, passing hardcoded values. And 
> then you have an integration test where you provide a class implementing 
> the `Random\Engine` interface as the IO boundary. How the glue code 
> turns a “Random\Engine” into a “random number” that is passed to the 
> unit-tested “number consumer” is an implementation detail that is not 
> worth testing.
>
> There are additional indicators that the `SystemTerminal` class is more 
> akin to a “helper” class:
>
> 1. Large parts of the `Terminal`’s API surface don’t need to be instance 
> methods, because they don’t really care about the object state (beyond 
> the stream). They could also be bare functions and they were in the 
> initial pre-RFC implementation. Having them as instance methods makes 
> the API a little more convenient to use and enables the RAII reset.
>
> 2. The interface’s contract is “What `SystemTerminal` does”. As you say 
> yourself, you are not sure if there can be multiple non-testing 
> implementations, and I can't think of any either: The `Terminal` 
> interface does not define an “abstract concept” with multiple equally 
> legal implementations.
>
> This is contrary to, say, Time\Clock where the Clock *is* the IO 
> boundary and you can have SystemClock, GpsClock (when a GPS receiver is 
> attached to your computer), HttpClock (fetching an HTTP service to 
> obtain the current time, not useful, but theoretically meaningful), ….
>
> 3. The interface is humongous and not well-defined: Terminals expose a 
> broad API surface and this is reflected by the `SystemTerminal` class 
> and the RFC even cut out some of the methods that are there in the 
> pre-RFC implementation. It is likely that the API surface will grow in 
> future PHP versions with additional helper functionality. This also 
> means that the interface needs to grow, which is an obvious breaking 
> change. Or new non-well-defined interfaces need to be added, which is 
> bad API design. In fact v0.3 of the RFC added a `readLine()` method 
> which (at least on POSIX) is effectively redundant with `fgets()`, but 
> allows consistent access to the “input side” of the terminal. It is not 
> unlikely that a future PHP version might want to add `readUntilEof()` or 
> similar.
>
> 4. The interface being a direct mapping of the Terminal API surface also 
> resulted in the `ModeToken` interface being added, which is an interface 
> for something that is an opaque “token” (value) object. And then due to 
> interface constraints / the lack of generics, the `Terminal` interface 
> requires any `Terminal` to accept any `ModeToken` in the signature of 
> `restoreMode()` just to validate that it is the `ModeToken` 
> implementation that belongs to the `Terminal`. The RFC specifies a 
> `ValueError` here, but it really is a `TypeError` that cannot be 
> expressed by the type system.
>
> 5. This has also resulted in the naming weirdness: The obvious name is 
> “Terminal”, the “System” part in “SystemTerminal” carries no additional 
> information.
>
> Best regards
> Tim Düsterhus

My main concern is being able to mock the terminal in order to effectively test code that uses the terminal, without putting a proprietary thin wrapper around it (which largely defeats the purpose of having a good API in core).  Interfaces are the standard way of doing that.  If you have a suggestion for a better way, I'm happy to see it.

--Larry Garfield

---

## #132771 — Bob Weinand — 2026-10-02T15:56:47+00:00

URL: https://externals.io/message/132663#132771 · In reply to: #132755

> Hi
>
> 
> > […]
>
> I very strongly disagree with the interface suggestions for the exact 
> “implementation detail” reason you mentioned and the introduction of 
> the interface has made the API much worse:
>
> The (System)Terminal class *is* an implementation detail of something 
> else and it is only exercised as part of the glue code within an 
> integration test. It is not the IO boundary, the “TTY stream” you 
> provide to the Terminal is. You need the TTY stream separately anyway, 
> because the (System)Terminal does not provide any mechanism to write 
> to the output (and the underlying stream is not exposed either). In 
> your tests you would then attach an appropriate stream to the terminal 
> (e.g. using `proc_open()` with `pty` descriptors, which is what the 
> RFC’s own implementation already uses for testing).
>
> This is very much like the final `Random\Randomizer` where you would 
> have unit tests for the “number consumer”, passing hardcoded values. 
> And then you have an integration test where you provide a class 
> implementing the `Random\Engine` interface as the IO boundary. How the 
> glue code turns a “Random\Engine” into a “random number” that is 
> passed to the unit-tested “number consumer” is an implementation 
> detail that is not worth testing.
>
> There are additional indicators that the `SystemTerminal` class is 
> more akin to a “helper” class:
>
> 1. Large parts of the `Terminal`’s API surface don’t need to be 
> instance methods, because they don’t really care about the object 
> state (beyond the stream). They could also be bare functions and they 
> were in the initial pre-RFC implementation. Having them as instance 
> methods makes the API a little more convenient to use and enables the 
> RAII reset.
>
> 2. The interface’s contract is “What `SystemTerminal` does”. As you 
> say yourself, you are not sure if there can be multiple non-testing 
> implementations, and I can't think of any either: The `Terminal` 
> interface does not define an “abstract concept” with multiple equally 
> legal implementations.
>
> This is contrary to, say, Time\Clock where the Clock *is* the IO 
> boundary and you can have SystemClock, GpsClock (when a GPS receiver 
> is attached to your computer), HttpClock (fetching an HTTP service to 
> obtain the current time, not useful, but theoretically meaningful), ….
>
> 3. The interface is humongous and not well-defined: Terminals expose a 
> broad API surface and this is reflected by the `SystemTerminal` class 
> and the RFC even cut out some of the methods that are there in the 
> pre-RFC implementation. It is likely that the API surface will grow in 
> future PHP versions with additional helper functionality. This also 
> means that the interface needs to grow, which is an obvious breaking 
> change. Or new non-well-defined interfaces need to be added, which is 
> bad API design. In fact v0.3 of the RFC added a `readLine()` method 
> which (at least on POSIX) is effectively redundant with `fgets()`, but 
> allows consistent access to the “input side” of the terminal. It is 
> not unlikely that a future PHP version might want to add 
> `readUntilEof()` or similar.
>
> 4. The interface being a direct mapping of the Terminal API surface 
> also resulted in the `ModeToken` interface being added, which is an 
> interface for something that is an opaque “token” (value) object. And 
> then due to interface constraints / the lack of generics, the 
> `Terminal` interface requires any `Terminal` to accept any `ModeToken` 
> in the signature of `restoreMode()` just to validate that it is the 
> `ModeToken` implementation that belongs to the `Terminal`. The RFC 
> specifies a `ValueError` here, but it really is a `TypeError` that 
> cannot be expressed by the type system.
>
> 5. This has also resulted in the naming weirdness: The obvious name is 
> “Terminal”, the “System” part in “SystemTerminal” carries no 
> additional information.
>
> Best regards
> Tim Düsterhus

Well laid out Tim,

the I/O boundary comes from the streams passed to fromStreams().

For any mocking purposes / different terminals, it's a matter of 
composing the stream. We just need to make sure that it's actually 
possible to construct such a tty in tests for example, which can 
intercept the events / set state for size etc..

Terminal (yes, SystemTerminal shouldn't be a thing) is just a class 
which takes stuff from the stream and outputs it. There's no IO 
operations in itself (from userland perspective), only on the resource, 
which is the actual underlying entity forwarding IO operations to the 
kernel.

Thanks,

Bob

---

## #132772 — tim@bastelstu.be — 2026-10-02T15:59:28+00:00

URL: https://externals.io/message/132663#132772 · In reply to: #132770

Hi


> My main concern is being able to mock the terminal in order to 
> effectively test code that uses the terminal, without putting a 
> proprietary thin wrapper around it (which largely defeats the purpose 
> of having a good API in core).

It is not clear to me what you mean by “proprietary” here. As I had 
mentioned in my email, the RFC’s own PR is tested against processes 
spawned using `proc_open()` with `pty` descriptors. The tests are 
naturally BSD-licensed, just like PHP itself is (since PHP 8.6).

In your test you would create a process that emits scripted output 
(possibly in response to some input), just like you would in the “mocked 
interface implementation” and then pass the PTY input/output streams to 
whatever you want to test, which will then call 
`(System)Terminal::fromStreams()` using them as parameters. The logic 
under test reads inputs from the Terminal instance and writes output 
using `fwrite()`. The subprocess will also enable you to properly model 
concurrency, delay and timing in IO processing. Terminal interactions in 
the real world are not synchronous either: The terminal logic relies on 
timing to distinguish escape sequences from individual characters 
(that's what the `$sequenceTimeout` is for) and the user might already 
provide additional input while your application is still busy rendering 
output and not yet expecting additional data.

> Interfaces are the standard way of doing that.  If you have a 
> suggestion for a better way, I'm happy to see it.

My email included 5 arguments as to why an interface is the wrong design 
here. Do you plan to engage with those?

Best regards
Tim Düsterhus

---

## #132773 — Larry Garfield — 2026-10-02T16:20:22+00:00

URL: https://externals.io/message/132663#132773 · In reply to: #132772

> Hi
>
> 
> > […]
>
> It is not clear to me what you mean by “proprietary” here. As I had 
> mentioned in my email, the RFC’s own PR is tested against processes 
> spawned using `proc_open()` with `pty` descriptors. The tests are 
> naturally BSD-licensed, just like PHP itself is (since PHP 8.6).

I didn't mean proprietary as in license.  I mean, for example, a SymfonyTerminal that wraps a Terminal so that SymfonyTerminal can be mocked.  Which of course is different than LaravelTerminal.  That would be a bad place to end up, and we should avoid that.  ("Wrap 3rd party code so you can mock it" is a common recommendation, though as in this case it can lead to other problems.)

> In your test you would create a process that emits scripted output 
> (possibly in response to some input), just like you would in the “mocked 
> interface implementation” and then pass the PTY input/output streams to 
> whatever you want to test, which will then call 
> `(System)Terminal::fromStreams()` using them as parameters. The logic 
> under test reads inputs from the Terminal instance and writes output 
> using `fwrite()`. The subprocess will also enable you to properly model 
> concurrency, delay and timing in IO processing. Terminal interactions in 
> the real world are not synchronous either: The terminal logic relies on 
> timing to distinguish escape sequences from individual characters 
> (that's what the `$sequenceTimeout` is for) and the user might already 
> provide additional input while your application is still busy rendering 
> output and not yet expecting additional data.
>
> > […]
>
> My email included 5 arguments as to why an interface is the wrong design 
> here. Do you plan to engage with those?

No, because I am not advocating for interfaces.  I am advocating for a clean and obvious mocking/testing mechanism, for which interfaces are a common solution.  I am not wedded to interfaces as the solution, just that there is a reliable one that is self-evident (and/or documented).  That is, my invitation to suggest a better way was in no way factious or snarky.

If I understand what you and Bob (thanks Bob) are suggesting, one would do something like:

$in = fopen('php://memory');
$out = fopen('php://memory');

fputs($in, "first line\n");
fputs($in, "second line\n");
rewind($in);

$t = Terminal::fromStreams($in, $out);
$line = $t->readLine();
assert($line === 'first line');
$line = $t->readLine();
assert($line === 'second line');

Is that correct?  Pratik, are you able to confirm (via tests) that this would work as a testing approach, including for key and secret reads?

If so, then I agree the interfaces become unnecessary, and the above should be included in the RFC as the recommended testing approach.

Although, it occurs to me while typing the above, the Terminal accepts an output stream, but doesn't appear to have any output API.  When is the output stream even used?  Should it be removed, or a print() (or similar) API added?

--Larry Garfield

---

## #132775 — tim@bastelstu.be — 2026-10-02T18:10:52+00:00

URL: https://externals.io/message/132663#132775 · In reply to: #132773

Hi


> If I understand what you and Bob (thanks Bob) are suggesting, one would 
> do something like:
> 
> $in = fopen('php://memory');
> $out = fopen('php://memory');
> 
> fputs($in, "first line\n");
> fputs($in, "second line\n");
> rewind($in);
> 
> $t = Terminal::fromStreams($in, $out);
> $line = $t->readLine();
> assert($line === 'first line');
> $line = $t->readLine();
> assert($line === 'second line');
> 
> Is that correct?  Pratik, are you able to confirm (via tests) that this 
> would work as a testing approach, including for key and secret reads?

Except for the missing `$mode` on `fopen()`, *literally* just that would 
work. I just compiled the branch and ran:

     <?php

     $in = fopen('php://memory', 'w+');
     $out = fopen('php://memory', 'w+');

     fputs($in, "first line\n");
     fputs($in, "second line\n");
     rewind($in);

     $t = Io\Terminal\SystemTerminal::fromStreams($in, $out);
     $line = $t->readLine();
     var_dump($line);
     $line = $t->readLine();
     var_dump($line);

the output is:

     string(10) "first line"
     string(11) "second line"

However for `readKey()` et al you need a “PTY” handle, so you would, as 
I mentioned in both of my replies, spawn sub-processes with stdin / 
stdout being of type `pty` in the `descriptor_spec` (this incidentally 
doesn't seem to be documented yet, except for the user comments) and 
appropriate environment variables. Spawning the side using the 
`Terminal` in the sub-process avoids weird inverted logic with regard to 
which descriptor is the logical input and which one is the logical 
output: Writing to the input handle emulates keyboard input and you can 
assert on whatever you get back on the output handle:

     <?php

     use Io\Terminal\SystemTerminal;

     function `app()`: void
     {
         $terminal = SystemTerminal::fromStdio();
         // The mode is kept on the terminal, we only need the token
         // for explicit resets.
         (void)$terminal->enableRawMode();

         // Synchronization point
         echo "test ready\n";

         $key = $terminal->readKey();
         var_dump($key);

         // Simulate timing / computation that happens.
         sleep(1);

         echo "Password?", PHP_EOL;
         $secret = $terminal->readSecret();
         echo "Got: ", $secret, "\n";
     }

     if (($argv[1] ?? null) === '--app') {
         `app()`;
         `exit()`;
     }

     $proc = proc_open(
         [PHP_BINARY, __FILE__, '--app'],
         [0 => ['pty'], 1 => ['pty'], 2 => ['pipe', 'w']],
         $pipes,
         null,
         ['TERM' => 'xterm-256color'],
     );

     // Wait for the synchronization point that confirms the application 
is up and running
     // and accepting keyboard input. In the real world this happens 
through the user
     // observing the output (which is slower than the application 
start).
     fgets($pipes[1]);

     // And now press the “Up” arrow.
     fwrite($pipes[0], "\e[A");
     echo fgets($pipes[1]);

     // Write the password in anticipation of the prompt (which is 
delayed by computation,
     // simulating user behavior).
     fwrite($pipes[0], "hunter2\n");

     // Assert the prompt and output confirming the password input.
     $prompt = trim(fgets($pipes[1]));
     assert($prompt === "Password?");
     var_dump(trim(fgets($pipes[1])));

     proc_close($proc);


The output of that is:

     enum(Io\Terminal\Key::Up)
     string(12) "Got: hunter2"

Spawning a process is comparatively heavy, but “testing the IO glue 
code” is integration test territory, the business logic - e.g. the 
actual password verification - is already tested independently as part 
of a unit test.

> If so, then I agree the interfaces become unnecessary, and the above 
> should be included in the RFC as the recommended testing approach.
> 
> Although, it occurs to me while typing the above, the Terminal accepts 
> an output stream, but doesn't appear to have any output API.  When is 
> the output stream even used?  Should it be removed, or a print() (or 
> similar) API added?

The output stream is used to determine the terminal size - which makes 
sense, since you want to know the size of what you're writing to. For 
output you can just `fwrite()` to the handle directly, but having some 
output functionality makes sense to me as future scope (e.g. a function 
to print some text with ANSI color escapes, which may be what's meant by 
“ANSI support” in the future scope).

Best regards
Tim Düsterhus

---

## #132777 — Pratik Bhujel — 2026-10-02T20:38:37+00:00

URL: https://externals.io/message/132663#132777 · In reply to: #132775

Hi all,

Larry, Tim, Bob, thanks for the detailed replies. Tim, thanks also for
compiling the branch and checking the POSIX example.

> Is that correct? Pratik, are you able to confirm (via tests) that this
> would work as a testing approach, including for key and secret reads?

For `readLine()`, yes: exactly as you wrote it, I can confirm it works. I ran:

    $in = fopen('php://memory', 'w+');
    fwrite($in, "first line\nsecond line\n");
    rewind($in);

    $t = Io\Terminal\SystemTerminal::fromStreams($in);

    var_dump($t->readLine());
    var_dump($t->readLine());
    var_dump($t->readLine());

and got:

    string(10) "first line"
    string(11) "second line"
    `NULL` 

I also checked empty lines, immediate EOF and a final unterminated line.
Those behave as specified.

For readKey() and readSecret(), ordinary streams like php://memory or
php://temp do not work, and are intentionally rejected because those
operations require an actual terminal device. For example:

    $t->readKey();
    // Io\Terminal\TerminalException: Failed to read key: input stream is
not a terminal

On POSIX, Tim's `proc_open()` + PTY example is exactly the right kind of test
for this. It gives the code a real terminal while still letting the test
script the input from pure PHP.

I also checked Windows because I did not want to assume the POSIX PTY
mechanism translated there. It does not directly, since `proc_open()` on
Windows does not provide the same PTY descriptor. I built the current
branch on Windows 11 ARM64 and exercised the native console path instead.
The PHP side was just:

    $t = Io\Terminal\SystemTerminal::fromStdio();
    var_dump($t->readKey());

A small harness attached to a real Windows console injected KEY_EVENT
records into CONIN$ with WriteConsoleInputW(). Injecting Up produced:

    enum(Io\Terminal\Key::Up)

I also checked Down, Left, Right, Enter, Tab, Backspace, Escape, F1,
ordinary characters, repeat counts and Unicode including a UTF-16 surrogate
pair. readSecret() was exercised through the same native console path for
normal input, empty input, backspace, Unicode, Escape/Ctrl+C cancellation
(which throws TerminalException) and zero-timeout return (which returns
null). `readLine()` was exercised through the native ReadConsoleW() path as
well. The ARM64 build needed an unrelated local Zend SIMD compiler
workaround, but I did not change Io\Terminal for these tests.

So the direct answer is yes for line input, but with a platform distinction
for interactive keys. Ordinary stream behavior can be scripted with normal
PHP streams. Terminal-specific behavior needs a real terminal. On POSIX
that is conveniently available through a PTY in pure PHP, as Tim showed. On
Windows the native implementation is testable too, but the equivalent test
currently needs a real console/native helper rather than the same PTY
recipe. That means Tim and Bob's point about the stream/terminal being the
underlying I/O boundary holds, while Larry's concern about a
straightforward cross-platform testing seam is still relevant for Windows
code that directly consumes readKey().

> Although, it occurs to me while typing the above, the Terminal accepts
> an output stream, but doesn't appear to have any output API. When is
> the output stream even used? Should it be removed, or a print() (or
> similar) API added?

I checked this in the implementation. The output stream is not unused.
getSize() queries the output side for terminal dimensions. readKey() also
checks the output side for size changes so it can return Key::Resize, and
on Windows a WINDOW_BUFFER_SIZE_EVENT causes the output side to be queried
again for the current dimensions. So I don't think the output stream should
be removed. I also don't think we need print() or write() just to justify
it; actual application output can continue to use `fwrite()`/echo and normal
stream APIs.

On the interface question, Tim's objections to the current
Terminal/ModeToken shape still make sense to me, especially the ModeToken
case where a userland token can satisfy the interface but cannot actually
be restored by the native terminal. At the same time, after checking
Windows I don't think I can honestly say that php://memory or stream
injection alone makes the interface unnecessary everywhere. It clearly
solves `readLine()`, and the PTY gives us a good POSIX integration seam, but
Windows userland code directly consuming readKey() does not have the same
pure-PHP PTY seam today.

I have not changed the RFC or implementation yet. I would like to settle
this point before making another API change, so that the RFC can go into
the next stage with this part resolved rather than carrying the
disagreement forward. The remaining question for me is what testing seam we
want cross-platform userland code which directly consumes readKey() to
have, without keeping the ModeToken problem Tim pointed out.

Best regards,
Pratik

---

## #132785 — Ilia — 2026-10-05T11:32:25+00:00

URL: https://externals.io/message/132663#132785 · In reply to: #132777

Hi Pratik,



I have not changed the RFC or implementation yet. I would like to settle
> this point before making another API change, so that the RFC can go into
> the next stage with this part resolved rather than carrying the
> disagreement forward. The remaining question for me is what testing seam we
> want cross-platform userland code which directly consumes readKey() to
> have, without keeping the ModeToken problem Tim pointed out.
>

I agree with Tim and Bob: drop both interfaces and rename SystemTerminal
to Terminal. The Windows gap closes if readKey() and readSecret() stop
rejecting non-terminal input. A pipe or php://memory stream already
delivers bytes unprocessed, which is all raw mode does to a tty. So on
a non-terminal stream, readKey() can run the POSIX byte decoder,
enableRawMode() can return a lease that changes nothing, and
readSecret() can read to the newline. Tests then use php://memory on
every OS. Callers that need a real terminal have `stream_isatty()`.
 `readLine()` already accepts redirected input, so this keeps the API
consistent.

Three other things:

1. Specify raw mode. On POSIX the patch clears OPOST and ISIG
   (cfmakeraw). Symfony's "-icanon -echo" and Laravel Prompts'
   "-icanon -isig -echo" clear neither. With OPOST off, every "\n"
   output during a lease staircases on POSIX but not on Windows,
   because the console's output mode is never touched. Keep OPOST, or
   state the flags in the RFC and make Windows match.

2. `pcntl_fork()`: the child inherits the lease list, and its RSHUTDOWN
   restores the shared tty, so a TUI loses raw mode when a worker
   exits. Store the pid that acquired the lease and skip restoring in
   any other process.

3. Drop the retained token and the no-argument restoreMode(). A lease
   that stays active after the caller's variable is unset undoes the
   RAII guarantee. With the token as the only owner, Nicolas's refcount
   design covers everything.

Also, adding a Key case later breaks any match() without a default arm,
so add Insert and Shift+Tab now.

-- 
Ilia Alshanetsky
Technologist, CTO, Entrepreneur
E: ilia@ilia.ws
T: @iliaa
B: http://ilia.ws

---

## #132786 — tim@bastelstu.be — 2026-10-05T12:47:10+00:00

URL: https://externals.io/message/132663#132786 · In reply to: #132777

Hi


> I also checked Windows because I did not want to assume the POSIX PTY
> mechanism translated there. It does not directly, since `proc_open()` on
> Windows does not provide the same PTY descriptor. I built the current
> branch on Windows 11 ARM64 and exercised the native console path 
> instead.
> The PHP side was just:
> 
>     $t = Io\Terminal\SystemTerminal::fromStdio();
>     var_dump($t->readKey());
> 
> A small harness attached to a real Windows console injected KEY_EVENT
> records into CONIN$ with WriteConsoleInputW(). Injecting Up produced:
> 
>     enum(Io\Terminal\Key::Up)
> 
> […]
> straightforward cross-platform testing seam is still relevant for 
> Windows
> code that directly consumes readKey().

I know nothing about Windows, but am seeing that `proc_open()` offers a 
`create_new_console` option that is documented as:

> create_new_console (windows only): the new process has a new console, 
> instead of inheriting its parent's console

Is that perhaps already all that is required? Or would a 
(Windows-specific) helper `Io\Terminal\inject_fake_keypress()` outside 
of the main terminal API be helpful?

> On the interface question, Tim's objections to the current
> Terminal/ModeToken shape still make sense to me, especially the 
> ModeToken
> case where a userland token can satisfy the interface but cannot 
> actually
> be restored by the native terminal. At the same time, after checking
> Windows I don't think I can honestly say that php://memory or stream
> injection alone makes the interface unnecessary everywhere. It clearly
> solves `readLine()`, and the PTY gives us a good POSIX integration seam, 
> but
> Windows userland code directly consuming readKey() does not have the 
> same
> pure-PHP PTY seam today.

It sounds to me that “PHP is unable to spawn terminals on Windows” is an 
orthogonal concern and a pre-existing limitation of e.g. the 
`proc_open()` API. The lack of Windows support must not influence the 
design of the `Io\Terminal` API to introduce a “short term” workaround 
that will become obsolete as soon as PHP will be able to spawn terminals 
on Windows, but that will affect the `Io\Terminal` API in a negative way 
for eternity. We should try very hard that every new API we add to the 
standard library is well-designed and convenient to use for 15+ years 
without introducing breaking changes. Perhaps Ilia’s suggestion of just 
allowing a `php://memory` stream is okay, keeps `readKey()` capabilities 
in sync with `readLine()` and fills gap until `proc_open()` is extended 
for proper end-to-end testing?

Best regards
Tim Düsterhus

---

## #132787 — tim@bastelstu.be — 2026-10-05T12:52:01+00:00

URL: https://externals.io/message/132663#132787 · In reply to: #132785

Hi


> Also, adding a Key case later breaks any match() without a default arm,
> so add Insert and Shift+Tab now.

This is a textbook case of a non-exhaustive enum. I have a very rough 
draft RFC in https://wiki.php.net/rfc/non_exhaustive_marker that would 
allow for a clear indicator of such enums. I am not currently working on 
it (and I'm not sure between interface and attribute). If you (or 
someone else) wants to pick that up and build a proper design around it, 
please let me know and I'm happy to add you as a coauthor and let you 
manage the RFC :-)

Best regards
Tim Düsterhus

---

## #132804 — Pratik Bhujel — 2026-10-06T08:27:15+00:00

URL: https://externals.io/message/132663#132804 · In reply to: #132785

Hi all,

Sorry for the delay. I took some time to think through the testing and
ownership points and verify the changes on both platforms.

I have updated the RFC and implementation to v0.4:

https://wiki.php.net/rfc/io_terminal
https://github.com/php/php-src/pull/23941

Ilia wrote:

I agree with Tim and Bob: drop both interfaces and rename SystemTerminal to
Terminal.

This is now implemented. Terminal and ModeToken are final native classes,
with one Terminal implementation and the existing fromStdio() and
fromStreams() factories.

Larry, the scripted-stream approach now works for key and secret reads as
well as line reads. readKey() decodes ordinary byte streams on both
platforms, readSecret() accepts scripted input, and enableRawMode() returns
a no-op lease for non-terminal streams. This gives application input tests
a straightforward PHP-stream approach without adding interfaces to core.
Native dimensions, resize events and mode failures still need terminal
integration tests.

Tim, your point about keeping Windows test infrastructure separate from the
long-term API design helped clarify this. I also investigated
create_new_console on Windows 11 ARM64. With standard input redirected to a
pipe, the child still reads that pipe; creating a console does not make
those bytes native console key events. Opening CONIN$/CONOUT$ gives access
to console handles, but writing bytes with `fwrite()` does not inject input
records. The native console checks therefore used WriteConsoleInputW()
through a test harness. That distinction no longer dictates the public API:
ordinary streams support application input tests on Windows too.

Ilia's other points are addressed:
- Raw-mode acquisition preserves POSIX output settings, including OPOST.
The RFC now specifies the observable input behavior.
- Leases record the acquiring process. A forked child's cleanup does not
restore the parent's terminal mode; explicit restoration of an inherited
parent token is rejected.
- Terminal no longer retains the latest token. restoreMode() requires a
token and returns void. Normal completion means the lease was released;
failures throw. An explicit final restoration failure leaves the token
active for a retry.
- Key::Insert and Key::ShiftTab are included.

Nicolas's shared logical-terminal lease model is retained: the first
acquisition saves the mode, overlapping leases can be released in any
order, and the final release restores the saved state. Cross-wrapper
restoration for the same terminal also remains supported.

 `readLine()` keeps its limited scope, without a portable editor or timeout
parameter. It does not override an outstanding native raw-mode lease. The
output stream remains useful for geometry and resize detection; application
output continues through normal stream APIs.

Local testing covered macOS and Windows 11 ARM64, including native Windows
console input, Unicode and mode restoration. The PR also includes
regressions for input transitions, split CRLF handling and timeout behavior.

Thanks to everyone for working through this. I would like to move this
revision toward voting after allowing time to review these changes. Please
flag any remaining concerns with the public contract or implementation.

Best regards,
Pratik

---

## #132822 — Jakub Zelenka — 2026-10-06T20:44:41+00:00

URL: https://externals.io/message/132663#132822 · In reply to: #132663

Hi,

On Sun, Sep 27, 2026 at 7:21 PM Pratik Bhujel <prateekbhujelpb@gmail.com>
wrote:

> Hi internals,
>
> I would like to formally propose the Io\Terminal API for PHP 8.7:
>
> https://wiki.php.net/rfc/io_terminal
>
>
>
In general it's a nice RFC.

I wonder if we should have some stream abstraction. So far, streams are not
directly in Io as we are dealing with underlying fds that are abstracted
through Poll\Handle implementations which is also used by IO hooks for
direct rw. It has got advantages that it can support more than just
streams. In this case I'm not sure more than streams is needed but I feel
it would still be nicer to have some sort of abstraction there. I wouldn't
see it as a blocker, more just something to consider and think about.

Kind regards,

Jakub

---

## #132823 — Pratik Bhujel — 2026-10-07T03:48:08+00:00

URL: https://externals.io/message/132663#132823 · In reply to: #132822

Hi Jakub,

> I wonder if we should have some stream abstraction.

Thanks for taking a look. I see the benefit of having something shared
across Io, especially if it can support sources beyond streams.

With fromStreams(), one thing I would want to preserve is the supplied
stream's buffering and input behavior, including support for php://memory.
We also need access to the native terminal capabilities for mode changes
and Windows console input.

For this RFC, I would lean towards keeping fromStdio() and fromStreams().
If a suitable shared abstraction becomes available, we could add another
factory for it while keeping these entry points. The ownership and
buffering rules would still need to be worked through.

Did you have a particular abstraction in mind? I would be interested in
understanding whether it would improve the current API, or whether keeping
these factories leaves us enough room to integrate it later.

Regards,
Pratik

---

## #132854 — Pratik Bhujel — 2026-10-09T12:40:18+00:00

URL: https://externals.io/message/132663#132854 · In reply to: #132823

Hi all,

I realised I had left the voting widget out of the RFC, so I’ve now added
it. The API is unchanged.

https://wiki.php.net/rfc/io_terminal

Sorry for missing this earlier.

Regards,
Pratik
