Date: 2026-03-30 Branch: feature/ghostty-terminal Status: Draft
Add a new terminal mode to the web-terminal package using ghostty-web — a WASM-powered terminal emulator (xterm.js API-compatible drop-in replacement) that provides a full interactive PTY shell experience in the browser. This mode complements the existing "Classic" command-by-command terminal with a real-time, persistent shell session streamed over WebSocket.
Both modes can be enabled simultaneously with an in-header toggle pill for switching between them. Each mode maintains an independent session.
This section documents the reasoning behind each major decision. Kept up to date as the design evolves.
ghostty-web is a WASM-compiled terminal emulator built from the same parser as the native Ghostty desktop terminal. It is designed as a drop-in xterm.js replacement — same API (Terminal, FitAddon, ITerminalOptions, term.buffer, etc.) but with a battle-tested WASM parser instead of a JavaScript reimplementation.
Advantages over xterm.js:
Since the API is xterm.js-compatible, switching to xterm.js in the future would be a one-line import change.
This was initially planned to use Reverb as the primary provider. After analysis, Reverb was dropped because it is architecturally incompatible with PTY streaming:
Reverb is pub/sub, not bidirectional streaming. Reverb implements Laravel Broadcasting — it pushes server events to subscribed clients. Clients cannot send arbitrary data back through a Reverb channel. The client-to-server path in Reverb goes through HTTP (Livewire/AJAX), not the WebSocket.
PTY I/O requires raw bidirectional byte streaming. Every keystroke must travel client→server instantly, and every byte of PTY output must stream server→client in real-time. This is a persistent, bidirectional byte pipe — fundamentally different from pub/sub events.
Latency and overhead. Even if workarounds existed (e.g., sending keystrokes via Livewire HTTP and output via Reverb), the added latency would make the terminal unusable for interactive work (vim, htop, tab completion).
ReactPHP + ratchet/rfc6455 is the correct tool because:
react/socket + react/event-loop) provides raw bidirectional socket connections with an event loopratchet/rfc6455 is the low-level WebSocket protocol parser (RFC 6455 compliant) without Ratchet's HTTP layer or Symfony dependenciescboden/ratchet is incompatible with Symfony 7/8 used by Laravel 12/13)terminal:serve), so it has full Laravel app access for auth, config, and credential retrievalsuggest dependencies (not require), so they only need to be installed when ghostty mode is usedFuture Reverb support: If Laravel Reverb ever adds raw WebSocket channel support (beyond pub/sub), it could be added as a provider behind the existing WebSocketProviderInterface. The interface is designed for this extensibility.
The existing WebTerminal.php is already 944 lines. Adding ghostty mode logic (WebSocket lifecycle, PTY bridge, different rendering) would push it well past 1,200 lines with deeply intertwined concerns. The dual-component approach:
WebTerminal.php is unchanged — zero regression riskGhosttyTerminal is never loadedThe TerminalContainer wrapper is intentionally thin — it only manages the toggle pill via Alpine.js.
Nested Livewire components have known edge cases with state hydration, [@entangle](https://github.com/entangle), and DOM diffing. The toggle pill switches which terminal is visible — this is purely a UI concern that doesn't need server state.
Using Alpine.js x-show means:
wire:key to prevent DOM diffing issuessuggest and not require?Most web-terminal users only need the Classic (Livewire) terminal. Requiring Ratchet for everyone would add an unnecessary dependency. By using suggest:
composer require cboden/ratchet)RuntimeException tells developers what to install if they enable ghostty without RatchetThe initial design used URL::signedRoute() but the Ratchet server runs on a separate port, so Laravel's signed URL validation (which checks the route) doesn't work directly. Instead, we use Laravel's Encrypter to create self-contained tokens:
useGhosttyTerminal Gate?Ghostty mode provides full PTY shell access — no command whitelisting, no sanitization, no rate limiting. In admin panels where some users shouldn't have unrestricted shell access, the Gate provides fine-grained control. If the Gate is not defined, it defaults to allowing authenticated users (same as Classic mode's connect).
When switching between Classic and Ghostty:
The Classic terminal has a fixed CSS height. Ghostty mode uses ghostty-web's FitAddon to auto-resize the terminal to fill its container, and sends resize events ({ type: 'resize', cols, rows }) to the PTY via WebSocket so the shell adjusts its output formatting. This is standard for modern web terminals.
TerminalBuilder (fluent API)
├── ->ghosttyTerminal() # enable ghostty mode
├── ->classicTerminal(false) # disable classic (optional)
├── ->defaultMode(TerminalMode::Ghostty)
├── ->ghosttyTheme([...]) # ghostty-specific theme/options
└── ->render()
├── [only classic] → WebTerminal (existing, unchanged)
├── [only ghostty] → GhosttyTerminal (new)
├── [both disabled] → throws InvalidArgumentException
└── [both enabled] → TerminalContainer
├── Toggle pill (Alpine.js only, no Livewire state)
├── WebTerminal (x-show, wire:key="classic-terminal")
└── GhosttyTerminal (x-show, wire:key="ghostty-terminal")
TerminalContainer — Thin Livewire component wrapping both terminals. The active mode toggle is Alpine.js state only (no [@entangle](https://github.com/entangle), no Livewire property) to avoid hydration issues with nested Livewire components. Each nested component gets a unique wire:key to prevent DOM diffing conflicts.
GhosttyTerminal — New Livewire component:
term.buffer.active)TerminalPtyBridge — Core server-side class for managing PTY sessions over WebSocket. Builds on the existing session-worker.php PTY spawning pattern but designed for persistent WebSocket connections rather than Livewire polling:
proc_open() with PTY descriptors (same pattern as existing session-worker.php lines 82-96, but managed by the WebSocket server process instead of a separate PHP worker)SSH2::getShell() for an interactive shell stream — same credentials from ConnectionConfig{ type: 'resize', cols, rows })Why not Reverb: Laravel Reverb is a pub/sub broadcasting system (server→client events). It is not designed for bidirectional raw byte streaming — clients cannot send arbitrary data to the server through Reverb channels. PTY I/O requires low-latency, bidirectional byte streaming which Reverb cannot provide without significant workarounds.
Primary provider: Ratchet (raw WebSocket)
Standalone PHP WebSocket server via php artisan terminal:serve:
cboden/ratchet (ReactPHP-based) for raw bidirectional WebSocket connections127.0.0.1:8090)Future provider option: If Reverb adds raw WebSocket channel support, it can be added as a provider later behind the same WebSocketProviderInterface.
WebSocketProviderInterface contract:
interface WebSocketProviderInterface
{
public function start(string $host, int $port): void;
public function stop(): void;
public function onConnect(ConnectionInterface $conn, string $token): void;
public function onMessage(ConnectionInterface $conn, string $data): void;
public function onClose(ConnectionInterface $conn): void;
public function sendToConnection(string $sessionId, string $data): void;
}
CORS / Origin: WebSocket connections from the browser to a different port on the same host are not subject to CORS (the Origin header is sent but the WebSocket protocol does not enforce same-origin policy). The Ratchet server will validate the Origin header against configured allowed origins as an extra security layer, defaulting to the app URL from config('app.url').
Route registration:
The Ratchet server runs on its own port — no Laravel HTTP route is needed for the WebSocket connection itself. However, a Laravel route is registered for generating signed connection tokens:
// Registered in WebTerminalServiceProvider::boot()
Route::post('terminal/ws-token', [TerminalWebSocketController::class, 'generateToken'])
->name('terminal.ws-token')
->middleware(['web', 'auth']);
The GhosttyTerminal Livewire component calls this endpoint (or exposes a $wire.getWebSocketUrl() method) to get a signed connection token. The token is then passed as a query parameter to the Ratchet WebSocket URL (ws://127.0.0.1:8090?token=...).
Authentication flow:
app('encrypter').ws://{ratchet_host}:{ratchet_port}?token={signed_token}onOpen(). Since terminal:serve boots the full Laravel application (it's an Artisan command with access to the app container), it can use app('encrypter') for token validation.SSH credential handoff:
For SSH connections, the connection config (host, username, password/key) cannot be embedded in the client token. Instead:
ConnectionConfig in Laravel's cache (Cache::put("terminal-pty:{$sessionId}", $config, $ttl)) with the same TTL as the token.sessionId reference.This ensures credentials never leave the server and are never exposed in URLs or tokens.
Provider detection:
// config/web-terminal.php
'ghostty' => [
'enabled' => false,
'websocket_provider' => 'ratchet', // 'ratchet' (only option currently)
'ratchet_host' => '127.0.0.1',
'ratchet_port' => 8090,
],
Ghostty mode provides unrestricted shell access. Unlike Classic mode which has CommandValidator, CommandSanitizer, and RateLimiter, Ghostty mode opens a full interactive PTY — these layers are intentionally bypassed.
Security controls:
->ghosttyTerminal() explicitly.GhosttyTerminal Livewire component checks a useGhosttyTerminal Gate before rendering. Developers can define this gate to restrict which users get access:
Gate::define('useGhosttyTerminal', function (User $user) {
return $user->is_admin;
});
If the gate is not defined, access defaults to the same check as Classic mode's connect() (authenticated user).->shell('/bin/rbash')) to use a restricted shell if desired.PTY processes spawned by the WebSocket server must be cleaned up in all scenarios:
terminal:serve ensures storage_path('web-terminal/') exists, then scans for orphaned PTY processes by checking a PID registry file (storage_path('web-terminal/pty-sessions.json')). Stale PIDs are killed. This directory is the same storage/web-terminal/ used by the existing FileSessionManager.namespace MWGuerra\WebTerminal\Enums;
enum TerminalMode: string
{
case Classic = 'classic';
case Ghostty = 'ghostty';
}
// Ghostty only
Terminal::make()
->connection(ConnectionType::SSH, [...])
->ghosttyTerminal()
->classicTerminal(false)
->height('500px')
->render();
// Both modes, ghostty default
Terminal::make()
->connection(ConnectionType::Local)
->ghosttyTerminal()
->defaultMode(TerminalMode::Ghostty)
->render();
// Classic only (unchanged, current behavior)
Terminal::make()
->connection(ConnectionType::Local)
->allowedCommands([...])
->render();
// Ghostty with theme
Terminal::make()
->connection(ConnectionType::SSH, [...])
->ghosttyTerminal()
->ghosttyTheme([
'background' => '#1a1b26',
'foreground' => '#a9b1d6',
'fontSize' => 14,
'fontFamily' => 'JetBrains Mono, monospace',
'cursorStyle' => 'bar',
'scrollback' => 5000,
])
->render();
New methods on TerminalBuilder:
ghosttyTerminal(bool $enabled = true) — Enable ghostty modeclassicTerminal(bool $enabled = true) — Enable/disable classic mode (default: true)defaultMode(TerminalMode $mode = TerminalMode::Classic) — Starting mode when both are enabledghosttyTheme(array $theme) — Theme/options passed to ghostty-web's ITerminalOptionsEdge cases:
classicTerminal(false) without ghosttyTerminal()): throws InvalidArgumentException("At least one terminal mode must be enabled")defaultMode() set to a disabled mode: throws InvalidArgumentExceptionghosttyTerminal() without Ratchet installed: throws RuntimeException with install instructionsPlaced in the header bar between the info button and connect/disconnect button. Only visible when both modes are enabled.
rgba(99, 102, 241, 0.3))rgba(168, 85, 247, 0.3))x-show — no Livewire roundtrip, instantx-data="{ activeMode: '{{ $defaultMode }}' }") — no [@entangle](https://github.com/entangle)| Button | Behavior |
|---|---|
| Scripts | Opens dropdown, sends commands to PTY stdin via WebSocket |
| Copy | Extracts full scrollback buffer via ghostty-web's term.buffer.active API |
| Info | Shows info panel overlay positioned absolutely over the canvas |
| Connect/Disconnect | Manages WebSocket lifecycle |
| Feature | Classic Mode | Ghostty Mode |
|---|---|---|
| Connect/Disconnect | Livewire method | WebSocket lifecycle |
| Scripts | Execute via Livewire | Write commands to PTY via WebSocket |
| Copy output | Per-block + copy all | Full terminal buffer copy (no per-block) |
| Info panel | Overlay on output | Overlay on canvas |
| Command history | Livewire-managed | Native shell history (bash) |
| Paste | Multi-line modal | Native ghostty-web paste |
| Interactive controls | Arrow/function key UI | Not needed (native keyboard) |
| ANSI colors | Server-side AnsiToHtml | Native WASM rendering |
| Resize | Fixed CSS height | FitAddon auto-resize + resize events to PTY |
| Inactivity timeout | Livewire-managed | WebSocket ping/pong + server timer |
ghostty-web distributed via NPM + Vite build:
ghostty-web to package.json dependenciesresources/js/ghostty-terminal.js — initializes WASM, creates Terminal, manages WebSocket connectionresources/dist/ghostty-terminal.jspublic/vendor/web-terminal/ directory.Missing dependencies:
ghosttyTerminal() called but cboden/ratchet not installed: throw RuntimeException("Ghostty terminal requires cboden/ratchet. Install it: composer require cboden/ratchet") at render time.WebSocket connection failures:
php artisan terminal:serve is runningSigned URL expiration:
signed_url_ttl)$wire.getWebSocketUrl() call, then reconnectsPTY session reconnection:
PTY cleanup:
beforeunload): WebSocket close sent, immediate PTY cleanupDual-mode edge cases:
keepConnectedOnNavigate), Ghostty starts fresh (WebSocket doesn't survive page reload)New keys in config/web-terminal.php:
'ghostty' => [
'enabled' => false,
'websocket_provider' => 'ratchet',
'ratchet_host' => '127.0.0.1',
'ratchet_port' => 8090,
'pty_grace_period' => 30, // seconds before killing PTY after disconnect
'max_session_lifetime' => 3600, // max PTY session duration (1 hour)
'signed_url_ttl' => 300, // signed URL lifetime in seconds
'them...
How can I help you explore Laravel packages today?