Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Web Terminal Laravel Package

mwguerra/web-terminal

View on GitHub
Deep Wiki
Context7

Ghostty Terminal Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a ghostty-web terminal mode to the web-terminal package with WebSocket PTY streaming, dual-mode toggle, and full feature parity.

Architecture: Dual Livewire component design — GhosttyTerminal (new) alongside WebTerminal (unchanged), wrapped by TerminalContainer when both are enabled. Ratchet WebSocket server bridges ghostty-web to a PTY process. See docs/superpowers/specs/2026-03-30-ghostty-terminal-design.md for full spec.

Tech Stack: PHP 8.2+, Laravel 12/13, Livewire 4, Alpine.js, ghostty-web (npm), cboden/ratchet, phpseclib3

Spec: docs/superpowers/specs/2026-03-30-ghostty-terminal-design.md


File Structure

New Files

File Responsibility
src/Enums/TerminalMode.php Classic/Ghostty enum with labels
src/WebSocket/WebSocketProviderInterface.php Contract for WebSocket providers
src/WebSocket/TerminalPtyBridge.php Manages PTY process lifecycle (local + SSH)
src/WebSocket/PtySessionRegistry.php PID registry for orphan cleanup
src/WebSocket/RatchetProvider.php Ratchet implementation of WebSocket provider
src/WebSocket/RatchetServer.php Ratchet IoServer + WsServer setup
src/Console/Commands/TerminalServeCommand.php php artisan terminal:serve command
src/Http/Controllers/TerminalWebSocketController.php Token generation endpoint
src/Livewire/GhosttyTerminal.php Livewire component for ghostty mode
src/Livewire/TerminalContainer.php Wrapper with toggle pill for dual-mode
resources/views/ghostty-terminal.blade.php Ghostty terminal Blade view
resources/views/terminal-container.blade.php Container Blade view
resources/views/partials/toggle-pill.blade.php Mode switcher partial
resources/js/ghostty-terminal.js ghostty-web init, WebSocket client, FitAddon
tests/Unit/Enums/TerminalModeTest.php Enum tests
tests/Unit/WebSocket/TerminalPtyBridgeTest.php PTY bridge tests
tests/Unit/WebSocket/PtySessionRegistryTest.php PID registry tests
tests/Unit/WebSocket/RatchetProviderTest.php Ratchet provider tests
tests/Unit/Livewire/GhosttyTerminalTest.php Ghostty component tests
tests/Unit/Livewire/TerminalContainerTest.php Container component tests
tests/Unit/Livewire/TerminalBuilderGhosttyTest.php Builder ghostty method tests

Modified Files

File Changes
src/Livewire/TerminalBuilder.php Add ghosttyTerminal(), classicTerminal(), defaultMode(), ghosttyTheme(), update render() and toHtml()
src/WebTerminalServiceProvider.php Register new Livewire components, route, command, conditional JS asset
config/web-terminal.php Add ghostty config section
composer.json Add cboden/ratchet to suggest
package.json Add ghostty-web dependency, add Vite/esbuild build script for JS
resources/css/index.css Add toggle pill and ghostty overlay styles

Task 1: TerminalMode Enum

Files:

  • Create: src/Enums/TerminalMode.php

  • Test: tests/Unit/Enums/TerminalModeTest.php

  • Step 1: Write the failing test

<?php
declare(strict_types=1);

use MWGuerra\WebTerminal\Enums\TerminalMode;

describe('TerminalMode', function () {
    it('has classic and ghostty cases', function () {
        expect(TerminalMode::cases())->toHaveCount(2);
        expect(TerminalMode::Classic->value)->toBe('classic');
        expect(TerminalMode::Ghostty->value)->toBe('ghostty');
    });

    it('returns labels', function () {
        expect(TerminalMode::Classic->label())->toBe('Classic');
        expect(TerminalMode::Ghostty->label())->toBe('Ghostty');
    });

    it('returns descriptions', function () {
        expect(TerminalMode::Classic->description())->toBeString()->not->toBeEmpty();
        expect(TerminalMode::Ghostty->description())->toBeString()->not->toBeEmpty();
    });

    it('provides options array', function () {
        $options = TerminalMode::options();
        expect($options)->toBeArray()->toHaveCount(2);
        expect($options['classic'])->toBe('Classic');
        expect($options['ghostty'])->toBe('Ghostty');
    });
});
  • Step 2: Run test to verify it fails

Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Enums/TerminalModeTest.php Expected: FAIL — class not found

  • Step 3: Write the enum

