Skip to content

Feature Request: PTY support for ChildProcess API #82

Description

@croustibat

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 (claude CLI) - 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

  1. gwleuverink commented on Feb 12, 2026

    @gwleuverink
    Collaborator

    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?

  2. simonhamp commented on May 29, 2026

    @simonhamp
    Member

    Even if the resizing stuff isn't handled yet, I think the PTY flag idea is solid and worth a PR

  3. gwleuverink commented on May 29, 2026

    @gwleuverink
    Collaborator

    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:

    • Great PR for a simple process using xterm
    • Impractical to bridge all the things people might actually want to do with it
  4. gwleuverink commented on May 29, 2026

    @gwleuverink
    Collaborator

    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 POSTs spawn/stop/list against 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 via script(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.

  5. croustibat commented on May 30, 2026

    @croustibat
    Author

    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 ChildProcess events 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: true on ChildProcess::start(...)
    • output bridged through the existing MessageReceived event
    • input through the existing ChildProcess::message(...)
    • optional initial cols / rows
    • a simple ChildProcess::resize($alias, $cols, $rows) API if feasible

    I 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.

  6. simonhamp commented on May 30, 2026

    @simonhamp
    Member

    Always open to it 👍🏼

  7. gwleuverink commented on May 30, 2026

    @gwleuverink
    Collaborator

    Hard agree. This will be a killer feature!

  8. gwleuverink commented on May 30, 2026

    @gwleuverink
    Collaborator

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions