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: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ Thumbs.db
*.swo

/vendor/
/tapper.sock
95 changes: 95 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# AGENTS.md

Guidance for AI coding agents (and humans) working in this repository. Read this before making changes. Deeper topics are split into `docs/` — see the index at [`docs/README.md`](docs/README.md).

## What this project is

Tapper is a PHP debugging tool in the shape of `Ray`/`Clockwork`: you call `tp($value)` anywhere in a PHP application, and the payload shows up live in a terminal UI (TUI) running as a separate process.

**Two things are true about this repo at once, and both should shape how you work in it:**

1. It is a real, shippable debugger with its own README and CI.
2. It is deliberately being used as a **research vehicle**. The longer-term goal (not yet started) is a general PHP desktop-app framework with a swappable rendering backend — this TUI today, a GLFW-based GUI later. Tapper exists to discover, under real pressure, what that framework's abstractions should be, before anything is extracted into a separate package.

Decision from 2026-08-15: **do not extract a framework yet.** Keep building Tapper as an application first. Extracting now, from a single example, would mean guessing at the TUI/GLFW boundary rather than discovering it — see [`docs/known-issues.md`](docs/known-issues.md#framework-extraction-readiness) for what "ready to extract" will look like and why it isn't yet (the widget/layout layer is currently *not* abstracted — components build `php-tui` widgets directly).

## Repository layout

```
bin/tapper CLI entrypoint — boots the DI container and runs the TUI Application
src/
helpers.php Global tp() function (the public API surface for debuggee apps)
Runtime/Tapper.php Client side: collects debug info, sends it over the wire, blocks for the reply
Rpc/ Minimal JSON-RPC-ish request/response types + blocking socket client
Server.php TUI-process side: unix socket server, decodes requests, mutates AppState
Console/
main.php DI container wiring (php-di) for the TUI process
Application.php Owns the ReactPHP event loop: render timer, resize timer, input handling
Component.php Base class for every UI component (see docs/console-framework.md)
EventBus.php Pub/sub used for key/mouse/custom events
CommandInvoker.php Thin wrapper around php-di's Invoker (see Commands note below)
MessageFormatter.php JSON syntax highlighting for log payloads
CommandAttributes/ #[KeyPressed] #[Mouse] #[OnEvent] #[Periodic] #[FirstRender]
Commands/Command.php Abstract marker class — currently has zero implementations (see known-issues.md)
Components/ Header, LogList, LogItem, Details, Navigation, Splash
Windows/ Main (root layout), Popup (stub, unused)
State/AppState.php Central observable state store (magic __get/__set)
State/LogItem.php Value object for a single log entry
Support/Scroll.php Cursor/offset scrolling math — the best-tested module in the repo
docs/ Deeper documentation — see docs/README.md
examples/BasicExample.php Runnable demo of the tp() API
tests/Unit/ScrollTest.php Only test suite that currently exists
```

## Running things

```bash
composer install

# Start the TUI (in one terminal)
php bin/tapper

# In another terminal/process, run something that calls tp()
php examples/BasicExample.php
```

There is no `composer.json` `bin` alias beyond `bin/tapper`; it is run directly with `php`.

### Tests & lint

```bash
composer test:unit # pest --compact
composer test:lint # pint --test (check only, does not auto-fix)
vendor/bin/pint # auto-fix style
```

CI (`.github/workflows/tests.yml`, `static.yml`) runs `test:unit` on PHP 8.2/8.3/8.4 × ubuntu/macos, and `test:lint` on PHP 8.3. There is no static analysis tool (phpstan/psalm) configured despite the workflow being named "Static Analysis" — it currently only runs Pint (code style), not type checking.

## Key architectural facts an agent should know before editing

- **Two processes, two concurrency models.** The debuggee (anything calling `tp()`) runs normal synchronous PHP and blocks on a socket round-trip in `Runtime/Tapper.php::send()`. The TUI process (`bin/tapper`) is fully async on a ReactPHP event loop. Never assume `tp()` callers can be non-blocking, and never introduce blocking I/O inside `Console/*` — it runs on the loop.
- **State flows one way into the TUI:** `Server.php` decodes a socket message → builds a `State\LogItem` → calls `AppState::appendLog()` → `AppState` notifies its `onChange` callback → `Application` sets `shouldDraw = true` → next 1/60s tick redraws. Components read `AppState` directly in `view()`; they do not receive props from parents in a React sense — see `docs/console-framework.md`.
- **`AppState` uses magic `__get`/`__set`.** Every field must exist as a constructor-promoted property *and* be documented in the `@property` docblock at the top of the class, or IDE/static tooling won't see it. `observe($name, $cb)` validates `$name` against `get_class_vars()` at runtime, not compile time.
- **There is a known, unresolved bug in `AppState` batching** (`deffer()`/`commit()`) — see the `@TODO` comment in `src/Console/State/AppState.php`. Don't build new features on top of `deffer()`/`commit()` without checking whether it still needs fixing.
- **Components declare behavior via PHP 8 attributes**, wired up by reflection in `Component::__construct` (`#[KeyPressed]`, `#[Mouse]`, `#[OnEvent]`, `#[Periodic]`, `#[FirstRender]`). See `docs/console-framework.md` for exact semantics (especially `global: true`, which is easy to get wrong).
- **Rendering is not backend-agnostic.** `Component::view()` returns a concrete `PhpTui\Tui\Widget\Widget`, and every component uses `php-tui` layout primitives (`Area`, `Constraint`, `Direction`, `Layout`) directly. If you are working toward the GLFW goal, do not scatter more `php-tui`-specific calls into component logic without first reading `docs/known-issues.md#framework-extraction-readiness` — the abstraction boundary that would make backends swappable does not exist yet, and it's the single biggest gap.
- **The RPC/transport layer is otherwise unfinished** (see below), though the socket-path bug is fixed: both `Server.php` and `Rpc/JsonRpcClient.php` now resolve the unix-socket path through the shared `Tapper\SocketPath::resolve()` (`src/SocketPath.php`) instead of computing it independently. See `docs/known-issues.md` before touching `Rpc/*`.
- **`docs/openrpc.json` is stale.** It documents an `appendLog` method with a nested `details` param; the actual server (`Server.php`) implements `log` and `wait` methods with flat params. Don't treat `openrpc.json` as ground truth for the wire format — see `docs/rpc-protocol.md` for what's actually implemented.
- **`Commands/Command.php` and `CommandInvoker` have no concrete usages anywhere in the codebase.** Treat this as unfinished scaffolding, not an established pattern to extend — confirm with the project owner before building on it.

## Conventions to follow when editing

- PSR-4 autoload root: `Tapper\` → `src/`. Namespaces mirror directory structure exactly.
- `declare(strict_types=1);` at the top of every PHP file — keep this on new files.
- Style is enforced by Laravel Pint (`vendor/bin/pint`) — run it before finishing a change; don't hand-format.
- No PHPDoc/comment blocks explaining *what* code does — only ones explaining non-obvious *why* (this mirrors the project owner's general preference, not something specific to this file).
- Prefer extending the existing attribute/`EventBus` pattern over inventing a new event mechanism for TUI interactions.
- New `AppState` fields: add to the constructor-promoted property list **and** the `@property` docblock in the same change — they're both authoritative and must stay in sync.

## Where to look next

- [`docs/README.md`](docs/README.md) — documentation index.
- [`docs/architecture.md`](docs/architecture.md) — full data-flow walkthrough, process boundaries.
- [`docs/console-framework.md`](docs/console-framework.md) — Component/EventBus/AppState mechanics in detail.
- [`docs/rpc-protocol.md`](docs/rpc-protocol.md) — the actual wire protocol between debuggee and TUI.
- [`docs/known-issues.md`](docs/known-issues.md) — bugs, gaps, and the framework-extraction roadmap.
21 changes: 21 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Tapper documentation index

This is the documentation set for the Tapper codebase, written for both humans and AI agents working in this repo. Start with [`../AGENTS.md`](../AGENTS.md) for the condensed orientation (commands, layout, must-know gotchas); come here for depth on a specific subsystem.

| Doc | Covers |
|---|---|
| [`architecture.md`](architecture.md) | The two-process design, full request/render data flow, event loop structure |
| [`console-framework.md`](console-framework.md) | The TUI's component model: `Component`, attribute-based event binding, `EventBus`, `AppState` reactivity |
| [`rpc-protocol.md`](rpc-protocol.md) | The actual wire protocol between a debuggee process and the TUI process (and where `openrpc.json` disagrees with it) |
| [`known-issues.md`](known-issues.md) | Known bugs, incomplete abstractions, and what "ready to extract a framework" would require |

## Reading order

- **New to the codebase, want the big picture:** `architecture.md`, then `console-framework.md`.
- **About to touch anything under `Console/`:** `console-framework.md` first — the attribute/event/state wiring has non-obvious rules.
- **About to touch `Rpc/`, `Server.php`, or `Runtime/Tapper.php`:** `rpc-protocol.md` first — there's a live bug in the client's socket path.
- **Planning ahead toward the GLFW/desktop-framework goal:** `known-issues.md#framework-extraction-readiness`.

## Source of truth

Code is the source of truth; these docs describe the state of the repo as of 2026-08-15 and should be updated in the same change that changes the behavior they describe. If a doc and the code disagree, trust the code and fix the doc.
93 changes: 93 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Architecture

## Two processes

Tapper is always at least two OS processes talking over a local unix socket:

1. **The debuggee** — any ordinary PHP script/app that calls the global `tp()` helper (`src/helpers.php`). Fully synchronous. One `tp()` call = one blocking socket round-trip.
2. **The TUI** — `bin/tapper`, a long-running process built on the ReactPHP event loop, rendering with `php-tui`. Fully async; nothing in this process should ever block.

There can be many debuggee processes talking to one running TUI. The TUI process owns the socket file and is the server; every debuggee is a client, despite "server" (`src/Server.php`) living in the same package as the "client" (`src/Rpc/JsonRpcClient.php`) — don't let the class names imply which process they run in without checking.

```mermaid
sequenceDiagram
participant App as Debuggee (sync PHP)
participant Tapper as Runtime\Tapper
participant Sock as unix socket (tapper.sock)
participant Server as Server.php (TUI process)
participant State as AppState
participant Loop as ReactPHP loop
participant TUI as php-tui Display

App->>Tapper: tp($value)
Note over Tapper: collects caller, backtrace,<br/>code excerpt, microtime
App->>Tapper: (end of statement -> __destruct)
Tapper->>Sock: write JSON-RPC-ish request, blocks on fgets()
Sock->>Server: decoded NDJSON message
Server->>State: appendLog(new LogItem(...))
State-->>Loop: onChange callback fires
Server-->>Sock: write {"result":"ok"}
Sock-->>Tapper: response
Tapper-->>App: unblocks, statement completes
Loop->>Loop: next 1/60s render tick sees shouldDraw=true
Loop->>TUI: Application::draw() -> Display::draw(widgets)
```

## Inside the TUI process

### Boot (`bin/tapper` → `src/Console/main.php` → `Application::run()`)

`main.php` builds a `php-di` `ContainerBuilder` with autowiring and attribute support enabled, registers a handful of framework objects (`Terminal`, `PhpTermBackend`, `Display`, ReactPHP `LoopInterface`) as factories, and resolves `Application` out of the container. Every other class in `Console/*` is constructor-injected — there is no manual wiring beyond `main.php`.

`Application::run()`:
1. Puts the terminal into raw/alternate-screen/mouse-capture mode.
2. Builds the component tree (`Main`, `Popup`) via the container.
3. Registers two `ReactPHP` periodic timers: a resize poll (`RESIZE_RATE = 1/4`s) and a render tick (`RENDER_RATE = 1/60`s, gated by a `shouldDraw` dirty flag).
4. Wires stdin into the event parser and dispatches parsed key/mouse events onto `EventBus`.
5. Starts `Server::run()` (opens the unix socket listener) and then calls `$this->loop->run()`, which blocks until the loop is stopped.

**Known wart:** `Application::run()` registers `SIGINT`/`SIGTERM` handlers *after* `$this->loop->run()` — since `loop->run()` blocks, those two `addSignal` calls never execute until the loop has already stopped, making them dead code. See `known-issues.md`.

### Render loop

Rendering is pull-based and coarse-grained: nothing re-renders incrementally per component. Every render tick (when dirty), `Application::draw()` calls `$this->window->render($area)` on the root `Main` component, which recursively calls `render()`/`view()` on its children and returns one big composed `Widget` tree, handed to `php-tui`'s `Display::draw()`. There is no diffing — `php-tui` and the terminal backend handle only drawing the frame, not minimizing writes at the Tapper level.

The dirty flag (`shouldDraw`) is set from exactly two places: `AppState::setOnChange()` (any state mutation) and the resize-poll timer noticing the terminal size changed.

### Input handling

Raw stdin bytes are parsed by `php-tui/term`'s `EventParser` into typed events (`CharKeyEvent`, `CodedKeyEvent`, `MouseEvent`, ...). `Application::handleEvent()` forwards the supported event types onto `EventBus::emit()`. A second path, `handleEventInTypingMode()`, exists for a not-yet-fully-used "typing mode" on `AppState` that redirects character input differently (used for the pause/`wait()` continue flow scaffolding — not deeply exercised yet).

See [`console-framework.md`](console-framework.md) for how components subscribe to these events.

## Outside the TUI process

### `tp()` client path

`src/helpers.php` exposes the global `tp($value)`, which constructs `Runtime\Tapper` and calls `->tap($value)`. `Runtime\Tapper::__construct()` immediately walks `debug_backtrace()` to capture caller file/line and a small source-code excerpt around that line (`getCodeExcerpt()`), and resolves the project root via the Composer `ClassLoader`'s own file location (`findProjectRoot()` — brittle if Composer's install layout changes, but currently the only signal available).

The actual network send happens in `__destruct()`, not in `tap()` — this means a `tp($value)` call sends *after* the full statement completes (e.g. `tp('x')->wait()` sends once, when the temporary `Tapper` object is destroyed at the end of the statement). `wait()` just changes `$this->type` from `'log'` to `'wait'` before send.

### Transport (`Rpc/*`)

`Runtime\Tapper::send()` builds a `Rpc\JsonRpcRequest` (method + params) and calls `JsonRpcClient::call()`, which opens a **blocking** `stream_socket_client()` connection, writes one line of JSON, and blocks on `fgets()` for the reply (up to `pauseTimeout`, 3600s by default — long enough to support `wait()` pausing indefinitely for a user to press Enter in the TUI). See [`rpc-protocol.md`](rpc-protocol.md) for the wire format and a live bug in this path.

### Server (`Server.php`, runs inside the TUI process)

Listens on a unix socket (`SocketServer('unix://' . $socketPath)`), decodes newline-delimited JSON (`clue/ndjson-react`) per connection, and switches on `method`:
- `log` → builds a `State\LogItem` and calls `AppState::appendLog()`, replies `{"result":"ok"}`.
- `wait` → builds a `State\LogItem` with a "paused" message, then instead of replying immediately, registers a **one-shot-ish** `EventBus::listen(KeyCode::Enter, ...)` closure that writes the reply whenever Enter is next pressed anywhere in the TUI. (Note: this listener is never removed — see `known-issues.md` for the implication if multiple `wait()` calls are in flight.)
- anything else → JSON-RPC style `-32601` "method not found" error.

## Directory-to-responsibility map

| Path | Responsibility |
|---|---|
| `src/helpers.php`, `src/Runtime/` | Public API surface used by debuggee apps (`tp()`) |
| `src/Rpc/` | Wire types + blocking client used by `Runtime\Tapper` |
| `src/Server.php` | TUI-side socket listener, the only writer to `AppState` from outside `Console/` |
| `src/Console/State/` | The single source of UI truth (`AppState`) and its value objects |
| `src/Console/*Attributes/` | Declarative event-binding attributes, consumed by `Component` |
| `src/Console/Component.php`, `Windows/`, `Components/` | The component tree and its rendering |
| `src/Console/EventBus.php` | Cross-cutting pub/sub for key/mouse/custom events |
| `src/Console/Support/` | Pure logic extracted out of components (currently just `Scroll`) |
Loading
Loading