Create src/Enums/TerminalMode.php following the exact pattern of src/Enums/ConnectionType.php:

<?php
declare(strict_types=1);

namespace MWGuerra\WebTerminal\Enums;

enum TerminalMode: string
{
    case Classic = 'classic';
    case Ghostty = 'ghostty';

    public function label(): string
    {
        return match ($this) {
            self::Classic => 'Classic',
            self::Ghostty => 'Ghostty',
        };
    }

    public function description(): string
    {
        return match ($this) {
            self::Classic => 'Command-by-command terminal via Livewire',
            self::Ghostty => 'Full interactive PTY terminal via WebSocket',
        };
    }

    public static function options(): array
    {
        return array_combine(
            array_column(self::cases(), 'value'),
            array_map(fn (self $case) => $case->label(), self::cases())
        );
    }
}
  • Step 4: Run test to verify it passes

Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Enums/TerminalModeTest.php Expected: PASS (all 4 tests)

  • Step 5: Commit
git add src/Enums/TerminalMode.php tests/Unit/Enums/TerminalModeTest.php
git commit -m "feat: add TerminalMode enum (Classic/Ghostty)"

Task 2: Extend TerminalBuilder with Ghostty Methods

Files:

  • Modify: src/Livewire/TerminalBuilder.php
  • Test: tests/Unit/Livewire/TerminalBuilderGhosttyTest.php

Reference: Read the existing TerminalBuilder.php and tests/Unit/Livewire/TerminalBuilderTest.php for patterns.

  • Step 1: Write the failing tests

Create tests/Unit/Livewire/TerminalBuilderGhosttyTest.php:

<?php
declare(strict_types=1);

use MWGuerra\WebTerminal\Enums\TerminalMode;
use MWGuerra\WebTerminal\Livewire\TerminalBuilder;

describe('TerminalBuilder Ghostty Methods', function () {
    describe('ghosttyTerminal()', function () {
        it('enables ghostty mode', function () {
            $builder = new TerminalBuilder;
            $builder->local()->ghosttyTerminal();
            $params = $builder->getParameters();
            expect($params['ghosttyEnabled'])->toBeTrue();
        });

        it('disables ghostty mode explicitly', function () {
            $builder = new TerminalBuilder;
            $builder->local()->ghosttyTerminal(false);
            $params = $builder->getParameters();
            expect($params['ghosttyEnabled'])->toBeFalse();
        });

        it('is disabled by default', function () {
            $builder = new TerminalBuilder;
            $builder->local();
            $params = $builder->getParameters();
            expect($params['ghosttyEnabled'])->toBeFalse();
        });
    });

    describe('classicTerminal()', function () {
        it('is enabled by default', function () {
            $builder = new TerminalBuilder;
            $builder->local();
            $params = $builder->getParameters();
            expect($params['classicEnabled'])->toBeTrue();
        });

        it('can be disabled', function () {
            $builder = new TerminalBuilder;
            $builder->local()->ghosttyTerminal()->classicTerminal(false);
            $params = $builder->getParameters();
            expect($params['classicEnabled'])->toBeFalse();
        });
    });

    describe('defaultMode()', function () {
        it('defaults to classic', function () {
            $builder = new TerminalBuilder;
            $builder->local()->ghosttyTerminal();
            $params = $builder->getParameters();
            expect($params['defaultMode'])->toBe('classic');
        });

        it('can be set to ghostty', function () {
            $builder = new TerminalBuilder;
            $builder->local()->ghosttyTerminal()->defaultMode(TerminalMode::Ghostty);
            $params = $builder->getParameters();
            expect($params['defaultMode'])->toBe('ghostty');
        });
    });

    describe('ghosttyTheme()', function () {
        it('stores theme options', function () {
            $theme = ['background' => '#1a1b26', 'fontSize' => 14];
            $builder = new TerminalBuilder;
            $builder->local()->ghosttyTerminal()->ghosttyTheme($theme);
            $params = $builder->getParameters();
            expect($params['ghosttyTheme'])->toBe($theme);
        });

        it('defaults to empty array', function () {
            $builder = new TerminalBuilder;
            $builder->local()->ghosttyTerminal();
            $params = $builder->getParameters();
            expect($params['ghosttyTheme'])->toBe([]);
        });
    });

    describe('validation', function () {
        it('throws when both modes disabled', function () {
            $builder = new TerminalBuilder;
            $builder->local()->classicTerminal(false);
            expect(fn () => $builder->render())->toThrow(\InvalidArgumentException::class, 'At least one terminal mode must be enabled');
        });

        it('throws when defaultMode set to disabled mode', function () {
            $builder = new TerminalBuilder;
            $builder->local()->defaultMode(TerminalMode::Ghostty);
            expect(fn () => $builder->render())->toThrow(\InvalidArgumentException::class);
        });
    });
});
  • Step 2: Run test to verify it fails

Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Livewire/TerminalBuilderGhosttyTest.php Expected: FAIL — methods not found

  • Step 3: Add ghostty properties and methods to TerminalBuilder

