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.
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:
- It is a real, shippable debugger with its own README and CI.
- 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 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).
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
SocketPath.php Resolves the shared unix-socket path
LogPath.php Resolves tapper.log's path (same directory as the socket)
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
ErrorHandler.php Redirects PHP warnings/notices (and, via bin/tapper, uncaught throwables) to tapper.log instead of stdout, which would otherwise corrupt the raw-mode/alt-screen render
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
PhpHighlighter.php PHP syntax highlighting (native token_get_all) for the code excerpt in Details
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, Filter (the `/` filter input line, see typingMode below)
Windows/ Main (root layout), Popup (working shortcuts modal, toggled with `?`)
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
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/ 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)
composer install
# Start the TUI (in one terminal)
php bin/tapper
# In another terminal/process, run something that calls tp()
php examples/BasicExample.phpThere is no composer.json bin alias beyond bin/tapper; it is run directly with php.
composer test:unit # pest --compact
composer test:lint # pint --test (check only, does not auto-fix)
vendor/bin/pint # auto-fix styleCI (.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.
- Two processes, two concurrency models. The debuggee (anything calling
tp()) runs normal synchronous PHP and blocks on a socket round-trip inRuntime/Tapper.php::send(). The TUI process (bin/tapper) is fully async on a ReactPHP event loop. Never assumetp()callers can be non-blocking, and never introduce blocking I/O insideConsole/*— it runs on the loop. - State flows one way into the TUI:
Server.phpdecodes a socket message → builds aState\LogItem→ callsAppState::appendLog()→AppStatenotifies itsonChangecallback →ApplicationsetsshouldDraw = true→ next 1/60s tick redraws. Components readAppStatedirectly inview(); they do not receive props from parents in a React sense — seedocs/console-framework.md. AppStateuses magic__get/__set. Every field must exist as a constructor-promoted property and be documented in the@propertydocblock at the top of the class, or IDE/static tooling won't see it.observe($name, $cb)validates$nameagainstget_class_vars()at runtime, not compile time.- There is a known, unresolved bug in
AppStatebatching (deffer()/commit()) — see the@TODOcomment insrc/Console/State/AppState.php. Don't build new features on top ofdeffer()/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]). Seedocs/console-framework.mdfor exact semantics (especiallyglobal: true, which is easy to get wrong). - Rendering is not backend-agnostic.
Component::view()returns a concretePhpTui\Tui\Widget\Widget, and every component usesphp-tuilayout primitives (Area,Constraint,Direction,Layout) directly. If you are working toward the GLFW goal, do not scatter morephp-tui-specific calls into component logic without first readingdocs/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.phpandRpc/JsonRpcClient.phpnow resolve the unix-socket path through the sharedTapper\SocketPath::resolve()(src/SocketPath.php) instead of computing it independently. Seedocs/known-issues.mdbefore touchingRpc/*. docs/openrpc.jsonis stale. It documents anappendLogmethod with a nesteddetailsparam; the actual server (Server.php) implementslogandwaitmethods with flat params. Don't treatopenrpc.jsonas ground truth for the wire format — seedocs/rpc-protocol.mdfor what's actually implemented.Commands/Command.phpandCommandInvokerhave 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.
- 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/
EventBuspattern over inventing a new event mechanism for TUI interactions. - New
AppStatefields: add to the constructor-promoted property list and the@propertydocblock in the same change — they're both authoritative and must stay in sync.
docs/README.md— documentation index.docs/architecture.md— full data-flow walkthrough, process boundaries.docs/console-framework.md— Component/EventBus/AppState mechanics in detail.docs/rpc-protocol.md— the actual wire protocol between debuggee and TUI.docs/known-issues.md— bugs, gaps, and the framework-extraction roadmap.