Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,3 @@ Thumbs.db
*.swo

/vendor/
/tapper.sock
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ src/
Support/SpanTruncator.php Clips/windows Span[] to a fixed width for fixed-width panes (Details, LogItem)
docs/ Deeper documentation — see docs/README.md
examples/BasicExample.php Runnable demo of the tp() API
tests/Unit/ ScrollTest.php, ScrollbarRenderTest.php, SpanTruncatorTest.php — only test suites that currently exist
tests/Unit/ Unit tests for pure/isolated logic (Scroll, SpanTruncator, AppState, EventBus, MessageFormatter, PhpHighlighter, Rpc/*, SocketPath, LogPath) — Console/Components, Server, and Application have no tests yet (need the ReactPHP loop/php-tui rendering)
```

## Running things
Expand Down
5 changes: 4 additions & 1 deletion bin/tapper
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ declare(strict_types=1);

require __DIR__.'/../vendor/autoload.php';

$options = getopt('', ['port::']);
$port = isset($options['port']) ? (int) $options['port'] : null;

$app = (require_once __DIR__.'/../src/Console/main.php');

try {
$app->run();
$app->run($port);
} catch (\Throwable $e) {
$app->close();
\Tapper\Console\ErrorHandler::logThrowable($e);
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
}
],
"require": {
"php": "^8.2",
"react/event-loop": "^1.5",
"php-tui/php-tui": "^0.2.1",
"react/socket": "^1.16",
Expand Down
10 changes: 9 additions & 1 deletion docs/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This doc consolidates everything found during a full-codebase review (2026-08-15

Verified with an isolated smoke test (no terminal required): booted `Server` directly against a real `AppState`/`EventBus`, confirmed the socket file appears at `SocketPath::resolve()`, and confirmed `tp()` from a separate process round-trips successfully (`result: ok`) instead of throwing `[Tapper] server not responding.`.

Note: `SocketPath::resolve()` still resolves to "the installed package's own directory" (`realpath(__DIR__.'/..')`), same semantics as the original `Server.php` code — when Tapper is installed as a dependency, that's `vendor/tapperphp/tapper/tapper.sock`, not the consuming project's root. That's fine functionally (both processes resolve the same install, so they agree), but if this ever needs to be more conventional (e.g. `sys_get_temp_dir()`-based, to avoid touching `vendor/`), that's a deliberate follow-up, not part of this fix.
~~Note: `SocketPath::resolve()` still resolves to "the installed package's own directory"~~ — fixed 2026-08-16: `SocketPath::resolve()` and `LogPath::resolve()` now resolve to `sys_get_temp_dir().'/tapper.sock'` / `'/tapper.log'` instead of `realpath(__DIR__.'/..')`. This was flagged as a deliberate follow-up above, and became a real requirement once native packaging entered the picture — a static-php-cli/phpmicro build (`docs/architecture.md` roadmap) embeds `src/` inside a read-only Phar, so a path computed from `__DIR__` would point at a virtual filesystem that can't hold a socket file or an appended-to log file. `sys_get_temp_dir()` is always a real, writable directory regardless of how the app is packaged. Trade-off: this drops the previous per-install isolation (two different projects each requiring `tapperphp/tapper` from their own `vendor/` used to get distinct socket paths for free, since `__DIR__` differed per install) — now every local Tapper session shares `<tmp>/tapper.sock`, so only one debuggee/TUI pair should run at a time per machine. Revisit if that turns out to matter in practice (e.g. namespace the filename by project directory hash).

### ~~`wait()` listeners on `EventBus` are never cleaned up~~ — fixed 2026-08-15

Expand Down Expand Up @@ -45,6 +45,14 @@ Reported case: pressing Space in `LogList` with zero log entries hit `LogList::s

Note: this intentionally does *not* route the "check the logs" notice through `Windows/Popup.php` (a full-screen modal isn't the right shape for a transient 5-second notice); the banner lives in `Header` via two new `AppState` fields (`errorNotice`, `errorNoticeExpiresAt`) instead.

### ~~Blocking: typed class constants (PHP 8.3+ syntax) broke every PHP 8.2 install despite README/CI claiming 8.2 support~~ — fixed 2026-08-16

`private const string FOO = '...';`-style *typed* class constants are a PHP 8.3 feature — on PHP 8.2 the parser throws `ParseError: syntax error, unexpected identifier "FOO", expecting "="` on the file, at include time, regardless of whether the constant is ever read. This pattern was scattered across most of `Console/*` (`Application`, `Component`, `ErrorHandler`, `Palette`, `MessageFormatter`, `PhpHighlighter`, `Components/{Splash,Details,LogItem}`, `Windows/Main`) — i.e. it would have broken `bin/tapper` for any PHP 8.2 user the moment a log rendered (`MessageFormatter`/`Palette` are on the hot render path for every list row).

It went undetected by CI (which does run PHP 8.2 in the matrix) purely because the old, minimal test suite (`Scroll`, `ScrollbarRenderer`, `SpanTruncator`) never autoloaded any of the affected classes — PHP never parsed the offending files under 8.2, so there was nothing to fail on. It surfaced the moment new tests started exercising `MessageFormatter`/`PhpHighlighter` directly. Fixed by dropping the type annotation from every class constant repo-wide (behavior is identical, `const FOO = '...'` works on 8.2–8.4); `composer.json` also had no `"php"` constraint at all, so `composer require`d installs wouldn't have been warned either — added `"php": "^8.2"` to `require` to match what the README already promises.

This is a strong argument for the still-open Console/Components/Server/Application test-coverage gap noted elsewhere in this doc: coverage that's too thin doesn't just miss logic bugs, it can hide the codebase not even parsing on a supported PHP version.

## Incomplete abstractions (finish or remove, don't extend as-is)

- **`Rpc\JsonRpc` interface / `JsonRpcResult` / `JsonRpcError`** — `JsonRpcResult.php` and `JsonRpcError.php` are empty files; `JsonRpc`'s encode/parse methods exist only as commented-out code; `Server.php` builds raw arrays instead of using these types. See `rpc-protocol.md`.
Expand Down
19 changes: 5 additions & 14 deletions docs/rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,12 @@ This documents what is **actually implemented** between a debuggee process (`Run

## Transport

- Unix domain socket, path `<project root>/tapper.sock` (computed by `Server.php` via `realpath(__DIR__.'/..')` from inside the installed package).
Two transports, selected by whether a port is configured — there is no separate mode flag:

- **Unix domain socket (default)**, path `sys_get_temp_dir().'/tapper.sock'`, resolved by `Tapper\SocketPath::resolve()` (`src/SocketPath.php`). Used whenever no port is given: `bin/tapper` without `--port`, and `Runtime\Tapper` when the `TAPPER_PORT` env var is unset/empty.
- **TCP, `127.0.0.1:<port>`** — `bin/tapper --port=2138` binds `Server` there instead of the socket; `TAPPER_PORT=2138` makes `Runtime\Tapper` connect there instead. Both sides must agree on the same port. `AppState::$port` is `null` in socket mode (Header hides the "port: N" segment) and holds the configured port in TCP mode (Header shows it).
- One line of JSON per message, `\n`-terminated, decoded with `clue/ndjson-react` on the server side.
- The client (`Rpc/JsonRpcClient.php`) is a **blocking** `stream_socket_client()` — connect timeout 0.5s by default, then `stream_set_timeout()` + blocking `fgets()` for the reply, using a separate `pauseTimeout` (default 3600s) so a `wait()` call can block for up to an hour waiting for the user to press Enter in the TUI.
- TCP is not currently wired up despite `JsonRpcClient` having `$host`/`$port` constructor properties — those fields are dead; the actual connection target is a hardcoded string, see the bug below.

## Socket path resolution

Both sides resolve the socket path through one shared helper, `Tapper\SocketPath::resolve()` (`src/SocketPath.php`):

```php
$projectPath = realpath(__DIR__.'/..'); // src/ -> package root
return $projectPath.'/tapper.sock';
```

`Server.php` calls it to bind the listener; `Rpc/JsonRpcClient.php` calls it to connect. Because both sides call the same function instead of computing the path independently, they can't diverge. (This previously *did* diverge — `JsonRpcClient` had a hardcoded absolute path to one developer's machine, fixed 2026-08-15; see `known-issues.md` for the history if you're wondering why this indirection exists instead of a literal path.)

## Request shape (client → server)

Expand All @@ -33,7 +24,7 @@ Built by `Rpc\JsonRpcRequest::payload()`:
}
```

- `id` is generated by `uniqid('rpc_', true)` on every call — `JsonRpcRequest` has no way to accept an explicit id (the constructor doesn't take one; `payload()` references an undefined `$id` variable that null-coalesces to always calling `uniqid()` — works, but is dead/misleading code, not an intentional "reuse an id" feature).
- `id` defaults to `uniqid('rpc_', true)` when the caller doesn't supply one — `JsonRpcRequest`'s constructor takes an optional `?string $id`, and `payload()` reads `$this->id ?? uniqid('rpc_', true)`.
- Two methods are implemented server-side: `log` and `wait`.

### `log` params (sent by every plain `tp($value)`)
Expand Down
9 changes: 5 additions & 4 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@

class Application
{
const float RESIZE_RATE = 1 / 4;
const RESIZE_RATE = 1 / 4;

const float RENDER_RATE = 1 / 60;
const RENDER_RATE = 1 / 60;

private Component $window;

Expand All @@ -53,12 +53,13 @@ public function __construct(
private Server $server,
) {}

public function run(): int
public function run(?int $port = null): int
{
ErrorHandler::install($this->appState);

$this->area = $this->phpTermBackend->size();
$this->appState->version = 'v0.1.1';
$this->appState->port = $port;
$this->terminal->execute(Actions::alternateScreenEnable());
$this->terminal->execute(Actions::cursorHide());
$this->terminal->execute(Actions::enableMouseCapture());
Expand All @@ -68,7 +69,7 @@ public function run(): int
$this->init();
$this->startRendering();
$this->startInputHandling();
$this->server->run();
$this->server->run($port);

$this->loop->addSignal(SIGINT, function () {
$this->close();
Expand Down
4 changes: 2 additions & 2 deletions src/Console/Component.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@

abstract class Component
{
const string BEFORE_INIT = 'beforeInit';
const BEFORE_INIT = 'beforeInit';

const string AFTER_INIT = 'afterInit';
const AFTER_INIT = 'afterInit';

protected array $components = [];

Expand Down
6 changes: 3 additions & 3 deletions src/Console/Components/Details.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ class Details extends Component
// 1 row top/bottom. Every width/height used to lay out content has to account for
// that, since BlockRenderer only insets the *rendering*, not values computed here
// beforehand (truncation budgets, paging math).
private const int BORDER_SIZE = 2;
private const BORDER_SIZE = 2;

private const int SCROLLBAR_GUTTER = 1;
private const SCROLLBAR_GUTTER = 1;

private const int H_STEP = 4;
private const H_STEP = 4;

private int $count = 0;

Expand Down
5 changes: 3 additions & 2 deletions src/Console/Components/Header.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ protected function view(Area $area): Widget
Span::fromString(' '),
Span::fromString($label),
$unread ? Span::fromString(sprintf(' (↓%s)', $this->appState->unread))->yellow() : Span::fromString(''),
Span::fromString(' | '),
Span::fromString(sprintf('port: %s', $this->appState->port)),
$this->appState->port !== null
? Span::fromString(sprintf(' | port: %s', $this->appState->port))
: Span::fromString(''),
$this->appState->filter !== '' && ! $this->appState->typingMode
? Span::styled(sprintf(' | filter: %s', $this->appState->filter), Style::default()->fg(RgbColor::fromHex(Palette::ACCENT)))
: Span::fromString(''),
Expand Down
10 changes: 5 additions & 5 deletions src/Console/Components/LogItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@

class LogItem extends Component
{
public const int HEIGHT = 2;
public const HEIGHT = 2;

private const int SCROLLBAR_GUTTER = 1;
private const SCROLLBAR_GUTTER = 1;

private ?LogItemState $log = null;

Expand All @@ -48,14 +48,14 @@ public function mouseMove(array $data): void
/** @var MouseEvent $event */
$event = $data['event'];

if (! $this->log || $this->index === null) {
if (! $this->log || $this->index === null || $this->area === null) {
return;
}

$elementPosInView = ($this->index - $this->appState->offset);
$itemPosition = ($elementPosInView * self::HEIGHT) + 1;
$itemPosition = $this->area->top() + ($elementPosInView * self::HEIGHT);

if ($event->row > $itemPosition
if ($event->row >= $itemPosition
&& $event->row < $itemPosition + self::HEIGHT
) {
$this->click();
Expand Down
38 changes: 29 additions & 9 deletions src/Console/Components/LogList.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
use PhpTui\Term\KeyCode;
use PhpTui\Term\KeyModifiers;
use PhpTui\Term\MouseEventKind;
use PhpTui\Tui\Color\AnsiColor;
use PhpTui\Tui\Display\Area;
use PhpTui\Tui\Extension\Core\Widget\Buffer\BufferContext;
use PhpTui\Tui\Extension\Core\Widget\BufferWidget;
use PhpTui\Tui\Extension\Core\Widget\CompositeWidget;
use PhpTui\Tui\Extension\Core\Widget\GridWidget;
use PhpTui\Tui\Extension\Core\Widget\Scrollbar\ScrollbarOrientation;
use PhpTui\Tui\Extension\Core\Widget\Scrollbar\ScrollbarSymbols;
use PhpTui\Tui\Extension\Core\Widget\ScrollbarWidget;
use PhpTui\Tui\Layout\Constraint;
use PhpTui\Tui\Position\Position;
use PhpTui\Tui\Style\Style;
use PhpTui\Tui\Widget\Direction;
use PhpTui\Tui\Widget\Widget;
use Tapper\Console\CommandAttributes\FirstRender;
Expand Down Expand Up @@ -219,6 +221,29 @@ protected function view(Area $area): Widget
{
$this->fill();

$thumbBounds = Scroll::proportionalThumb($this->count, $this->visible, $this->appState->offset, $area->height);

// Built by hand rather than via php-tui's ScrollbarWidget/ScrollbarRenderer: that
// combo can only size the thumb off ScrollbarState's single contentLength field,
// which is wrong for a short list barely taller than the viewport (see the docblock
// on Scroll::proportionalThumb()). Details.php already draws its scrollbar the same
// way for the same reason.
$scrollbar = BufferWidget::new(function (BufferContext $context) use ($thumbBounds): void {
$trackArea = $context->area;
$x = max(0, $trackArea->right() - 1);
$neutralStyle = Style::default()->fg(AnsiColor::Reset)->bg(AnsiColor::Reset);

for ($y = $trackArea->top(); $y < $trackArea->bottom(); $y++) {
$isThumb = $thumbBounds !== null
&& ($y - $trackArea->top()) >= $thumbBounds[0]
&& ($y - $trackArea->top()) < $thumbBounds[1];

$context->buffer->get(Position::at($x, $y))
->setChar($isThumb ? '█' : ($thumbBounds === null ? ' ' : '│'))
->setStyle($neutralStyle);
}
});

return CompositeWidget::fromWidgets(
GridWidget::default()
->direction(Direction::Vertical)
Expand All @@ -229,12 +254,7 @@ protected function view(Area $area): Widget
$this->listItems
),
),
ScrollbarWidget::default()
->state(Scroll::scrollbarState($this->count, $this->visible, $this->appState->offset))
->orientation(ScrollbarOrientation::VerticalRight)
->symbols(new ScrollbarSymbols('│', '█', '', ''))
->endSymbol(null)
->beginSymbol(null),
$scrollbar,
);
}
}
2 changes: 1 addition & 1 deletion src/Console/Components/Splash.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

class Splash extends Component
{
private const string TAPPER = 'T A P P E R';
private const TAPPER = 'T A P P E R';

protected function view(Area $area): Widget
{
Expand Down
2 changes: 1 addition & 1 deletion src/Console/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/
final class ErrorHandler
{
private const int NOTICE_SECONDS = 5;
private const NOTICE_SECONDS = 5;

public static function install(AppState $appState): void
{
Expand Down
16 changes: 8 additions & 8 deletions src/Console/MessageFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@

class MessageFormatter
{
private const string STRING_COLOR = '9ece6a';
private const STRING_COLOR = '9ece6a';

private const string KEY_COLOR = '73daca';
private const KEY_COLOR = '73daca';

private const string NUMBER_COLOR = 'fd9d63';
private const NUMBER_COLOR = 'fd9d63';

private const string BOOL_COLOR = 'fd9d63';
private const BOOL_COLOR = 'fd9d63';

private const string NULL_COLOR = '2bc3de';
private const NULL_COLOR = '2bc3de';

private const string BRACKETS_COLOR = Palette::TEXT_DEFAULT;
private const BRACKETS_COLOR = Palette::TEXT_DEFAULT;

private const string PUNCTUATION_COLOR = '89ddff';
private const PUNCTUATION_COLOR = '89ddff';

private const string ERROR_COLOR = Palette::ERROR;
private const ERROR_COLOR = Palette::ERROR;

public static function colorizeInlineJson(string $json): array
{
Expand Down
8 changes: 4 additions & 4 deletions src/Console/Palette.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
*/
final class Palette
{
public const string ACCENT = '7aa2f7';
public const ACCENT = '7aa2f7';

public const string TEXT_DEFAULT = 'c0caf5';
public const TEXT_DEFAULT = 'c0caf5';

public const string SELECTION_BG = '2a2e42';
public const SELECTION_BG = '2a2e42';

public const string ERROR = 'f7768e';
public const ERROR = 'f7768e';
}
20 changes: 10 additions & 10 deletions src/Console/PhpHighlighter.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@
*/
final class PhpHighlighter
{
private const string KEYWORD_COLOR = 'bb9af7';
private const KEYWORD_COLOR = 'bb9af7';

private const string VARIABLE_COLOR = '7dcfff';
private const VARIABLE_COLOR = '7dcfff';

private const string STRING_COLOR = '9ece6a';
private const STRING_COLOR = '9ece6a';

private const string NUMBER_COLOR = 'ff9e64';
private const NUMBER_COLOR = 'ff9e64';

private const string COMMENT_COLOR = '565f89';
private const COMMENT_COLOR = '565f89';

private const string FUNCTION_COLOR = '7aa2f7';
private const FUNCTION_COLOR = '7aa2f7';

private const array KEYWORD_TOKENS = [
private const KEYWORD_TOKENS = [
T_IF, T_ELSE, T_ELSEIF, T_ENDIF, T_FOR, T_FOREACH, T_ENDFOR, T_ENDFOREACH,
T_WHILE, T_ENDWHILE, T_DO, T_SWITCH, T_ENDSWITCH, T_CASE, T_DEFAULT, T_BREAK,
T_CONTINUE, T_FUNCTION, T_FN, T_RETURN, T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM,
Expand All @@ -44,14 +44,14 @@ final class PhpHighlighter
T_INSTEADOF,
];

private const array STRING_TOKENS = [
private const STRING_TOKENS = [
T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE, T_START_HEREDOC,
T_END_HEREDOC,
];

private const array COMMENT_TOKENS = [T_COMMENT, T_DOC_COMMENT];
private const COMMENT_TOKENS = [T_COMMENT, T_DOC_COMMENT];

private const array BARE_KEYWORDS = ['true', 'false', 'null', 'self', 'parent'];
private const BARE_KEYWORDS = ['true', 'false', 'null', 'self', 'parent'];

/**
* @return Span[]
Expand Down
4 changes: 2 additions & 2 deletions src/Console/State/AppState.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

/**
* @property string $version
* @property int $port
* @property ?int $port
* @property bool $live
* @property bool $showDot
* @property bool $typingMode
Expand Down Expand Up @@ -41,7 +41,7 @@ class AppState
*/
public function __construct(
private string $version = '',
private int $port = 2137,
private ?int $port = null,
private bool $live = true,
private bool $showDot = true,
private bool $typingMode = false,
Expand Down
Loading
Loading