Open src/Livewire/TerminalBuilder.php. Add new properties alongside existing ones:

protected bool $ghosttyEnabled = false;
protected bool $classicEnabled = true;
protected TerminalMode $defaultMode = TerminalMode::Classic;
protected array $ghosttyTheme = [];

Add the fluent methods (following existing patterns like allowAllCommands()):

public function ghosttyTerminal(bool $enabled = true): static
{
    $this->ghosttyEnabled = $enabled;
    return $this;
}

public function classicTerminal(bool $enabled = true): static
{
    $this->classicEnabled = $enabled;
    return $this;
}

public function defaultMode(TerminalMode $mode = TerminalMode::Classic): static
{
    $this->defaultMode = $mode;
    return $this;
}

public function ghosttyTheme(array $theme): static
{
    $this->ghosttyTheme = $theme;
    return $this;
}

Add the new keys to getParameters(). Important: Only include ghostty-related params when ghostty is actually enabled, to avoid passing unexpected properties to the classic WebTerminal component:

// In getParameters(), add conditionally:
if ($this->ghosttyEnabled) {
    $params['ghosttyEnabled'] = true;
    $params['ghosttyTheme'] = $this->ghosttyTheme;
}
if (! $this->classicEnabled) {
    $params['classicEnabled'] = false;
}
$params['defaultMode'] = $this->defaultMode->value;

Add validation to render() before the existing rendering logic:

if (! $this->classicEnabled && ! $this->ghosttyEnabled) {
    throw new \InvalidArgumentException('At least one terminal mode must be enabled');
}

if ($this->defaultMode === TerminalMode::Ghostty && ! $this->ghosttyEnabled) {
    throw new \InvalidArgumentException('Cannot set default mode to Ghostty when Ghostty is disabled');
}

if ($this->defaultMode === TerminalMode::Classic && ! $this->classicEnabled) {
    throw new \InvalidArgumentException('Cannot set default mode to Classic when Classic is disabled');
}

Important: Don't change the actual rendering logic yet (Task 8 handles routing to the correct component). Just add the properties, methods, validation, and parameter output.

  • Step 4: Run test to verify it passes

Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Livewire/TerminalBuilderGhosttyTest.php Expected: PASS (all tests)

  • Step 5: Run existing TerminalBuilder tests to ensure no regressions

Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/Livewire/TerminalBuilderTest.php Expected: PASS (all existing tests still pass)

  • Step 6: Commit
git add src/Livewire/TerminalBuilder.php tests/Unit/Livewire/TerminalBuilderGhosttyTest.php
git commit -m "feat: add ghostty fluent methods to TerminalBuilder"

Task 3: Configuration and Dependencies

Files:

  • Modify: config/web-terminal.php

  • Modify: composer.json

  • Modify: package.json

  • Step 1: Add ghostty config section

Open config/web-terminal.php. Add at the end, before the closing ];:

/*
|--------------------------------------------------------------------------
| Ghostty Terminal Mode
|--------------------------------------------------------------------------
|
| Configuration for the ghostty-web terminal mode. This mode provides a
| full interactive PTY shell via WebSocket, using the ghostty-web WASM
| terminal emulator. Requires cboden/ratchet to be installed.
|
| See: docs/superpowers/specs/2026-03-30-ghostty-terminal-design.md
|
*/

