Debugging Silvery
Canonical reference for debugging rendering issues. All other docs link here instead of duplicating.
Environment Variables
Verification Modes
# Buffer-level: incremental render phase must produce same buffer as fresh render
SILVERY_STRICT=1 bun run app
# ANSI-level: verify output via internal VT100 parser (fast, same process)
SILVERY_STRICT_TERMINAL=vt100 bun run app
# Terminal-level: verify via independent xterm.js emulator
SILVERY_STRICT_TERMINAL=xterm bun run app
# Terminal-level: verify via Ghostty WASM emulator
SILVERY_STRICT_TERMINAL=ghostty bun run app
# Multiple backends (comma-separated)
SILVERY_STRICT_TERMINAL=vt100,xterm bun run app
# All backends
SILVERY_STRICT_TERMINAL=all bun run app # vt100 + xterm + ghostty
# Accumulated ANSI: replays ALL frames (O(N^2)) to catch compounding errors
SILVERY_STRICT_ACCUMULATE=1 bun run appNotes:
SILVERY_STRICT=1→ enables buffer-level check AND vt100 output verificationSILVERY_STRICT_TERMINAL=all→ shorthand forvt100,xterm,ghostty- These terminal verification modes use Termless backends internally
Diagnostics
All diagnostic output is routed through loggily structured logging. Use DEBUG for log output and TRACE for span timing.
# All silvery diagnostic output (file-based to avoid stdout corruption)
DEBUG=silvery:* DEBUG_LOG=/tmp/silvery.log bun run app
# Render phase stats only (nodes visited/rendered/skipped per frame)
DEBUG=silvery:content DEBUG_LOG=/tmp/silvery.log bun run app
# Per-node trace entries (requires SILVERY_STRICT for trace collection)
DEBUG=silvery:content:trace DEBUG_LOG=/tmp/silvery.log bun run app
# Per-cell debug (which nodes cover a specific cell during incremental rendering)
SILVERY_CELL_DEBUG=77,85 DEBUG=silvery:content:cell DEBUG_LOG=/tmp/silvery.log bun run app
# Pipeline phase timing spans
TRACE=silvery:render DEBUG_LOG=/tmp/silvery.log bun run app
# Measure phase debug (text measurement calls)
DEBUG=silvery:measure DEBUG_LOG=/tmp/silvery.log bun run app
# Instrumentation counters (enables stats collection, also exposed on globalThis)
SILVERY_INSTRUMENT=1 bun run appLoggily Namespace Reference
| Namespace | What |
|---|---|
silvery:render | Frame-level spans with per-phase timing |
silvery:content | Render phase stats per frame (render/skip counts) |
silvery:content:trace | Per-node trace entries (skip/render decisions) |
silvery:content:cell | Per-cell debug (node coverage at target coords) |
silvery:measure | Measure phase debug (text measurement calls) |
@silvery/ag-react | React reconciler pipeline spans |
Enriched STRICT Errors
When SILVERY_STRICT detects a mismatch, the IncrementalRenderMismatchError automatically captures:
- Render-phase stats (nodes visited/rendered/skipped, per-flag breakdown)
- Cell attribution (mismatch debug context)
- Dirty flags, scroll state, fast-path analysis
The scheduler auto-enables instrumentation for the STRICT comparison render. No need for separate SILVERY_INSTRUMENT or SILVERY_CELL_DEBUG runs when diagnosing STRICT failures.
What Each Mode Catches (and Misses)
| Mode | Catches | Misses |
|---|---|---|
STRICT | Render phase bugs (wrong dirty flag evaluation, skipped nodes, wrong region clearing, scroll tier errors) | Output phase bugs, terminal interpretation bugs |
STRICT_TERMINAL=vt100 | changesToAnsi bugs where our parser disagrees with our generator (style transitions, cursor arithmetic) | Bugs where parser and generator agree but real terminals disagree (pending-wrap, \x1b[K in wrap state) |
STRICT_TERMINAL=xterm | Terminal interpretation bugs (xterm.js-specific: OSC 66, wide char cursor, buffer overflow) | Ghostty-specific bugs, bugs requiring accumulated state |
STRICT_TERMINAL=ghostty | Ghostty-specific terminal interpretation bugs | xterm.js-specific bugs |
STRICT_ACCUMULATE | Compounding errors across multiple frames | Same limitation as vt100 (self-referential parser) |
Hierarchy: STRICT (buffer) → STRICT_TERMINAL=vt100 (ANSI) → STRICT_TERMINAL=xterm (terminal) → STRICT_TERMINAL=all (cross-backend).
CI strategy:
- PR CI:
SILVERY_STRICT_TERMINAL=vt100(fast, zero deps) - Nightly:
SILVERY_STRICT_TERMINAL=xterm(independent emulator) - Scheduled/allow-fail:
SILVERY_STRICT_TERMINAL=ghostty(WASM, has known grapheme bugs) - Local debug:
SILVERY_STRICT_TERMINAL=all
Diagnostic Workflow
Start with STRICT:
SILVERY_STRICT=1 bun vitest run ...catches any incremental vs fresh render divergence immediately.Write a failing test: If fuzz found it, extract the seed. If user-reported, construct a
withDiagnostics(createBoardDriver(...))test with minimal reproduction steps.Read the mismatch error: The enhanced error includes cell values, node path, dirty flags, scroll context, and fast-path analysis. This tells you exactly which node diverged and why it was skipped.
Check instrumentation:
SILVERY_INSTRUMENT=1enables stats collection. View withDEBUG=silvery:content DEBUG_LOG=/tmp/silvery.log(loggily output) or programmatically viaglobalThis.__silvery_content_detail. Useful for understanding whether too many or too few nodes rendered.Check the five critical formulas:
layoutChanged,contentAreaAffected,contentRegionCleared,skipBgFill,childrenNeedFreshRenderinrenderNodeToBuffer(render-phase.ts). If any is wrong, the cascade propagates errors to the entire subtree.Text bg inheritance: Text nodes inherit bg via
nodeState.inheritedBg(threaded top-down, O(1) per node), not buffer reads. Viewport clears and region clears still affect buffer state, which matters for thegetCellBglegacy fallback (used by scroll indicators). If your fix clears a region, verify it clears to the correct bg (usuallynullto match fresh render state).
Symptom → Check Cross-Reference
| Symptom | Check First |
|---|---|
| Stale background color persists | bgDirty flag; nodeState.inheritedBg (threaded top-down); is region being cleared? |
| Border artifacts after color change | stylePropsDirty vs contentAreaAffected distinction; border-only change should NOT cascade |
| Scroll glitch (content jumps/disappears) | Scroll tier selection; Tier 1 unsafe with sticky; Tier 3 needs stickyForceRefresh |
| Children blank after parent changes | childrenNeedFreshRender → childHasPrev=false; is viewport clear setting childHasPrev correctly? |
| Absolute child disappears | Two-pass rendering order; absolute children need ancestorCleared=false in second pass |
| Content correct initially, wrong after navigation | Incremental rendering bug; SILVERY_STRICT=1 will catch it |
| Colors wrong but characters correct (garble) | Output phase: diffBuffers row pre-check skipping true-color Map diffs; check rowExtrasEquals |
| Text bg different from parent Box bg | nodeState.inheritedBg; check if ancestor Box has backgroundColor; check region clearing |
| Flickering on every render | Check layoutChangedThisFrame flag; verify syncPrevLayout runs at end of render phase |
| Stale overlay pixels after shrink (black area) | clearExcessArea not called; check contentRegionCleared + forceRepaint interaction |
| CJK/wide char garble, text shifts right | bufferToAnsi cursor drift: wide char without continuation at col+1. Run SILVERY_STRICT_TERMINAL=xterm |
| Flag emoji garble at wide terminals (200+ cols) | bufferToAnsi/changesToAnsi cursor re-sync after wide chars; wrapTextSizing |
| Stale chars in ancestor border/padding after child shrinks | Descendant overflow: clearExcessArea clips to immediate parent. Use hasDescendantOverflowChanged() for recursive detection |