Everything under src/Console/ (minus State/, EventBus.php, main.php, Application.php) is built around one base class, Component, plus five attributes and one pub/sub bus. This doc is the detailed reference; AGENTS.md has the condensed must-know version.
This layer is not backend-agnostic today — Component::view() returns a concrete php-tui Widget, and layout is done with php-tui's Area/Constraint/Direction/Layout. Treat everything below as "how the TUI is built," not yet as "the framework" — see known-issues.md.
Abstract base class. Every concrete component (Header, LogList, LogItem, Details, Navigation, Splash, and the two Windows/*) extends it and implements one method:
abstract protected function view(Area $area): Widget;Component::__construct() does five things, in order, using PHP reflection over the concrete class's methods:
- Calls a
beforeInit()method if one exists (via the DI container, so it's autowired). - Scans methods for
#[KeyPressed],#[Mouse],#[OnEvent]attributes and registers a closure withEventBus::listen()for each. The closure captures the attribute instance and does the matching described below before calling the method. - Scans methods for
#[Periodic]and registers a ReactPHP periodic timer calling the method directly. - Scans methods for
#[FirstRender]and stashes them to be called once, the first timerender()is invoked (not from the constructor —$areaisn't known yet at construction time). - Instantiates every class listed in
protected array $components = [...]via the container (registerComponents()), then callsafterInit()if it exists.
Because this all happens in the base constructor via reflection, every component pays this cost on every instantiation — including the LogItem instances created per visible row in LogList::ensureVisible(). This is fine at current scale (a few dozen components) but is worth knowing if you're ever tempted to construct components in a hot loop.
These are the actual runtime rules baked into the closure registered in step 2 above — read this before adding a new #[KeyPressed]/#[Mouse] handler, the logic is not obvious from the attribute alone:
#[KeyPressed($key, $keyModifiers = null, $global = false)]— if$keyModifiersis set, the event's modifiers must match exactly (not "at least contain"). If the componentisActive() === falseand$globalisfalse, the handler is skipped entirely.#[Mouse($key, $button = MouseButton::Left, $global = false)]— for scroll events (ScrollUp/Down/Left/Right) the button is ignored. For click events, the event's button must match$buttonexactly. Sameglobal/isActive()gating as above.#[OnEvent($key, $global = false)]— matches onEventBus::emit()'s custom string keys (e.g.'resize','input'). Sameglobal/isActive()gating.#[Periodic($interval, $global = false)]— note the$globalparameter is declared but not read anywhere inComponent::__construct(); periodic timers always fire regardless ofisActive(). Don't rely onglobal: falsedoing anything for#[Periodic].#[FirstRender]— fires once, on the firstrender()call, regardless ofisActive().
isActive()/activate()/deactivate() is a per-component boolean the component tree uses to route input only to the "focused" component — see Windows/Main.php::afterInit() for the pattern: it observes AppState's previewLog and toggles Details vs LogList active state via Loop::futureTick() (deferred by one tick so the activation change doesn't happen mid-event-dispatch).
render(Area $area) is called top-down starting from Application::draw() on the root Main component. First call captures $area and fires any #[FirstRender] methods; every call (first or not) then delegates to the abstract view($area), which returns the actual Widget tree for that subtree. Parent components call renderComponent($childClass, $area) / getComponent($childClass)->render($area) on their registered children — there is no automatic recursive render; each parent's view() explicitly renders the children it wants, in the layout it wants (see Windows/Main.php::view() for the canonical 3-row GridWidget split: header / body / nav).
Components do not receive props from parents. A component that needs external data (e.g. LogItem needs to know which log entry to draw) exposes an explicit setter (LogItem::setData()) that the parent (LogList::fill()) calls before rendering. There's no generalized prop-passing mechanism — each case is bespoke.
protected function execute(Command $command): mixed delegates to CommandInvoker::invoke(), which just calls Invoker\InvokerInterface::call($command) (php-di's auto-wiring invoker). Commands/Command.php is an empty abstract class. No component anywhere calls execute() and no concrete Command subclass exists in the codebase. This looks like the start of a command-object pattern (useful for e.g. undo/redo or macro recording) that was never followed through. Don't assume it's an established convention to build on without checking with the project owner first.
A flat string|int => callable[] registry. listen($event, $func) normalizes KeyCode/MouseEventKind enum keys to strings ($event->name, or "Mouse{$event->name}" for mouse kinds) before storing. emit($event, $data = []) does the same normalization on the way out, so a KeyPressed(KeyCode::Enter) attribute and a raw EventBus::listen(KeyCode::Enter, ...) call in Server.php end up on the exact same registry key (Server.php relies on this — see architecture.md's note on wait).
There is no unsubscribe. Every listen() call is permanent for the lifetime of the process. Combined with Server.php's wait handler registering a fresh KeyCode::Enter listener per wait() call (see known-issues.md), this bus only ever grows.
emit() silently no-ops if nothing is registered for a key — there's no way to detect "nobody handled this event."
A single, app-wide, mutable object. It is the only channel by which the socket-facing Server.php communicates with the rendering tree — there is no separate "props" or "context" system.
- Magic properties:
__get/__setproxy to real private/constructor-promoted properties. The@propertyPHPDoc block at the top of the class is what makes these visible to IDEs/static analysis at all — it is not generated, it must be hand-kept in sync with the constructor's promoted properties. If you add a field to one and forget the other, you get either a silent dynamic property (constructor not updated) or an IDE that can't see a real property (docblock not updated). - Change notification: every
__set(outside a batch) callsnotifyChange()(the single$changecallback registered once, byApplication::startRendering(), to flipshouldDraw = true) andcallObservers($name)(per-field subscribers registered viaobserve($name, $callable)). - Field-specific observers:
observe(string $name, callable $callable)validates$nameexists viaget_class_vars($this::class)at call time (aRuntimeExceptionif you typo a field name — this is your only safety net, and it's runtime-only). Multiple components observe overlapping fields today, e.g.LogList::beforeInit()observeslogs,cursor, andliveto keep unread counts and live-follow behavior in sync — read that method as the canonical example of cross-field reactive logic. - Batching:
deffer()(sic — note the actual method name has this typo, not "defer") sets abatchingflag; subsequent__setcalls accumulate field names into$changedinstead of notifying immediately.commit()flips batching off, replays observers for every accumulated field, then callsnotifyChange()once.$changedis keyed by field name (a set, not a list), so repeatedly setting the same field while batched — e.g. pressing Enter many times while atp()->wait()is paused — no longer grows$changedunbounded or replays observers once per write; each field's observers replay exactly once percommit().
The one piece of business logic in Console/ that's pure enough to unit test in isolation, and the only class in the repo with a real test suite (tests/Unit/ScrollTest.php). It operates purely on (cursor, offset) pairs read/written through AppState, given count/visible as parameters — no widget, no rendering, no reflection. LogList and Details both have their own scroll-adjacent logic; LogList delegates to a Scroll instance, Details reimplements similar (but not identical, and untested) up/down/page logic inline (Components/Details.php::up()/down()/pageUp()/pageDown()) instead of reusing Scroll. If you're touching scrolling behavior in Details, consider whether it should be unified with Support/Scroll rather than diverging further.
- Extend
Component, implementview(Area $area): Widget. - If it needs to be constructed by a parent, add its class name to the parent's
protected array $components = [...]and register it in the container graph implicitly via autowiring (no explicit binding needed for a plain class). - If it needs external data from its parent, add an explicit setter — don't reach for a generic prop-passing mechanism, there isn't one.
- If it needs input, use
#[KeyPressed]/#[Mouse]/#[OnEvent]— rememberglobal: trueif it should react while not the "active" component (e.g. aq-to-quit handler). - If it needs one-time setup once its
Areais known, use#[FirstRender], not the constructor. - If it reads
AppState, read directly ($this->appState->foo) insideview()or an event handler — don't invent a new state channel.