'ghostty' => [
    'enabled' => env('WEB_TERMINAL_GHOSTTY_ENABLED', false),
    'websocket_provider' => 'ratchet',
    'ratchet_host' => env('WEB_TERMINAL_RATCHET_HOST', '127.0.0.1'),
    'ratchet_port' => env('WEB_TERMINAL_RATCHET_PORT', 8090),
    'pty_grace_period' => 30,
    'max_session_lifetime' => 3600,
    'signed_url_ttl' => 300,
    'allowed_origins' => [env('APP_URL', 'http://localhost')],
    'theme' => [
        'background' => '#1a1b26',
        'foreground' => '#a9b1d6',
        'fontSize' => 14,
    ],
],
  • Step 2: Add ratchet to composer.json suggest

In composer.json, add to the suggest section:

"suggest": {
    "filament/filament": "Required for Filament admin panel integration (^5.0)",
    "cboden/ratchet": "Required for Ghostty terminal mode - WebSocket PTY bridge (^0.4)"
}
  • Step 3: Add ghostty-web to package.json

Update package.json to add ghostty-web and a JS build script:

{
    "private": true,
    "scripts": {
        "build": "npx [@tailwindcss](https://github.com/tailwindcss)/cli -i resources/css/index.css -o resources/dist/web-terminal.css --minify",
        "build:js": "npx esbuild resources/js/ghostty-terminal.js --bundle --minify --format=iife --outfile=resources/dist/ghostty-terminal.js",
        "build:all": "npm run build && npm run build:js"
    },
    "dependencies": {
        "ghostty-web": "^0.4.0"
    },
    "devDependencies": {
        "[@tailwindcss](https://github.com/tailwindcss)/cli": "^4.1.0",
        "esbuild": "^0.25.0",
        "tailwindcss": "^4.1.0"
    }
}
  • Step 4: Install npm dependencies

Run: cd /home/guerra/projects/web-terminal && npm install Expected: ghostty-web and esbuild installed successfully

  • Step 5: Commit
git add config/web-terminal.php composer.json package.json package-lock.json
git commit -m "feat: add ghostty config, ratchet suggest dep, ghostty-web npm dep"

Task 4: PtySessionRegistry (PID tracking for orphan cleanup)

Files:

  • Create: src/WebSocket/PtySessionRegistry.php

  • Test: tests/Unit/WebSocket/PtySessionRegistryTest.php

  • Step 1: Write the failing tests

<?php
declare(strict_types=1);

use MWGuerra\WebTerminal\WebSocket\PtySessionRegistry;

beforeEach(function () {
    $this->registryPath = sys_get_temp_dir() . '/web-terminal-test-' . uniqid() . '/pty-sessions.json';
    $this->registry = new PtySessionRegistry(dirname($this->registryPath));
});

afterEach(function () {
    if (file_exists($this->registryPath)) {
        unlink($this->registryPath);
    }
    $dir = dirname($this->registryPath);
    if (is_dir($dir)) {
        rmdir($dir);
    }
});

describe('PtySessionRegistry', function () {
    it('registers a session with PID', function () {
        $this->registry->register('session-1', 12345, 1);
        $sessions = $this->registry->all();
        expect($sessions)->toHaveKey('session-1');
        expect($sessions['session-1']['pid'])->toBe(12345);
        expect($sessions['session-1']['userId'])->toBe(1);
    });

    it('unregisters a session', function () {
        $this->registry->register('session-1', 12345, 1);
        $this->registry->unregister('session-1');
        expect($this->registry->all())->not->toHaveKey('session-1');
    });

    it('finds a session by ID', function () {
        $this->registry->register('session-1', 12345, 1);
        $session = $this->registry->find('session-1');
        expect($session)->not->toBeNull();
        expect($session['pid'])->toBe(12345);
    });

    it('returns null for unknown session', function () {
        expect($this->registry->find('nonexistent'))->toBeNull();
    });

    it('creates directory if it does not exist', function () {
        $dir = dirname($this->registryPath);
        expect(is_dir($dir))->toBeFalse();
        $this->registry->register('session-1', 12345, 1);
        expect(is_dir($dir))->toBeTrue();
    });

    it('records created_at timestamp', function () {
        $before = time();
        $this->registry->register('session-1', 12345, 1);
        $session = $this->registry->find('session-1');
        expect($session['createdAt'])->toBeGreaterThanOrEqual($before);
    });

    it('cleans up stale sessions', function () {
        // Register with a past timestamp by writing directly
        $dir = dirname($this->registryPath);
        if (! is_dir($dir)) { mkdir($dir, 0755, true); }
        file_put_contents($this->registryPath, json_encode([
            'old-session' => ['pid' => 99999, 'userId' => 1, 'createdAt' => time() - 7200],
            'new-session' => ['pid' => 88888, 'userId' => 1, 'createdAt' => time()],
        ]));

        $stale = $this->registry->cleanupStale(3600); // 1 hour max
        expect($stale)->toHaveKey('old-session');
        expect($stale)->not->toHaveKey('new-session');
        expect($this->registry->find('old-session'))->toBeNull();
        expect($this->registry->find('new-session'))->not->toBeNull();
    });
});
  • Step 2: Run test to verify it fails

Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/WebSocket/PtySessionRegistryTest.php Expected: FAIL — class not found

  • Step 3: Implement PtySessionRegistry

