diff --git a/docs/rpc-protocol.md b/docs/rpc-protocol.md index f740be2..85e89e1 100644 --- a/docs/rpc-protocol.md +++ b/docs/rpc-protocol.md @@ -46,9 +46,12 @@ Built by `Rpc\JsonRpcRequest::payload()`: | `trace` | array of `{file, line}` | backtrace, `DEBUG_BACKTRACE_IGNORE_ARGS`, first 2 frames skipped | | `rootDir` | string\|null | resolved via Composer's `ClassLoader` file location | | `code` | array of `{number, line, active}` | ±3 lines of source around the caller line | +| `kind` | string\|null | `null`/absent for a normal value; `"error"` when `tp()` was called with a `Throwable` — in that case `message` is a preformatted string (not JSON-encoded) and `caller`/`trace`/`code` describe where the exception was thrown, not the `tp()` call site | Server response: `{"jsonrpc":"2.0","result":"ok","id":}`. +Consecutive `log`/`wait` calls with identical `kind`+`message`+`caller` are coalesced server-side (`AppState::appendLog()`) into one entry with an incrementing `LogItem::$repeatCount`, instead of appending a new row per call — this is what a UI-facing "×N" counter renders from. + ### `wait` params Same shape as `log`. Server behavior differs: it still calls `AppState::appendLog()` immediately (so the "paused" message shows up right away), but **does not reply immediately**. Instead it registers a listener on `EventBus` for `KeyCode::Enter` that writes the deferred reply the next time Enter is pressed anywhere in the TUI: diff --git a/examples/DebugMenu.php b/examples/DebugMenu.php index 9b86fd5..8685ea2 100644 --- a/examples/DebugMenu.php +++ b/examples/DebugMenu.php @@ -38,6 +38,18 @@ $line = fgets(STDIN); tp(trim($line ?: '')); }], + '9' => ['label' => 'Exception (tp($throwable))', 'run' => function () { + try { + throw new \RuntimeException('Something went wrong while processing the request'); + } catch (\Throwable $e) { + tp($e); + } + }], + '10' => ['label' => 'Repeated identical log (×N counter test)', 'run' => function () { + for ($i = 0; $i < 5; $i++) { + tp('Same message every time'); + } + }], ]; echo "Tapper dev menu — make sure `php bin/tapper` is running in another terminal.\n"; diff --git a/src/Console/Components/Details.php b/src/Console/Components/Details.php index 579dc91..3896706 100644 --- a/src/Console/Components/Details.php +++ b/src/Console/Components/Details.php @@ -9,6 +9,7 @@ use PhpTui\Term\KeyModifiers; use PhpTui\Term\MouseButton; use PhpTui\Term\MouseEventKind; +use PhpTui\Tui\Color\RgbColor; use PhpTui\Tui\Display\Area; use PhpTui\Tui\Extension\Core\Widget\CompositeWidget; use PhpTui\Tui\Extension\Core\Widget\List\ListItem; @@ -25,6 +26,7 @@ use Tapper\Console\CommandAttributes\Mouse; use Tapper\Console\Component; use Tapper\Console\MessageFormatter; +use Tapper\Console\Palette; use Tapper\Console\Support\Scroll; class Details extends Component @@ -137,9 +139,11 @@ protected function view(Area $area): Widget ]; $infoListItems = array_map(fn ($line) => ListItem::new(Text::fromLine($line)), $info); - $formattedMessage = $log->kind === 'wait' - ? [Line::fromSpan(Span::styled($log->message, Style::default()->yellow()))] - : MessageFormatter::colorizeFormattedJson($log->message); + $formattedMessage = match ($log->kind) { + 'wait' => [Line::fromSpan(Span::styled($log->message, Style::default()->yellow()))], + 'error' => [Line::fromSpan(Span::styled($log->message, Style::default()->fg(RgbColor::fromHex(Palette::ERROR))))], + default => MessageFormatter::colorizeFormattedJson($log->message), + }; $formattedListItems = array_map(fn ($line) => ListItem::new(Text::fromLine($line)), $formattedMessage); $allItems = [ diff --git a/src/Console/Components/LogItem.php b/src/Console/Components/LogItem.php index dd25158..29c5544 100644 --- a/src/Console/Components/LogItem.php +++ b/src/Console/Components/LogItem.php @@ -125,11 +125,23 @@ protected function view(Area $area): Widget Span::styled($time, Style::default()->fg(RgbColor::fromHex(Palette::ACCENT))), ); - $messageSpans = $this->log->kind === 'wait' - ? [Span::styled($message, Style::default()->yellow())] - : MessageFormatter::colorizeInlineJson($message); + $messageSpans = match ($this->log->kind) { + 'wait' => [Span::styled($message, Style::default()->yellow())], + 'error' => [Span::styled($message, Style::default()->fg(RgbColor::fromHex(Palette::ERROR)))], + default => MessageFormatter::colorizeInlineJson($message), + }; - $wMess = ParagraphWidget::fromSpans(...$this->truncateSpans($messageSpans, $messageWidth, $darkGray)); + $repeatBadge = $this->log->repeatCount > 1 + ? Span::styled(sprintf(' ×%d', $this->log->repeatCount), Style::default()->fg(RgbColor::fromHex(Palette::ACCENT))) + : null; + + $messageSpans = $this->truncateSpans($messageSpans, $messageWidth - ($repeatBadge?->width() ?? 0), $darkGray); + + if ($repeatBadge) { + $messageSpans[] = $repeatBadge; + } + + $wMess = ParagraphWidget::fromSpans(...$messageSpans); $wFile = ParagraphWidget::fromSpans( ...$this->truncateSpans([Span::styled(sprintf('↪ %s', $this->log->caller), $darkGray)], $messageWidth, $darkGray), ); diff --git a/src/Console/State/AppState.php b/src/Console/State/AppState.php index 182c94e..6a92849 100644 --- a/src/Console/State/AppState.php +++ b/src/Console/State/AppState.php @@ -65,9 +65,24 @@ public function setOnChange(callable $change): void $this->change = $change; } - public function appendLog(LogItem $logItem): void + /** + * @return bool true if a new entry was appended, false if it was merged + * into the previous identical entry (its repeat counter bumped) + */ + public function appendLog(LogItem $logItem): bool { - $this->logs[] = $logItem; + $last = $this->logs[count($this->logs) - 1] ?? null; + $isRepeat = $last + && $last->kind === $logItem->kind + && $last->message === $logItem->message + && $last->caller === $logItem->caller; + + if ($isRepeat) { + $last->repeatCount++; + } else { + $this->logs[] = $logItem; + } + if (! $this->batching) { $this->notifyChange(); $this->callObservers('logs'); @@ -76,6 +91,8 @@ public function appendLog(LogItem $logItem): void if ($this->batching) { $this->changed[] = 'logs'; } + + return ! $isRepeat; } public function observe(string $name, callable $callable): void diff --git a/src/Console/State/LogItem.php b/src/Console/State/LogItem.php index 3e6c0cf..495338f 100644 --- a/src/Console/State/LogItem.php +++ b/src/Console/State/LogItem.php @@ -15,5 +15,6 @@ public function __construct( public string $rootDir, public array $code, public string $kind = 'log', + public int $repeatCount = 1, ) {} } diff --git a/src/Runtime/Tapper.php b/src/Runtime/Tapper.php index 863f9ea..d829d60 100644 --- a/src/Runtime/Tapper.php +++ b/src/Runtime/Tapper.php @@ -19,12 +19,16 @@ class Tapper private string $type = 'log'; + private ?string $kind = null; + private array $trace = []; private ?string $rootDir = null; private array $callerToGet; + private ?array $code = null; + public function __construct() { if (self::$client === null) { @@ -36,11 +40,35 @@ public function __construct() public function tap(mixed $message): self { + if ($message instanceof \Throwable) { + return $this->tapThrowable($message); + } + $this->message = $message; return $this; } + private function tapThrowable(\Throwable $throwable): self + { + $file = $throwable->getFile(); + $line = $throwable->getLine(); + + $this->kind = 'error'; + $this->message = sprintf('✕ %s: %s', get_class($throwable), $throwable->getMessage()); + $this->caller = sprintf('%s:%s', basename($file), $line); + $this->trace = [ + ['file' => $file, 'line' => $line], + ...array_map(fn (array $frame): array => [ + 'file' => $frame['file'] ?? null, + 'line' => $frame['line'] ?? null, + ], $throwable->getTrace()), + ]; + $this->code = $this->getCodeExcerpt($file, $line); + + return $this; + } + public function wait(): self { $this->type = 'wait'; @@ -119,7 +147,8 @@ private function send(): void 'microtime' => $this->microtime, 'trace' => $this->trace, 'rootDir' => $this->rootDir, - 'code' => $this->getCodeExcerpt($this->callerToGet['file'], $this->callerToGet['line']), + 'code' => $this->code ?? $this->getCodeExcerpt($this->callerToGet['file'], $this->callerToGet['line']), + 'kind' => $this->kind, ]; $request = new JsonRpcRequest( diff --git a/src/Server.php b/src/Server.php index d5a6af2..b313c09 100644 --- a/src/Server.php +++ b/src/Server.php @@ -52,14 +52,16 @@ public function run(): void switch ($method) { case 'log': - $this->appState->appendLog(new LogItem( + $kind = $params['kind'] ?? 'log'; + $isAppended = $this->appState->appendLog(new LogItem( self::$id, $params['microtime'], - json_encode($params['message'], JSON_UNESCAPED_UNICODE), + $kind === 'error' ? $params['message'] : json_encode($params['message'], JSON_UNESCAPED_UNICODE), $params['caller'], $params['trace'], $params['rootDir'], $params['code'], + kind: $kind, )); $encoder->write([ @@ -68,11 +70,13 @@ public function run(): void 'id' => $id, ]); - self::$id++; + if ($isAppended) { + self::$id++; + } break; case 'wait': - $this->appState->appendLog(new LogItem( + $isAppended = $this->appState->appendLog(new LogItem( self::$id, $params['microtime'], "⏸ {$params['message']} — press ENTER to continue", @@ -83,7 +87,10 @@ public function run(): void kind: 'wait', )); - self::$id++; + if ($isAppended) { + self::$id++; + } + $this->appState->pendingWaits++; $this->eventBus->listen(KeyCode::Enter, function () use ($encoder, $id) {