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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ src/
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)
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
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
tests/Unit/ ScrollTest.php, ScrollbarRenderTest.php — only test suites that currently exist
```

## Running things
Expand Down
6 changes: 3 additions & 3 deletions docs/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ Reported case: pressing Space in `LogList` with zero log entries hit `LogList::s
1. Root cause: `LogList::select()` now reads via `?? null` and no-ops when there's no log at the cursor, instead of indexing blind.
2. General case: `Console\ErrorHandler::install()` (called first thing in `Application::run()`, before raw mode/alt-screen are enabled) installs a `set_error_handler` that logs any warning/notice/deprecation to `tapper.log` (`LogPath::resolve()`, same directory as `tapper.sock`) and sets a 5-second `AppState::errorNotice` banner (rendered by `Header`) instead of letting PHP print to stdout. Uncaught `Throwable`s still propagate normally — `bin/tapper`'s `catch (\Throwable $e)` (widened from `\Exception`) calls `Application::close()` to restore the terminal, logs via `ErrorHandler::logThrowable()`, then rethrows so the trace prints on a normal, restored terminal rather than corrupting the TUI.

Note: this intentionally does *not* revive `Windows/Popup.php` for the "check the logs" notice — see the dead-scaffolding entry below; the banner lives in `Header` via two new `AppState` fields (`errorNotice`, `errorNoticeExpiresAt`) instead.
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.

## 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`.
- **`Commands/Command.php` + `CommandInvoker`** — an empty abstract class and an invoker with zero concrete `Command` subclasses anywhere in the codebase. Either commit to modeling user actions as `Command` objects (useful for undo/redo, macro recording, remapping keys later) or remove the layer until there's a real consumer.
- **`docs/openrpc.json`** — describes a method (`appendLog`, nested `details` param) that doesn't match the implemented protocol (`log`/`wait`, flat params). Regenerate from `Server.php` or delete.
- **`Windows/Popup.php`** — exists, is instantiated and checked (`Application::draw()` renders it when `isActive()`), but its `view()` just returns an empty `BlockWidget::default()` and nothing in the codebase ever calls `activate()` on it (confirmed by grep — zero hits). Dead scaffolding for a not-yet-built feature (likely a modal/dialog system) — fine to leave, but don't assume it's a working popup system if you go looking for one.
- **`AppState::typingMode` is only ever set to `false`**, never `true`, anywhere in the codebase (confirmed by grep). `Application::handleEventInTypingMode()` therefore always returns early — its entire body (redirecting character input, exiting on Esc) is currently unreachable dead code, presumably scaffolding for a not-yet-built text-input feature.
- ~~**`Windows/Popup.php`**~~corrected 2026-08-16: this entry was stale. `Popup` is a working shortcuts-help modal (toggled with `?`, closed with `Esc`, real `view()` content) — not dead scaffolding. `Application::draw()` renders it based on `AppState::popupOpen`, not `isActive()`; `Popup`'s own bindings are `global: true` so its `isActive` is never touched at all, by design.
- ~~**`AppState::typingMode` is only ever set to `false`**~~ — resolved 2026-08-16: it's now the basis for log filtering. `LogList::startFilter()` (bound to `/`) sets it `true`; `Console\Components\Filter` (a standalone overlay owned by `Application`, mirroring how `Popup` is owned rather than being a `Main` child) uses `Application::handleEventInTypingMode()`'s `'input'` event to build `AppState::filter`, live-filtered via `AppState::filteredLogs()` (case-insensitive substring match on `LogItem::$message`). Enter confirms and exits typing mode keeping the filter; Esc cancels and clears it. See `Filter.php` for why its key bindings are deliberately non-global. One related fix required to make this safe: `handleEventInTypingMode()` used to pass the *raw stdin chunk* as the `'input'` payload for every drained event — since one stdin read can drain several events (fast typing/paste), they'd all have shared and duplicated the same bytes. It now emits the event's own `CharKeyEvent::$char` instead. Also: `Application::startInputHandling()`'s `'q'`-quits-the-app check now only fires outside typing mode, since `q` is a normal character you'd type into the filter box.
- **`EventBus` has no unsubscribe** — every `listen()` is permanent. This is fine for the fixed component tree Tapper has today (components are never torn down mid-run), but is a real gap the moment anything needs dynamic component lifecycles. (The `wait()` leak that used to be a direct symptom of this gap is fixed above by having `Server` register one permanent listener instead of one per call — that sidesteps the gap for this one case, it doesn't close it.)

## Framework-extraction readiness
Expand Down
45 changes: 40 additions & 5 deletions src/Console/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use PhpTui\Tui\Extension\Core\Widget\CompositeWidget;
use React\EventLoop\LoopInterface;
use React\Stream\ReadableResourceStream;
use Tapper\Console\Components\Filter;
use Tapper\Console\State\AppState;
use Tapper\Console\Windows\Main;
use Tapper\Console\Windows\Popup;
Expand All @@ -33,6 +34,8 @@ class Application

private Popup $popup;

private Filter $filter;

private Area $area;

private bool $shouldDraw = true;
Expand Down Expand Up @@ -83,6 +86,20 @@ private function init(): void
{
$this->window = $this->container->make(Main::class);
$this->popup = $this->container->make(Popup::class);
$this->filter = $this->container->make(Filter::class);

// Filter's own key bindings are non-global (see Filter.php), so it needs its
// isActive toggled explicitly — mirrors Main::afterInit()'s LogList/Details dance.
// Synchronous (no futureTick): typing a filter query can drain several char events
// from one stdin read, all dispatched within the same tick, and Filter needs to be
// active in time to catch them — see the matching note in Main::afterInit().
$this->appState->observe('typingMode', function (bool $typing): void {
if ($typing) {
$this->filter->activate();
} else {
$this->filter->deactivate();
}
});
}

private function startRendering(): void
Expand Down Expand Up @@ -114,6 +131,10 @@ private function draw(Area $area): void
$widgets[] = $this->popup->render($area);
}