Create src/WebSocket/PtySessionRegistry.php:

<?php
declare(strict_types=1);

namespace MWGuerra\WebTerminal\WebSocket;

class PtySessionRegistry
{
    private string $registryPath;

    public function __construct(string $storagePath)
    {
        $this->registryPath = rtrim($storagePath, '/') . '/pty-sessions.json';
    }

    public function register(string $sessionId, int $pid, int $userId): void
    {
        $sessions = $this->all();
        $sessions[$sessionId] = [
            'pid' => $pid,
            'userId' => $userId,
            'createdAt' => time(),
        ];
        $this->save($sessions);
    }

    public function unregister(string $sessionId): void
    {
        $sessions = $this->all();
        unset($sessions[$sessionId]);
        $this->save($sessions);
    }

    public function find(string $sessionId): ?array
    {
        return $this->all()[$sessionId] ?? null;
    }

    public function all(): array
    {
        if (! file_exists($this->registryPath)) {
            return [];
        }

        $content = file_get_contents($this->registryPath);
        if ($content === false || $content === '') {
            return [];
        }

        return json_decode($content, true) ?? [];
    }

    public function cleanupStale(int $maxLifetimeSeconds): array
    {
        $sessions = $this->all();
        $stale = [];
        $now = time();

        foreach ($sessions as $sessionId => $session) {
            if ($now - $session['createdAt'] > $maxLifetimeSeconds) {
                $stale[$sessionId] = $session;
                unset($sessions[$sessionId]);
            }
        }

        $this->save($sessions);

        return $stale;
    }

    private function save(array $sessions): void
    {
        $dir = dirname($this->registryPath);
        if (! is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        file_put_contents(
            $this->registryPath,
            json_encode($sessions, JSON_PRETTY_PRINT),
            LOCK_EX
        );
    }
}
  • Step 4: Run test to verify it passes

Run: cd /home/guerra/projects/web-terminal && vendor/bin/pest tests/Unit/WebSocket/PtySessionRegistryTest.php Expected: PASS (all 6 tests)

  • Step 5: Commit
git add src/WebSocket/PtySessionRegistry.php tests/Unit/WebSocket/PtySessionRegistryTest.php
git commit -m "feat: add PtySessionRegistry for orphan process cleanup"

Task 5: WebSocketProviderInterface and TerminalPtyBridge

Files:

  • Create: src/WebSocket/WebSocketProviderInterface.php

  • Create: src/WebSocket/TerminalPtyBridge.php

  • Test: tests/Unit/WebSocket/TerminalPtyBridgeTest.php

  • Step 1: Create the WebSocketProviderInterface

<?php
declare(strict_types=1);

namespace MWGuerra\WebTerminal\WebSocket;

interface WebSocketProviderInterface
{
    public function start(string $host, int $port): void;

    public function stop(): void;

    public function sendToConnection(string $sessionId, string $data): void;
}

The sendToConnection method is the key extensibility point — it...

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
nexmo/api-specification
capell-app/block-library
axium/identity
cetria/laravel-dummy-models
cetria/reflection-helper
agropredict/sso-auth-bundle
evolvestudio/spam-protection
datacore/hub-sdk
develia/commons
cuci/prototurk-sdk
cuci/prototurk-sdk-symfony
develia/geo-bundle
dreamzy/livewire-charts
touchestate-sdk/php-sdk
ecotone/kafka
22h/doctrine-garbage-collection-bundle
agtp/agtp-php
agtp/mod-php
splash/sonata-admin
splash/metadata