Repository navigation
Feature Request: PTY support for ChildProcess API #82
Description
Summary
Add pseudo-terminal (PTY) support to the ChildProcess API to enable running interactive CLI/TUI applications within NativePHP desktop apps.
Current Behavior
The current ChildProcess implementation uses Electron's utilityProcess.fork() which spawns processes with stdio: 'pipe'. This works well for simple command-line tools that read/write plain text via stdin/stdout.
However, interactive TUI (Text User Interface) applications do not work because they require a pseudo-terminal (PTY) to:
- Detect terminal dimensions (rows/columns)
- Handle raw input mode (character-by-character instead of line-buffered)
- Support ANSI escape sequences for cursor movement, colors, and screen manipulation
- Respond to terminal resize events (SIGWINCH)
Use Case
We are building a NativePHP desktop app that needs to embed interactive CLI tools like:
- Claude Code (
claudeCLI) - an AI coding assistant with a full TUI interface - Other TUI apps:
htop,vim,lazygit, terminal-based REPLs, etc.
These tools detect that they are not connected to a TTY and either refuse to start in interactive mode or fall back to a degraded non-interactive mode with no output rendering.
Proposed Solution
Add an optional pty: true option to the ChildProcess API that uses a PTY instead of plain pipes. On the Node.js/Electron side, this could leverage:
node-pty- a mature library for spawning PTY processes in Node.js- Native PTY APIs via N-API addon
Example API
// Current (pipes only)
ChildProcess::start('claude', cwd: '/path/to/project');
// Proposed (with PTY support)
ChildProcess::start('claude', cwd: '/path/to/project', pty: true);
// Optional: resize support
ChildProcess::resize($alias, cols: 120, rows: 40);Events
The existing event system (MessageReceived, ErrorReceived, ProcessExited) would continue to work. An additional event could be added for resize handling:
#[On('native:...\ChildProcess\ProcessResized')]
public function onResize(string $alias, int $cols, int $rows): void { }Technical Details
The key change would be in the Electron-side childProcess.js helper:
// Current: uses stdio pipes
const proc = spawn(command, args, { stdio: 'pipe', cwd });
// With PTY: use node-pty
const pty = require('node-pty');
const proc = pty.spawn(command, args, { cwd, cols: 80, rows: 24 });The rest of the event chain (stdout → notifyLaravel → broadcastToWindows → Livewire dispatch) would remain unchanged.
Environment
- NativePHP Desktop v2
- macOS (primary), but node-pty supports Windows and Linux as well
- Electron (utilityProcess context)
Workaround
Currently there is no viable workaround for running interactive TUI applications through the ChildProcess API.
Activity
gwleuverink commentedon Feb 12, 2026
Great suggestion! Coincidentally I had a need for this a couple of weeks ago.
Created a little proof of concept and it works. But you'd have to manually convert the ansi codes in order to display in the electron window
I didn't finish it because the focus of our app shifted but I might revisit this in the near future.
I wonder though if resizing events can work as you proposed.
You have to set the number of columns when starting the pty process, handle the output events in the electron window and pass the resize events from the client back to the pty process.
The process itself has no clue where it is rendered so I'm curious to know how that could be possible. Can you elaborate your idea?
simonhamp commentedon May 29, 2026
Even if the resizing stuff isn't handled yet, I think the PTY flag idea is solid and worth a PR
gwleuverink commentedon May 29, 2026
I've gone deep on this subject since my last comment. While this is likely very possible for the boring spawn/read/write/resize surface you'd expect from a PTY, the level of control you need for anything beyond the bare-minimum use-case isn't practical to bridge over Electron's HTTP APIs. And that's before you consider keystroke latency and ANSI throughput, every byte of which has to cross the bridge somehow. IPC or HTTP.
It might sound trivial, but there are a LOT of knobs to fine-tune depending on what you're building: full OSC message handling, foreground-process capture, process-tree observation, orphan reaping/recovery, just to name a few.
In Devkeepr we now have a fully functional shell emulator & supervisor that happily handles dozens of concurrent shells without breaking a sweat, running in PHP without any limitations set by a bridge API. That said, it is a lot of code (expensive), but you can do this right now without bridging anything.
What I mean to say is:
gwleuverink commentedon May 29, 2026
Nice, vague reply without actually mentioning how I did it 😆
Here's a helicopter view of how the PTY plumbing fits together. Hope this helps people that stumble on this thread and need this today (with the huge caveat that I use a PHP build with pcntl (non-windows):
Supervisor. A long-running PHP process (a NativePHP
ChildProcess) that owns the registry of live shells. It exposes a small HTTP/1.1 control plane over a Unix-domain socket, and the rest of the app POSTsspawn/stop/listagainst it.ReactPHP event loop. The supervisor's control socket and every PTY run on the same non-blocking loop. Spawning a shell adds two things to the loop: a ReactPHP child process for the PTY and a loopback HTTP listener for it. That's how a single PHP process can run many concurrent shells.
Per-PTY TerminalServer. Each shell gets its own
TerminalServer. It spawns the actual PTY viascript(1)(so we get a real TTY end-to-end), then listens on a loopback HTTP port: SSE for output, POST for keystrokes and resizes. The xterm.js front-end runs in an iframe and connects straight to that port. Closing the window just drops the SSE connection. The shell keeps running until it exits on its own.croustibat commentedon May 30, 2026
Thanks for the updates here. This maps very closely to what we are hitting in Hive Desktop.
We currently have xterm.js mounted in the NativePHP window, route keystrokes back through Laravel, and forward process output from
ChildProcessevents to the terminal. That works for pipe-friendly commands, but it breaks down for real TUI tools because the spawned process still does not see a TTY.So from our side, even a small first version of this would already be valuable:
pty: trueonChildProcess::start(...)MessageReceivedeventChildProcess::message(...)cols/rowsChildProcess::resize($alias, $cols, $rows)API if feasibleI agree with the point that this will not cover every advanced terminal-supervisor use case. Process tree tracking, orphan recovery, foreground process detection, OSC handling, throughput tuning, etc. are probably beyond what the NativePHP bridge should promise.
But a minimal PTY surface would still unlock a lot of practical use cases: Claude Code TUI, shell sessions, vim, lazygit, htop, REPLs, and xterm.js integrations.
I am happy to try a small PR focused on that minimal surface if maintainers are open to it.
simonhamp commentedon May 30, 2026
Always open to it 👍🏼
gwleuverink commentedon May 30, 2026
Hard agree. This will be a killer feature!
gwleuverink commentedon May 30, 2026
PS If you ever hit any of these limitations in your app i'll be happy to hop on a call and share our code. Its closed source so i cant drop the repo here