Skip to content

Browserless rewrite + 0.0.35 backwards-compat nudge - #3

Merged
phil-scott-78 merged 49 commits into
mainfrom
total-rewrite
Jun 7, 2026
Merged

Browserless rewrite + 0.0.35 backwards-compat nudge#3
phil-scott-78 merged 49 commits into
mainfrom
total-rewrite

Conversation

@phil-scott-78

Copy link
Copy Markdown
Owner

Summary

This PR brings the total-rewrite branch — VCR# is now fully browserless. The native in-process PTY + from-scratch VT500 engine (VtScreen) is the only backend; ttyd, Playwright, Chromium, and xterm.js are gone. The only external runtime dependency is FFmpeg, and only for GIF/MP4/WebM (SVG/PNG need nothing).

The final commit on top adds a small but user-facing nudge:

  • README and docs homepage now point anyone depending on pre-rewrite behavior at vcr 0.0.35 (dotnet tool install --global vcr --version 0.0.35).
  • Refreshed the docs sample tapes and regenerated SVGs for the browserless backend.
  • New sample tapes (landing, multiple-formats, sleeping, static-widget, vt-styles) plus a shared samples/docs/vcr.toml.

What's in the rewrite (highlights across the 43 commits)

  • Browserless core — real shell over ConPTY (Windows) / Unix PTY, parsed by an in-process VT500 engine, rasterized directly to frames.
  • Native rendering — raster (ImageSharp), text SVG (static + animated, no font needed), FFmpeg video writer.
  • Event-driven capture — snapshot after every output chunk + key pacing, so transient TUI states are never missed.
  • VT engine — full SGR model (dim, reverse, conceal, strike, overline, truecolor), DEC modes, alt screen, grapheme/emoji width, line-drawing; conformance-tested against a vendored libvterm corpus.
  • Cross-platform — Unix PTY backend; everything above the IPtyProcess seam is platform-neutral.
  • Config + docsvcr.toml preset layer, vcr migrate, and a rewritten docs site for the browserless backend.

Test plan

  • dotnet build VcrSharp.sln
  • dotnet test
  • Docs build: cd docs/VcrSharp.Docs && dotnet run -- build

phil-scott-78 and others added 30 commits June 5, 2026 22:04
Capture the last frame timestamp before collapsing the trailing static
tail so totalDuration runs to the true end of the recording. The tail
collapse still drops frames identical to the final frame (keeping the
file small), but stretching the final visibility interval to the real
end means a looping SVG pauses on the final frame for the captured hold
(the EndBuffer window) instead of flashing it and instantly restarting.
…rate tool

Phase 1 of the ground-up redesign: a config layer that kills the copy-pasted
house-style block duplicated across every tape, implemented entirely in Core +
Cli (no Infrastructure/engine changes, near-zero runtime risk).

Grammar (Core):
- `Use <preset>` pulls a named preset from a discovered vcr.toml
- `Run "cmd"` is sugar for Type + Enter + Wait (desugared before capture)
- `Exec name arg` macro form expands against a [macro] template

Config engine (Core/Config):
- VcrConfigReader: focused vcr.toml subset reader ([preset.X], inherits,
  [macro], typed values) with walk-up discovery
- PresetResolver: expands Use/macros/Run, derives Output, layered precedence
  (defaults < preset < tape Set < CLI --set), clear errors for unknown
  presets/macros/settings and inheritance cycles
- TapeMigrator + `vcr migrate`: clusters tapes into profiles by
  equivalence-fit, mines a shared `base` + per-profile child presets
  (inherits), and rewrites tapes to `Use` them. Every rewrite is
  equivalence-checked (identical realized config + action sequence) before it
  is declared safe; drift leaves the tape untouched.

Wired into `vcr <tape>` and `vcr validate`.

Verified against the real consumers (dry run): all 53 spectre-docs tapes
migrate with 0 skipped and 302 duplicated Set lines removed; the landing fork
is correctly split into its own base-inheriting preset. samples/ (the
showroom) correctly declines to over-extract.

59 new tests; 351/351 passing.
…gs, add HoldDuration/Animate

Shrinks the felt TAPE surface without changing any rendered output (the removal
itself lands in a later release):