if ($this->appState->typingMode) {
$widgets[] = $this->filter->render($area);
}

$composite = CompositeWidget::fromWidgets(...$widgets);

$this->display->draw($composite);
Expand All @@ -126,13 +147,24 @@ private function startInputHandling(): void
$this->eventParser->advance($data, false);

foreach ($this->eventParser->drain() as $event) {
// Captured before handleEvent(): if this exact event is the one that just
// flipped typingMode on (e.g. the '/' that starts a filter), it shouldn't
// also be treated as typed filter input — that key press activates typing
// mode, it doesn't type into it.
$wasTyping = $this->appState->typingMode;

$this->handleEvent($event);
$this->handleEventInTypingMode($event, $data);

if ($wasTyping) {
$this->handleEventInTypingMode($event);
}
}

// `stty raw` (enabled by Terminal::enableRawMode()) disables ISIG, so Ctrl+C
// never reaches us as SIGINT — it arrives as byte 0x03 (ETX) on stdin instead.
if ($data === 'q' || $data === "\x03") {
// Ctrl+C always quits; 'q' only quits outside typing mode, since it's a normal
// character you'd type into the filter box otherwise.
if ($data === "\x03" || (! $this->appState->typingMode && $data === 'q')) {
$this->close();
}
});
Expand All @@ -151,7 +183,7 @@ private function handleEvent(Event $event): void
}
}

private function handleEventInTypingMode(Event $event, $data): void
private function handleEventInTypingMode(Event $event): void
{
if (! $this->appState->typingMode) {
return;
Expand All @@ -166,12 +198,15 @@ private function handleEventInTypingMode(Event $event, $data): void
}

if ($event instanceof CharKeyEvent) {
$this->eventBus->emit('input', ['data' => $data]);
// Emit the event's own char, not the raw stdin chunk — a single stdin read can
// drain multiple events (fast typing/paste), and they'd otherwise all share the
// same raw bytes, duplicating input.
$this->eventBus->emit('input', ['char' => $event->char]);

return;
}

if ($event->code === KeyCode::Esc) {
if ($event instanceof CodedKeyEvent && $event->code === KeyCode::Esc) {
$this->appState->typingMode = false;

return;
Expand Down
77 changes: 77 additions & 0 deletions src/Console/Components/Filter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace Tapper\Console\Components;

use PhpTui\Term\KeyCode;
use PhpTui\Tui\Color\RgbColor;
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\ParagraphWidget;
use PhpTui\Tui\Style\Style;
use PhpTui\Tui\Text\Span;
use PhpTui\Tui\Widget\Widget;
use Tapper\Console\CommandAttributes\KeyPressed;
use Tapper\Console\CommandAttributes\OnEvent;
use Tapper\Console\Component;
use Tapper\Console\Palette;

/**
* The `/` filter input line. Only active while `AppState::typingMode` is on (toggled by
* `LogList::startFilter()`, wired up in `Main::afterInit()`) — its key bindings are
* intentionally non-global so they don't shadow the normal list/details bindings.
*/
class Filter extends Component
{
#[OnEvent('input')]
public function appendChar(array $data): void
{
$this->appState->filter .= $data['char'];
}

#[KeyPressed(KeyCode::Backspace)]
public function backspace(): void
{
$this->appState->filter = mb_substr($this->appState->filter, 0, -1);
}

#[KeyPressed(KeyCode::Enter)]
public function confirm(): void
{
$this->appState->typingMode = false;
}

#[KeyPressed(KeyCode::Esc)]
public function cancel(): void
{
$this->appState->filter = '';
$this->appState->typingMode = false;
}

protected function view(Area $area): Widget
{
$barArea = Area::fromScalars(
$area->position->x,
$area->position->y + $area->height - 1,
$area->width,
1,
);

$prefix = Span::styled(' /', Style::default()->fg(RgbColor::fromHex(Palette::ACCENT)));
$text = Span::fromString($this->appState->filter);

// ParagraphRenderer only writes cells under actual glyphs — it doesn't blank the
// rest of its area — so without explicit padding, Navigation's text (rendered into
// this same row a moment earlier) bleeds through past the end of the filter text.
$padding = Span::fromString(str_repeat(' ', max(0, $barArea->width - $prefix->width() - $text->width())));

return BufferWidget::new(function (BufferContext $context) use ($barArea, $prefix, $text, $padding): void {
$context->draw(
ParagraphWidget::fromSpans($prefix, $text, $padding),
$barArea,
);
});
}
}
3 changes: 3 additions & 0 deletions src/Console/Components/Header.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ protected function view(Area $area): Widget
$unread ? Span::fromString(sprintf(' (↓%s)', $this->appState->unread))->yellow() : Span::fromString(''),
Span::fromString(' | '),
Span::fromString(sprintf('port: %s', $this->appState->port)),
$this->appState->filter !== '' && ! $this->appState->typingMode
? Span::styled(sprintf(' | filter: %s', $this->appState->filter), Style::default()->fg(RgbColor::fromHex(Palette::ACCENT)))
: Span::fromString(''),
$this->appState->errorNotice !== null
? Span::styled(' | '.$this->appState->errorNotice, Style::default()->fg(RgbColor::fromHex(Palette::ERROR)))
: Span::fromString(''),
Expand Down
22 changes: 16 additions & 6 deletions src/Console/Components/LogItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ class LogItem extends Component

private ?LogItemState $log = null;

public function setData(?LogItemState $log): void
// Position of $log within the currently displayed (possibly filtered) list — NOT
// $log->id, which is a permanent identifier assigned when the log was received and
// only coincidentally matches list position when nothing is filtered out.
private ?int $index = null;

public function setData(?LogItemState $log, ?int $index = null): void
{
$this->log = $log;
$this->index = $index;
}

#[Mouse(MouseEventKind::Down, global: true)]
Expand All @@ -41,11 +47,11 @@ public function mouseMove(array $data): void
/** @var MouseEvent $event */
$event = $data['event'];

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

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

if ($event->row > $itemPosition
Expand All @@ -59,10 +65,14 @@ public function mouseMove(array $data): void

public function click(): void
{
if ($this->appState->cursor === $this->log->id) {
if ($this->index === null) {
return;
}

if ($this->appState->cursor === $this->index) {
$this->appState->previewLog = $this->log;
} else {
$this->appState->cursor = $this->log->id;
$this->appState->cursor = $this->index;
}
}

Expand Down Expand Up @@ -112,7 +122,7 @@ protected function view(Area $area): Widget
$dt = DateTime::createFromFormat('U.u', sprintf('%.6f', $this->log->timestamp));
$time = $dt->format('H:i:s.u');

$mark = $this->appState->cursor === $this->log->id;
$mark = $this->index !== null && $this->appState->cursor === $this->index;

$darkGray = Style::default()->darkGray();
$markerColor = RgbColor::fromHex(Palette::SELECTION_BG);
Expand Down
38 changes: 34 additions & 4 deletions src/Console/Components/LogList.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public function beforeInit(): void
$this->scroll = new Scroll($this->appState);

$this->appState->observe('logs', fn (): null => $this->updateLogs());
$this->appState->observe('filter', fn (): null => $this->updateFilter());
$this->appState->observe('cursor', function (int $cursor): void {
$this->appState->live = $cursor >= $this->count - 1;
});
Expand Down Expand Up @@ -108,10 +109,23 @@ public function pageDown(): void
$this->scroll->jump($this->appState->cursor + $halfPage, $this->count, $this->visible);
}

#[KeyPressed('/')]
public function startFilter(): void
{
// Only write if it's actually changing — __set() notifies unconditionally, and a
// no-op write here would still trigger updateFilter()'s jump-to-top before the user
// has typed anything.
if ($this->appState->filter !== '') {
$this->appState->filter = '';
}

$this->appState->typingMode = true;
}

#[KeyPressed(' ')]
public function select(): void
{
$log = $this->appState->logs()[$this->appState->cursor] ?? null;
$log = $this->appState->filteredLogs()[$this->appState->cursor] ?? null;

if ($log === null) {
return;
Expand Down Expand Up @@ -139,11 +153,16 @@ public function clear(): void
$this->appState->cursor = 0;
$this->appState->offset = 0;
$this->appState->unread = 0;
$this->appState->filter = '';
}

#[KeyPressed(KeyCode::Esc)]
public function backToLive(): void
{
if ($this->appState->filter !== '') {
$this->appState->filter = '';
}

$this->scroll->scrollToBottom($this->count, $this->visible);
}

Expand All @@ -153,13 +172,24 @@ private function updateLogs(): void
$this->appState->unread++;
}

$this->count = count($this->appState->logs());
$this->refreshCount();

if ($this->appState->live) {
$this->scroll->scrollToBottom($this->count, $this->visible);
}
}

private function updateFilter(): void
{
$this->refreshCount();
$this->scroll->jump(0, $this->count, $this->visible);
}

private function refreshCount(): void
{
$this->count = count($this->appState->filteredLogs());
}

private function ensureVisible(): void
{
$visible = $this->visible;
Expand All @@ -180,8 +210,8 @@ private function fill(): void
{
foreach ($this->listItems as $i => $component) {
$logIndex = $this->appState->offset + $i;
$log = $this->appState->logs()[$logIndex] ?? null;
$component->setData($log);
$log = $this->appState->filteredLogs()[$logIndex] ?? null;
$component->setData($log, $logIndex);
}
}

Expand Down
Loading
Loading