- SettingDeprecations registry: 27 settings + 6 commands that are dead, no-ops
  on the SVG path, or superseded now emit a non-fatal deprecation warning with
  replacement guidance when used (e.g. "Set Margin … has no effect on SVG
  output", "Set StaticOutput … use 'Set Animate false'"). They still parse and
  apply, so existing tapes are unaffected.
- Forward names added as aliases: HoldDuration (= EndBuffer) and Animate
  (= inverse of StaticOutput). Defaults are unchanged, so behavior is identical.
- `vcr record` and `vcr validate` surface the warnings.

The output-changing default flips (static-by-default, FitToContent on,
Screenshot-always-settle) are intentionally NOT included here: they alter all 53
committed SVGs and require a golden-diff regen pass with the full
ttyd+Chromium+ffmpeg toolchain.

6 new tests; 357/357 passing.
…recation list

Reorients Phase 2 around the real workflow: animation is a first-class, common
case (not a minority to opt into), and there are two independent sizing modes.

- New settings as the clear front-ends (defaults unchanged, zero output change):
    Set Mode animated|static   (animated default; static = run, settle, capture
                                the final screen — today's StaticOutput)
    Set Size grid|fit          (grid default = exact Cols×Rows at FontSize;
                                fit = crop to content + scale — today's FitToContent)
  Set now accepts bare-word values (Set Mode animated) via the grammar.
- Removed the short-lived `Animate` boolean in favor of `Mode`.
- Walked back the over-eager deprecations: Loop/LoopCount/LoopOffset/PlaybackSpeed/
  MaxColors/CursorBlink/Margin/MarginFill/WindowBarSize/BorderRadius/LetterSpacing/
  LineHeight/FontFamily/SvgIntrinsicSize/SvgMetadata and the settle-timing knobs are
  all first-class again (they matter for animated + raster + sizing). The deprecation
  list is now just the genuinely-dead/renamed: Width, Height, StaticOutput→Mode,
  FitToContent→Size, WaitPattern, CssVariables; commands Require/Source/Copy/Paste
  (Hide/Show kept — legitimate animation frame-gating).
- Deprecation collector also lints Mode/Size values for typos.
- vcr migrate now emits `mode`/`size` in generated presets instead of the
  deprecated StaticOutput/FitToContent.

Decision context: rendered table.tape and status.tape both ways — static+crop is a
clear win for plain widgets (370→182px, 7× smaller, no content lost) but collapses
live widgets like status to their final line (the spinner narrative lives only in
the animation). So static stays opt-in, not the default.

9 deprecation/alias tests rewritten; 360/360 passing.
Hard-removes the surface that has zero uses across all real tapes:
- Settings: Width, Height, WaitPattern, CssVariables (the underlying fields stay
  as internal fallbacks/behavior; only the `Set <name>` tape syntax is gone).
- Commands: Require, Source, Copy, Paste (AST, tokens, tokenizer keywords, parser
  rules, VcrSession skip-list, the RecordCommand Require check, and tests).

Deliberately KEPT (animation-first + migration compat):
- StaticOutput / FitToContent still parse as deprecated aliases so `vcr migrate`
  can read legacy tapes and rewrite them into Mode/Size. (8 + 4 real uses.)
- Loop/LoopCount/PlaybackSpeed/MaxColors/Framerate/CursorBlink/Margin*/Hide/Show
  remain first-class — they matter for animated and raster output.

Verified: `vcr migrate` still reads the 53 spectre tapes (0 skipped, 206 lines
removed on the Examples dir, all equivalence-checked). 351/351 tests pass.
…yd/Chromium)

Proves the SVG path never needed a browser. New pipeline:
  command -> ConPTY pseudoconsole -> VT/ANSI parser -> TerminalContent -> SvgRenderer

- VtScreen (Core/Terminal): a VT/ANSI parser + cell grid. Handles printable text
  with autowrap + wide-char width, C0 controls (CR/LF/BS/HT), SGR (16/256/truecolor
  + bold/italic/underline), cursor (CUP/CUU-D/CHA), erase (ED/EL/ECH), insert/delete
  chars (ICH/DCH), and OSC/DCS/charset escapes (consumed, not leaked). Emits cells in
  the exact SvgRenderer encoding (#rrggbb / palette-index-string / null; width 1/2/0).
  22 unit tests, zero PTY/browser dependency.
- ConPtyProcess (Infrastructure/Terminal): zero-dependency Windows ConPTY P/Invoke
  (CreatePseudoConsole + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE). Spawns a child, exposes
  its VT output stream.
- NativeTerminalRenderer: spawn `pwsh -Command "<cmd>"` in a ConPTY, drain into
  VtScreen, snapshot the settled grid. TERM/COLORTERM env nudges Spectre to truecolor.
- `vcr native-snap "<cmd>" -o out.svg` [experimental]: renders with no ttyd and no
  Chromium, cropping to content (Size fit).

Verified end to end on Windows: `echo` and a real Spectre.Console table (rounded
borders + truecolor) render browserless with correct colors, box-drawing (as vector
paths), and text. ECH (ESC[NX) was the key fix for Spectre's cursor-addressed redraws.

Remaining: cursor-redraw fidelity long-tail, animation (poll grid at framerate), Unix
PTY, and the golden-diff gate vs the browser path. 372/372 tests pass.
…ard (Phase 0)

Foundation for turning the proof-of-concept VtScreen into a complete,
Windows-Terminal-class VT engine with its own consumable test suite.

- New engine assembly src/VcrSharp.Terminal (VtScreen moved here). References
  VcrSharp.Core only for the TerminalContent/TerminalCell snapshot contract;
  leaf-ifying that out is a tracked follow-up.
- New test project tests/VcrSharp.Terminal.Tests (xUnit v3 + Shouldly); the 21
  existing parser tests moved here intact.
- Vendored 43 libvterm *.test conformance files (MIT, LICENSE + ATTRIBUTION
  preserved). LibVtermHarness reimplements the grid-coupled subset of upstream's
  run-test.pl in C# (no GPL esctest, no C build dep): decodes Perl-quoted PUSH
  byte strings, feeds VtScreen, evaluates ?cursor/?screen_row/?screen_chars/
  ?screen_text/movecursor; unmodelled callback/pen assertions count as skipped.
- Gates: EngineDoesNotCrash (theory over all 43 files) + FuzzTests = live
  robustness gate (0 errors). Scoreboard [Fact] writes docs/vt-conformance-
  scoreboard.md. ParserCorrectnessTests = 6 skipped P1/P2/P4 acceptance targets.
- docs/vt-engine-conformance.md: the audit, conformance matrix, phased plan,
  locked scope decisions, and adversarially-verified corrections.

Baseline: 230/385 evaluated assertions pass (59.7%), 875 skipped (breadth
frontier), 0 errors. Repo: Core.Tests 351 green, Terminal.Tests 71 green +6
skipped, no regressions.
…tate machine

Pure-routing refactor behind the same Feed() API; all existing semantics
(SGR colors, cursor, erase, edit) unchanged, only the byte routing corrected.

- Full DFA: ground / escape / escape-intermediate / csi-{entry,param,intermediate,
  ignore} / osc-string / string-consume (DCS/SOS/PM/APC), replacing the ad-hoc
  7-state machine.
- ESC now always cancels and re-enters escape handling, so the byte after a
  string's terminating ESC is no longer dropped (fixes OSC-then-CSI: ESC]…ESC[31m
  now applies the SGR instead of printing "31m").
- CAN/SUB abort from any state; 8-bit C1 introducers recognised (robustness;
  ConPTY emits 7-bit); DEL ignored; C0 executes in non-string states.
- CSI parameters: colon sub-parameters (38:2::r:g:b and 4:n styled underline),
  empty params tracked as -1, bounded to 32 (overlong-list safety).
- SGR ApplyExtendedColor handles both the legacy `38;2;r;g;b` and ITU colon
  `38:2::r:g:b` / `38:5:n` forms.

Un-skips the two P1 acceptance targets (OscFollowedByCsi, ColonSubParams).
Scoreboard 59.7% -> 61.3% (230->236 pass, 0 errors), zero per-file regressions
(28state_dbl_wh 0%->75%, 90vttest_01-movement-3 20%->80%). All 21 original
parser tests + fuzz gate green.
…tore, resets

- Scroll region (DECSTBM) with margin-aware LineFeed/IND/NEL/RI and SU/SD;
  bottom-margin LF scrolls only [top,bottom]; lines leaving a screen-anchored
  region go to a bounded scrollback ring (ED 3 / RIS clear it).
- IL/DL (insert/delete line) within the margins; cursor column left unchanged to
  match xterm/libvterm (ECMA-48's move-to-home is explicitly not done by xterm).
- CNL/CPL/HPA/HPR/VPR cursor moves; CUU/CUD now stop at the scroll margins.
- Tab stops: default every 8; HT/CHT/CBT honor them; HTS sets, TBC clears.
- DECSC/DECRC (ESC 7/8) + SCOSC/SCORC (CSI s/u) save+restore cursor & pen.
- DECSTR (CSI ! p) soft reset and RIS (ESC c) hard reset; intermediate byte now
  tracked so CSI ! p is distinguished.

Un-skips the P2 acceptance targets (ScrollRegion, InsertLine). Scoreboard
61.3% -> 72.2% (236->278 pass), 0 errors, 0 regressions. Notable: 11state_movecursor
74%->97%, 27state_reset & 21state_tabstops -> 100%, 90vttest_01-movement-1 12%->68%.
- Alternate screen buffer (?47 / ?1047 / ?1049, plus ?1048 cursor save/restore):
  second buffer with the correct clear/save semantics per variant.
- DEC mode model: DECTCEM (?25, real CursorVisible in the snapshot now),
  DECAWM (?7 autowrap, with no-wrap overwrite-at-margin), DECOM (?6 origin mode:
  CUP/VPA/DECSTBM relative to the scroll region), DECSCNM (?5, tracked).
- ANSI modes: IRM (4, insert) and LNM (20, newline). Other private modes
  (mouse/paste/focus/…) are tracked so DECRQM stays honest and they never corrupt.
- DECSTR/RIS now also reset the mode state; RIS clears both buffers.

Un-skips the final two acceptance targets (AltScreen, HideCursor) — zero skipped
acceptance tests remain. Scoreboard 72.2% -> 80.5% (278->310 pass), 0 errors, 0
regressions. Notable: 90vttest_01-movement-2 1->20, 15state_mode 8->12,
22state_save 7->10. Core.Tests still 351 green (no SVG-cursor ripple).
…g marks

- Cell/pen model widened: dim (2), blink (5/6/25), reverse (7/27), conceal (8/28),
  strikethrough (9/29), overline (53/55), styled underline (4 / 4:n / 21 / 24 as a
  level), and separate underline color (58/59 incl. colon + semicolon forms).
  TerminalCell gains the matching fields (IsUnderline stays derived for renderers).
- DEC special-graphics charset: ESC ( 0 / ESC ) 0 designation + SO/SI (G0/G1)
  with the full 0x5F..0x7E line-drawing table.
- Combining marks (Mn/Mc/Me) merge into the preceding base cell instead of
  consuming a new cell.
- DECSC/DECRC and alt-screen save/restore now carry the whole pen via a Pen record.
- Harness grades ?pen (bold/italic/underline-level/blink/reverse); cell colors
  remain ungraded by design.

SvgRenderer rendering of the new attrs (reverse/dim/strike/overline/styled
underline) is a tracked product follow-up; IsUnderline keeps existing SVG output
unchanged.

Scoreboard 80.5% -> 82.4% with a *larger* denominator (385->421 evaluated as ?pen
converts from skipped; skips 875->839). 30state_pen 0->31, 22state_save 10->15,
0 errors, 0 regressions. Core.Tests 351 green.
VtScreen.Resize(cols, rows): top-left-anchored, no reflow. Growing adds blank
rows/cols; shrinking truncates blank edges, but scrolls the top into the
scrollback ring just enough to keep the cursor and bottom content on screen
(matching xterm/libvterm sans reflow). Cursor clamped, margins reset to full,
tab stops rebuilt. Harness RESIZE now drives Resize instead of recreating.

Scoreboard 82.4% -> 87.2% (347->367 pass). 63screen_resize 18->31,
16state_resize 2->5, 69screen_reflow 13->17. 0 errors, 0 regressions.
- Engine: CSI j (HPB) and CSI k (VPB) cursor moves; a deferred wrap (phantom
  cursor at the right margin) now resolves to a real column when a wider resize
  makes room (libvterm "doesn't cancel the phantom").
- Harness: PUSH/expected now evaluate Perl string repetition ("x"xN) and
  concatenation ('.'), matching run-test.pl — recovers real signal that was
  showing as decode false-failures.
- Scoreboard now samples failures from every failing file (not just screen tests).

Scoreboard 87.2% -> 89.5% (367->377 pass). 12state_scroll & 11state_movecursor
-> 100%, 16state_resize 71%->100%, 63screen_resize 86%->92%. 0 errors, 0 regressions.
- DECALN (ESC # 8): fill the screen with 'E' — the vttest alignment pattern the
  movement tests rely on (they DECALN then erase to carve the frame).
- Harness: ?screen_text expects UTF-8 *bytes* (0xc3,0x81,…) whereas
  ?screen_row/?screen_chars use codepoints; decode each accordingly. Fixes the
  unicode false-failures (engine output was already correct).

Scoreboard 89.5% -> 92.2% (377->388 pass). 90vttest_01-movement-1 68%->100%,
61screen_unicode 50%->100%. 0 errors, 0 regressions.
Living spec now tracks the climb (59.7% -> 92.2%), the disposition of every
remaining failing bucket (reflow deferred, resize-pop not grid-gradable,
double-width out of scope, DECSLRM/selective-erase deferred, vttest written-space),
and the deferred follow-ups (SvgRenderer attr rendering, engine leaf-ification,
P6 pipeline wire-in, P7 forkpty).
…-framerate

Wires the conformant VT engine into the real render pipeline for animation:
NativeTerminalRenderer.RunAndCaptureAsync runs a command in a ConPTY and polls the
live VtScreen at a configurable framerate, collecting a de-duplicated stream of
timestamped TerminalStateWithTime snapshots (a drain thread feeds the parser, the
poll loop snapshots it, a lock prevents frame tearing). Those states feed the
existing SvgRenderer.RenderAnimatedAsync directly — no ttyd, no Chromium, no PNG
frames.

`vcr native-snap "<cmd>" --animate [--framerate N] -o out.svg` produces the
animated SVG (leading-blank/trailing-static trimmed, timestamps rebaselined, final
frame held to the true end). Verified end to end on Windows: a 5-line colored loop
recorded to a 2.9 KB animated SVG with correct SMIL <animate> timing and content.

Remaining P6: ITerminalBackend seam + VcrSession auto-fallback so full .tape
recordings prefer native; native GIF/MP4 (rasterize states) and P7 forkpty later.
…, no browser

The playback model is the whole point of VCR, and it now runs browserless. The tape
commands already target ITerminalPage/IFrameCapture, so the win is implementing
those over ConPTY + VtScreen — every existing command works unchanged:

- NativeTerminalPage (ITerminalPage): Type/Key/Modifier write bytes to the
  pseudo-console stdin and the REAL shell echoes them back through the parser;
  Wait polls the live grid for a regex; Copy/Paste use an in-process clipboard;
  Hide/cursor + snapshots honored.
- NativeKeyMap: browser-style key codes -> terminal byte sequences (Enter=\r,
  ArrowUp=ESC[A, Ctrl+C=0x03, Alt=ESC-prefix, Shift+Tab=ESC[Z, F-keys, …).
- NativeFrameCapture (IFrameCapture): Screenshot -> SvgRenderer static; buffer
  settle polls the grid.
- NativeRecordingSession: interactive pwsh in a ConPTY, a drain thread feeds the
  parser, a poll loop snapshots the grid at framerate honoring Hide/Show
  (IsCapturing); runs the parsed tape via the existing ExecutionContext; Exec runs
  as live startup input; encodes an animated SVG via NativeSvgWriter (shared with
  native-snap --animate).
- `vcr native-play <tape> -o out.svg` (SVG only for now; GIF/MP4 noted as needing
  rasterisation).

Verified end to end on Windows: a Set/Type/Enter/Sleep tape recorded to a 29 KB
animated SVG (68 frames, simulated typing echoed by the live shell). Tests green
(Terminal 81, Core 351), 0 regressions.
#3 crop)

Measured native vs ttyd/Chromium on the spectre-docs tapes; closed the two
integration gaps the comparison surfaced:

- #2 No shell prompt / echoed command line. Pure-Exec showcase tapes (no
  Type/Key) now run the command non-interactively (`pwsh -Command "<exec>"`,
  like ttyd's startup) so only the program output is captured — no prompt, no
  command echo. Type-driven tapes keep the interactive REPL (+ PSReadLine quiet).
- #3 Static output now crops to the measured content extent (matching the
  browser's static path) instead of leaving the default-width canvas padded with
  blank space. landing-table: 1183x580 -> 551x172, matching the browser's ~600px.
- Settle uses the browser's InactivityTimeout/MaxWaitForInactivity budget plus a
  minWait so it no longer settles on the pre-output prompt during `dotnet run`
  startup (was capturing a near-empty screen).

Verified: landing-table and tree now render essentially identically to the
browser (content, colors, box-drawing, tight canvas, no prompt); cli-quickstart
typing playback still works (69 frames). Tests green.
…block fills (#1)

The native path fell back to FontSize*0.55 (=12.1px at size 22) for cell width —
a fractional value, so every per-cell background rect landed on a sub-pixel
boundary and adjacent same-color cells (bar-chart/breakdown/progress fills)
anti-aliased independently, showing hairline vertical seams. The browser path
passes the font's measured integer advance (13px) and renders smooth.

Fix: round the estimated cell metrics to whole pixels and use ~0.6 (a truer
monospace advance) — round(22*0.6)=13, round(22*1.2)=26, exactly matching the
browser's measured cell here. Block fills now tile cleanly. The measured-value
path (browser) is unchanged; renderer tests set ActualCellWidth explicitly so
they're unaffected. Verified: bar-chart bars render smooth, identical to ttyd.
…ation selectors

Investigated emoji + multi-diacritic width (the grid drifts when per-codepoint
width disagrees with the terminal's grapheme width). Found: stacked diacritics and
variation selectors already merged correctly (P3 combining handling, since VS16 is
a nonspacing mark), but emoji skin-tone modifiers (U+1F3FB–1F3FF) and ZWJ
sequences were over-counted as separate width-2 cells, shifting everything after.

Fix: treat ZWJ (U+200D), skin-tone modifiers, and variation selectors as
zero-width extenders that attach to the base cell, and join the emoji *following*
a ZWJ into the same grapheme (👨‍👩‍👧 = one width-2 cell). 4 new GraphemeWidthTests
cover stacked diacritics, skin tone, ZWJ family, and VS16. Terminal.Tests 85 green;
libvterm scoreboard unchanged.
…capture leak)

Survey across all 53 spectre-docs tapes (native animated vs committed browser
asset, comparing SVG text content) showed the interactive cli tapes had
native-only noise: fragments of the "Set-PSReadLineOption …; Clear-Host" setup we
typed into the live shell were being captured. Move that setup into the shell's
own startup via `pwsh -NoExit -Command "…"` so it runs before any capture and
never enters the recording. Showcase (Exec) tapes already had no prompt/leak.

Survey result: 53/53 render with zero failures; 35/52 tapes are a 100% SVG-text
match; the rest 86–96% on settled content, with residual deltas being prompt
wrap, animation timing (progress %), and interactive selection-frame sampling —
not content divergence.
Matched the browser's shell setup exactly — ShellConfiguration["pwsh"] applies
`Set-PSReadLineOption -HistorySaveStyle SaveNothing -PredictionSource None;
function prompt { '> ' }` at startup. Native now does the same via
`pwsh -NoExit -Command "<same init>"`, so the interactive prompt is a clean '> '
instead of the default full path (PS B:\...>), and no setup is typed into the
capture. Resolves the "path on the shell rather than >" gap.

Settled-content match on the interactive cli tapes jumped to ~100%
(cli-error-handling 93->100, cli-customizing-help 91->100, cli-defining-arguments
100, cli-quickstart 95). Showcase tapes unaffected (already 100%, no prompt).
Native sees every byte, so it should be SMOOTHER than the browser, not just close.
Two changes so no on-screen state is ever dropped:

- Event-driven capture: the drain thread snapshots after EVERY output chunk
  (de-duplicated) instead of sampling on a fixed framerate timer, so transient
  states between timer ticks are no longer lost. Capture is gated on after the
  shell-init setup and honors Hide/Show.
- Key pacing: a 24ms pause after each Key/Modifier so a TUI redraws (and the drain
  captures the new frame) before the next key. The browser gets this free from
  input-pipeline latency; native is instant, so rapid key bursts (e.g. four Downs
  with no Sleep, as in interactive-prompt-tutorial) collapsed into one frame.

Result: interactive-prompt-tutorial 86% -> 100% (all six cursor positions through
the multi-select list now captured); bar-chart/showcase unchanged at 100%.
Tests green (Terminal 85, Core 351).
…ration

Native previously hardcoded pwsh, so a `Set Shell "bash"` (or zsh/cmd/fish) tape
would run its commands in PowerShell. Now native builds its ConPTY command line
from the same ShellConfiguration the browser/ttyd path uses:

- interactive tapes reuse the shell's exact invocation (BuildTtydCommand: flags +
  clean '> ' prompt + init), so prompt/input match across backends;
- non-interactive (Exec-only) runs `<shell> <execFlag> "<cmds>"` and exits
  (pwsh/powershell get -NoLogo -NoProfile; cmd uses '&' as the separator);
- the shell's own environment (e.g. zsh PROMPT) is merged in too.

Native defaults to pwsh when Shell is unset (the generic fallback is bash, which
is wrong on Windows where ConPTY runs). pwsh tapes are byte-for-byte unchanged
(bar-chart/cli-quickstart verified). Shrinks the auto-fallback set by one trigger.
…no browser)

Native could only emit SVG; GIF/MP4 output forced a browser fallback. Now native
rasterizes each captured grid frame and feeds the sequence to FFmpeg:

- RasterRenderer: TerminalContent -> ImageSharp image (no new dependency — ImageSharp
  + SixLabors.Fonts are already in the tree). Same cell metrics + theme/256-palette
  resolution as the SVG path; glyphs from a system monospace font (Cascadia Mono /
  Consolas fallback). Full-block (█) cells are filled solid (not drawn as glyphs) and
  background rects use no anti-aliasing, so bar/breakdown/chart fills tile seamlessly.
  Handles bold/italic/underline/strike/overline/reverse/dim/conceal + cursor.
- NativeVideoWriter: renders frames to a temp dir, builds an FFmpeg concat manifest
  with per-frame durations, and encodes GIF (palettegen/paletteuse), MP4 (h264,
  even-dim pad), WebM (vp9), or a single PNG of the final frame.
- NativeRecordingSession routes .gif/.mp4/.webm/.png to the raster path; native-play
  accepts them (was SVG-only).

Verified on Windows: bar-chart -> smooth GIF/MP4 (h264 1066x208), tree/table/panel
PNG render faithfully (box-drawing, colors, attrs). Shrinks the auto-fallback set by
its biggest trigger. Tests green (Terminal 85, Core 351).
…acOS

Add UnixPtyProcess, the cross-platform sibling of ConPtyProcess, behind a
new IPtyProcess seam + PtyProcess.Start factory (Windows → ConPTY, Unix →
UnixPtyProcess). It opens a real PTY (posix_openpt/grantpt/unlockpt) and
launches the child as a session leader on the slave via posix_spawn with
POSIX_SPAWN_SETSID — not a managed forkpty, since forking a multi-threaded
CLR and running managed code in the child is unsafe; posix_spawn does the
fork+exec inside libc. The master fd is a duplex stream that maps EIO to a
clean EOF on child exit. Linux + macOS ioctl/open/setsid constants handled.

All three native consumers (NativeTerminalRenderer, NativeRecordingSession,
NativeTerminalPage) route through the seam. The unspecified-shell default is
now platform-aware (pwsh on Windows, bash on Unix), and the native-snap /
native-play Windows-only gates are removed. Parser + grid stay 100%
platform-neutral in VcrSharp.Terminal.

Also fix bare `Wait` after a non-interactive `Exec`: it defaults to the
shell-prompt pattern, but such a shell exits instead of re-prompting, so the
prompt never comes and the wait blocked to the 15s timeout and failed the
recording. NativeTerminalPage's wait loops now treat child-exit as
wait-satisfied (brief tail-settle, then proceed) — "the command is done."
Inert for interactive tapes (shell stays alive → prompt-matching unchanged).
Same on Windows and Unix.

Verified end-to-end on WSL: static + animated SVG, ANSI color, interactive
Type/Key with prompt-Wait, Exec capture + Wait-on-exit, GIF rasterization.
436 tests green (351 Core + 85 Terminal), 0 regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th raster

The native VT engine parses reverse video and stores it on every cell
(TerminalCell.IsReverse), and RasterRenderer already applies it for
GIF/MP4/WebM/PNG. SvgRenderer ignored it: its run coalescing, StyleRun,
row hash, and CSS-class builder only looked at bold/italic/underline.

The result was a silent cross-format bug — the same tape recorded to
.svg dropped every status bar, selected row, and reverse-video prompt
that GIF/PNG rendered correctly.

Render reverse in the SVG path, mirroring RasterRenderer:
- background rect paints the run's (resolved) foreground color, so a
  reversed run draws a block even with no explicit background;
- text paints the run's (resolved) background color, falling back to the
  theme bg/fg defaults when the run leaves them unset;
- a reversed run drops its foreground CSS class so the inline fill is not
  overridden by a class rule (CSS beats presentation attributes);
- reversed blank runs survive the trailing-blank trim (a reversed space
  is a visible foreground-colored block, not whitespace);
- IsReverse joins the run-coalescing key and the animation row hash so
  reversed runs split correctly and re-render across frames.

Verified end to end via native-snap: default reverse swaps to the theme
fg/bg, and `7;31m` paints a red block with bg-colored text.
RasterRenderer already dims with fg.WithAlpha(0.55f); the SVG path
ignored IsDim. Emit a .dim{fill-opacity:0.55} class so faint text — git
hashes/hints, bat line numbers, delta, fzf hints, lipgloss faint — reads
as de-emphasized instead of full-strength.

fill-opacity is independent of how the fill is set, so the class composes
with a foreground color class, an inline RGB fill, and reverse video
(dimming the swapped text, leaving the block at full opacity — matching
RasterRenderer, which dims fg after the reverse swap). IsDim joins the
run-coalescing key and the animation row hash.
RasterRenderer already strikes (an overstrike line at mid-cell); the SVG
path ignored IsStrikethrough.

Underline, strikethrough, and overline are all text-decoration lines and
must be able to combine, but separate CSS classes that each set
text-decoration conflict (last rule wins, so underline+strike would lose
one line). Emit them as a single combined text-decoration presentation
attribute per run via BuildTextDecoration, and route the existing
underline through it too (dropping the now-unused .underline class).

This commit adds underline + line-through; overline follows next. The
decoration follows the run's text fill (currentColor), so it inherits
reverse-swapped colors correctly. IsStrikethrough joins the
run-coalescing key and the animation row hash.

Verified via native-snap: `9m` strikes, `4m` underlines, and the two
compose into text-decoration="underline line-through".
RasterRenderer already draws a top line for overline; the SVG path
ignored IsOverline. Add "overline" to the combined text-decoration
declaration introduced for strikethrough, so it composes with underline
and line-through in a single attribute. IsOverline joins the
run-coalescing key and the animation row hash.

Verified via native-snap: `53m` emits text-decoration="overline" and
overline+underline combine into "underline overline".
RasterRenderer paints the background then skips the glyph for concealed
cells; the SVG path drew the glyph regardless. Skip the glyph in the text
pass while leaving the already-painted background and the cell width
intact, so concealed text renders blank without disturbing layout.

Reverse+conceal therefore yields a solid block (reversed background, no
glyph) — matching RasterRenderer. IsConceal joins the run-coalescing key
and the animation row hash.

Verified via native-snap: `8m`-concealed text is absent from the output
while following text keeps its position.
…tion

A one-glance way to confirm reverse / dim / strikethrough / overline /
conceal (plus bold / italic / underline and combinations) survive into
the SVG output at parity with the raster (GIF/PNG) path.

- samples/vt-styles.ps1 emits each attribute via raw SGR sequences.
- samples/vt-styles.tape Execs it and renders an SVG.

Run: vcr native-play samples/vt-styles.tape -o vt-styles.svg

Verified end to end through the native ConPTY + VT path: the reversed
rows swap to a foreground-colored block, strike/overline/underline (and
their combination) emit the right text-decoration, dim reduces opacity,
and the concealed run is absent from the output.
The native path chose its launch shape from whether the tape had
Type/Key/Modifier commands ("interactive"). That mis-handled the mixed
case — launch an app via Exec, then drive it with Type/Key (e.g. an
interactive-prompt demo): it took the bare-REPL path and *typed* the
`dotnet run …` launch line into the shell, leaking the echoed command
into the recording.

Decide on Exec presence instead (ShouldUseBareRepl): a bare interactive
REPL is used ONLY when the tape has no Exec (there the typed command line
IS the demo and must show). Any tape WITH Exec launches it as the shell's
hidden foreground process; the launched app inherits the PTY, so Type/Key
sent afterward flow straight to it — no prompt to wait for, no launch
line echoed. Pure-showcase Exec tapes are unchanged.

BuildCommandLine/BuildUnixArgv/ShouldUseBareRepl made internal +
InternalsVisibleTo(VcrSharp.Core.Tests) so NativeLaunchTests can pin the
launch shape across the pure-interactive, pure-showcase, and mixed cases.
- .gitignore .native-demo/ (local scratch recordings) and the Claude Code
  local state files so they stop showing as untracked.
- Remove the root welcome.svg — it is generated output of
  samples/welcome.tape (Output welcome.svg), not referenced by any docs,
  and now ignored so it will not creep back in.
… remove browser

Switch the default `vcr <tape>` (and snap/capture/record) entirely onto the
browserless native path (ConPTY / Unix-PTY + the VtScreen VT engine) and delete
the Playwright/Chromium + ttyd stack.

Front door:
- Repoint RecordCommand, SnapCommand, CaptureCommand to NativeRecordingSession;
  fold the experimental native-snap/native-play into the consolidated commands.
- Reimplement `vcr record` natively over a PTY (raw-console passthrough ->
  InputToTapeConverter); no Chromium.
- Native parity: PNG Screenshot via RasterRenderer, ScreenshotFiles tracking,
  `Output frames/` directory.
- Delete PlaywrightBrowser/TerminalPage/KeyboardMapper/TtydProcess, the browser
  VcrSession, the now-dead VideoEncoder/Encoders cluster + frame pipeline, the
  Microsoft.Playwright package, and the Integration.Tests project.

Adversarial hardening (empirical fuzzing + multi-agent audit; 12 fixes):
- ConPtyProcess: TerminateProcess orphaned child on teardown (Unix parity);
  WaitForSingleObject liveness (exit-code-259 collision); free HPC/HGLOBAL when
  Start fails.
- VtScreen: saturate CSI param before multiply (10+ digit overflow -> negative).
- NativeKeyMap: emit xterm CSI modifier params for Ctrl/Alt/Shift + arrows /
  Home/End/Delete / function keys (was silently dropping modifiers).
- NativeTerminalRenderer.Signature: include strike/overline/dim/conceal/blink/
  underline-style so distinct frames are not deduped away.
- NativeRecordingSession: validate terminal size (1..10000); preserve the
  settled final frame when MaxFrames is hit.
- RasterRenderer: pixel-budget guard (no OOM); actionable error when no fonts.
- NativeVideoWriter: clamp PlaybackSpeed>0 and MaxColors>=4 for FFmpeg.

Tests: Core 301, Terminal 89 (added param-saturation, key-modifier, and
dedup-signature regressions). Build clean.
…ckend

The total-rewrite branch replaced the ttyd + Playwright/Chromium stack with
an in-process PTY (ConPTY / Unix PTY) driving a from-scratch VT500 engine
(VcrSharp.Terminal / VtScreen) rasterized directly. The docs described a
codebase that no longer exists; this rewrites them against the real one.

- CLAUDE.md: new architecture (4 projects incl. VcrSharp.Terminal), native
  recording + interactive flows, removed Copy/Paste/Require/Source, added
  Use/Run/Exec-macro, Mode/Size/HoldDuration settings, vcr.toml/preset/macro
  layer, and accurate deps (FFmpeg only for GIF/MP4/WebM; no ttyd/Playwright).
- README: corrected install (no ttyd), SVG-first quick example, full CLI
  table incl. vcr migrate, browserless framing.
- docs site (Pennington/Diataxis):
  - delete obsolete ttyd-interaction.md and ttyd-options.md
  - new: reference/cli-commands, reference/vcr-toml, how-to/presets,
    explanation/browserless-engine
  - rewrite: getting-started, tape-syntax, configuration-options,
    interactive-recording, screenshots, ffmpeg-options
  - fix stale wording, remove Set CssVariables/Width/Height, fix order
    collision, Width/Height -> Cols/Rows in cli-overrides
- samples: drop the nonexistent `vcr native-play` verb from vt-styles.*

Verified: `dotnet run -- build` (24 pages, exit 0) and `diag warnings` clean.
The Playwright package is gone, so the NU5111 NoWarn (it only suppressed
warnings from Playwright's package scripts) is dead. Remove it and the
matching note in CLAUDE.md.

Left SessionOptions.CssVariables alone: it is not dead — it backs a working,
unit-tested SvgRenderer feature (fill:var(--vcr-*) + :root palette), only the
`Set CssVariables` tape binding was intentionally removed.
…and nullability

- Simplified AST command creation by removing redundant `Ast.` prefixes (e.g., `new Ast.RunCommand` -> `new RunCommand`).
- Streamlined method signatures by removing unused parameters (`dropped`, `totalDuration`) and replacing obsolete cases.
- Improved code readability by replacing unnecessary null checks and consolidating optional object handling.
- Adjusted rendering logic to align with simpler assumptions (e.g., `IsNotBlank` checks).
- Introduced local variable extraction for terminal I/O (`ptyOutput`, `ptyInput`) to reduce repeated accesses.
- Removed historical or unused fields (e.g., `_reverseScreen`, `padY`).
- General cleanup: consistent composite handling (e.g., combined `text-decoration`), implicit conversions, and improved nullability annotations across methods affecting terminal, CLI, and rendering.

These refinements reduce dead code paths and improve maintainability without altering behavior.
Box/line glyphs (e.g. a truecolor gradient progress bar) emit one <path>
per cell, and a gradient defeats run-coalescing, so a single recording can
reach tens of thousands of paths. Each path carried identical boilerplate
(stroke-linecap="square", fill="none", an explicit stroke-width) plus a
redundantly-split d, which dominated file size.

- CustomGlyphRenderer: light/heavy box lines now reference shared .bl/.bh
  CSS classes (width + square cap + fill:none live there once) and coalesce
  an opposite half-pair into a single full-span line command.
- SvgRenderer: define .bl/.bh once in <style>; swap the per-row-per-frame
  MD5 dedup hash for a single-pass FNV-1a (allocation/CPU only, same key).
- NativeSvgWriter: honor the configured Framerate when emitting SVG by
  thinning captured states to the cap (keeps the settled final frame).

progress.tape: 2,820,748 -> 1,416,971 bytes (-50%); ~23 KB gzipped.
Within a style segment (one color/weight), a run of the same horizontally-
tileable glyph is now emitted as ONE element spanning the run instead of one
path/rect per cell: solid lines (- = with light/heavy/double weight) become a
single full-width stroke, and full-cell-width blocks (full block, the
horizontal half/eighth bands, and the shades) become a single rect. Corners,
partial-width blocks, quadrants, dashed lines and powerline are left per-cell.

Runs only form across cells with identical style, so a per-cell gradient is
unaffected; merging abutting block rects also removes their sub-pixel seams.
Output is visually identical (same outer extents, solid interiors).

Measured (animated SVG, browserless native path):
  progress  1,416,971 -> 384,086  (-73%)
  table       130,187 ->  46,761  (-64%)
  rule         28,458 ->   4,922  (-83%)
  panel        60,409 ->  21,838  (-64%)
  tree         77,359 ->  28,925  (-63%)
  calendar    139,623 ->  86,805  (-38%)
Add a backwards-compatibility note to the README and docs homepage pointing users who depend on pre-rewrite behavior at vcr 0.0.35. Also refreshes the docs sample tapes and regenerated SVGs for the browserless backend, adds new sample tapes (landing, multiple-formats, sleeping, static-widget, vt-styles) and a shared samples/docs/vcr.toml.
# Conflicts:
#	src/VcrSharp.Infrastructure/Rendering/Encoders/SvgEncoder.cs
The browserless rewrite removed Playwright and VcrSharp.Integration.Tests, but the .NET workflow still ran the (now-missing) playwright.ps1 install step, failing CI. Remove that step and clean up stale Playwright mentions in dotnet-releaser.toml and CONTRIBUTING.md (also correcting the project tree to the current layout).
Clears the Node 20 deprecation warning on actions/checkout@v4 and aligns setup-dotnet to v5 across both workflows.
The drain-thread lambda closed over the mutable locals capturing/lastSignature (also reassigned by the command loop), tripping ReSharper's 'captured variable is modified in the outer scope'. Move the shared capture state (frames, stopwatch, signature, capturing flag, lock) into a CaptureState holder so the lambda captures one never-reassigned reference. Behavior is unchanged; all access stays under the same lock.
Rename the Native* classes (and their files) to unprefixed names — the prefix distinguished them from the removed browser/Playwright backend, which no longer exists, so it carried no information: NativeTerminalPage->TerminalPage, NativeFrameCapture->FrameCapture, NativeKeyMap->KeyMap, NativeInteractiveRecorder->InteractiveRecorder, NativeTerminalRenderer->TerminalRenderer, NativeRecordingSession->RecordingSession, NativeVideoWriter->VideoWriter, NativeSvgWriter->SvgWriter, NativeFixupTests->TerminalFixupTests, NativeLaunchTests->LaunchTests.

Also rename ShellConfiguration.BuildTtydCommand->BuildLaunchCommand and scrub stale comments/XML-doc/CLI output that contrasted "native" against the removed browser/Playwright/Chromium/ttyd/xterm.js stack. Legitimate VT terminology (xterm CSI, TERM=xterm-256color, xterm 256-color palette, libvterm conformance, SVG cross-browser) is kept.

Delete docs/vt-engine-conformance.md (an obsolete browser/native build plan) and clean up every dangling reference to it; the conformance scoreboard generator no longer points at it.
@phil-scott-78
phil-scott-78 merged commit f9cd9e1 into main Jun 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant