diff --git a/README.md b/README.md index fb26232b..f7291a41 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,10 @@ Every session is backed by a real PTY (`node-pty`) and an append-only event log. Rendering uses Ghostty's terminal engine through two interchangeable backends (`--renderer`): -- **`libghostty-vt`** — Ghostty's native VT engine, bound into Node. Fast, browser-free semantic snapshots and `wait` checks. Also powers the dashboard. -- **`ghostty-web`** (default) — a headless web build of Ghostty driven by Playwright/Chromium. Adds pixel PNG screenshots and WebM video. +- **`libghostty-vt`** — Ghostty's native VT engine, bound into Node. It is the default for semantic snapshots, render-backed `wait` checks, and screen hashes when the optional native package is available. It also powers the dashboard. +- **`ghostty-web`** — a headless web build of Ghostty driven by Playwright/Chromium. It is the default for visual PNG screenshots and WebM video, and it remains the automatic semantic fallback when `libghostty-vt` is unavailable. -`ghostty-web` is a _reference_ renderer: it shows what a pinned Ghostty build draws, not a pixel-for-pixel guarantee of any particular native terminal window. That tradeoff is deliberate. The renderer sits behind an adapter, so native backends can be added later without changing the CLI contract. +`ghostty-web` is a _reference_ visual renderer: it shows what a pinned Ghostty build draws, not a pixel-for-pixel guarantee of any particular native terminal window. That tradeoff is deliberate. The renderer sits behind an adapter, so additional backends can be used without changing the CLI contract. Set `--renderer ghostty-web`, `AGENT_TTY_RENDERER=ghostty-web`, or `config.json` `defaultRenderer` to restore legacy all-`ghostty-web` behavior. ## Where it came from @@ -141,6 +141,7 @@ See [`docs/AGENT-SKILLS.md`](./docs/AGENT-SKILLS.md). `agent-tty` is `0.4.3` and focused on reliable, isolated, reviewable terminal and TUI automation through a stable CLI. - Linux and macOS are tier-1; Windows is tier-2 and not CI-tested. +- Semantic snapshots and render-backed waits prefer the optional `libghostty-vt` backend when it is available, then fall back to `ghostty-web`. - Screenshots and WebM video depend on Playwright/Chromium and the `ghostty-web` backend. - `run` is best for shell setup and command injection; it does not capture a child command's structured output or exit status. - Apache-2.0, runs entirely locally, no account or SaaS. diff --git a/RELEASE.md b/RELEASE.md index fa6bd23e..a7d900b1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -9,7 +9,7 @@ For per-release changes, see [`CHANGELOG.md`](./CHANGELOG.md). For release mecha ## Supported capabilities - Reliable isolated session lifecycle management: `create`, `inspect`, `destroy`, and `gc` all work against isolated agent-tty homes. -- Renderer-backed screenshots, semantic snapshots, and WebM export for reviewer-visible proof artifacts. +- Renderer-backed screenshots, semantic snapshots, and WebM export for reviewer-visible proof artifacts; semantic operations prefer `libghostty-vt` when available, while visual artifacts use `ghostty-web`. - The `run` command for robust in-session command execution without having to simulate long shell setup scripts as manual keystrokes. - `doctor --json` with isolation-aware diagnostics for home resolution, renderer prerequisites, and screenshot viability. - An append-only event log that remains the canonical replay/export source of truth. @@ -17,7 +17,7 @@ For per-release changes, see [`CHANGELOG.md`](./CHANGELOG.md). For release mecha ## Explicitly out of scope -- Native renderer backends such as Ghostty native or kitty. +- Additional native renderer backends beyond the shipped `libghostty-vt` semantic renderer, such as kitty or platform terminal automation. - Mouse input support. - Remote or networked sessions. - An MCP wrapper. @@ -27,7 +27,7 @@ For per-release changes, see [`CHANGELOG.md`](./CHANGELOG.md). For release mecha ## Known limitations -- The renderer is the `ghostty-web` reference backend, not a native-terminal parity guarantee. +- Semantic operations may use `libghostty-vt`, but visual screenshots and WebM remain `ghostty-web` reference artifacts, not a native-terminal parity guarantee. - `run` completion detection relies on shell-visible echo of an injected boundary marker. - Screenshots and WebM export require Playwright/Chromium to be installed and discoverable. - The reviewed LazyVim workflow currently assumes Neovim `>= 0.11.2` plus its usual prerequisites; older Neovim builds are out of contract for that scenario. diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index 78aa892d..8895320b 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -21,7 +21,7 @@ This design intentionally describes a **general product**, not a Mux-specific im ## Current shipped status -The current `0.3.x` line is centered on reliable, isolated, reviewable terminal and TUI automation. The shipped surface includes `run` for robust in-session command execution, renderer/browser-path handling that respects isolated-home workflows, and isolation-aware `doctor --json` diagnostics on top of lifecycle, snapshot, screenshot, and export work. Larger asks such as native renderers, mouse input, remote/network sessions, MCP wrapping, and broader semantic TUI automation remain intentionally deferred. +The current shipped line is centered on reliable, isolated, reviewable terminal and TUI automation. The shipped surface includes `run` for robust in-session command execution, split semantic/visual renderer defaults, renderer/browser-path handling that respects isolated-home workflows, and isolation-aware `doctor --json` diagnostics on top of lifecycle, snapshot, screenshot, and export work. Larger asks such as additional native renderers, mouse input, remote/network sessions, MCP wrapping, and broader semantic TUI automation remain intentionally deferred. The repository now ships the first three milestones of this design plus Weeks 4–7 of CLI/artifact/lifecycle hardening, config/rendering/platform closeout, contract/introspection reconciliation, and Week 7 contract/doc ratification: @@ -53,10 +53,10 @@ The recommended v1 shape is: 3. **TypeScript/Node** implementation 4. **One session-host process per terminal session**, not a global daemon 5. **`node-pty`** for PTY/process control -6. **`ghostty-web`** as the default reference renderer -7. **Playwright** as the screenshot / replay harness +6. **`libghostty-vt`** as the preferred semantic renderer when available, with **`ghostty-web`** as the visual reference renderer and semantic fallback +7. **Playwright** as the screenshot / replay-video harness 8. **Event-log-as-truth** architecture so screenshots, snapshots, and recordings can be replayed deterministically -9. **Renderer adapter interface** from day one so native renderers can be added later without redesigning the CLI +9. **Renderer adapter interface** from day one so renderer defaults can evolve without redesigning the CLI ## Why this shape @@ -156,29 +156,30 @@ That lets v1: - render videos from replay, - and debug failures after the fact. -### 5) Reference renderer now, native renderers later +### 5) Semantic and visual renderers stay separated -V1 uses `ghostty-web` as a reference renderer for: +V1 uses two Ghostty-backed renderer paths by default: -- semantic snapshots, -- deterministic screenshots, -- deterministic video replay. +- `libghostty-vt` for semantic snapshots, screen hashes, and render-backed waits when the optional native package is available, +- `ghostty-web` for deterministic screenshots and deterministic video replay, +- `ghostty-web` again as the semantic fallback when native rendering is unavailable. -The architecture reserves native backends for later: +The architecture still reserves additional native backends for later: - WezTerm-like native automation, -- Ghostty native automation, +- platform-specific terminal automation, - platform-specific compatibility runs. ## Tiered truth model `agent-tty` should treat terminal truth as layered rather than singular. -| Layer | Source of truth | What it answers | -| ---------------------- | --------------------------- | --------------------------------------------------------- | -| Execution truth | PTY + event log | What bytes, signals, and resize events actually occurred? | -| Reference visual truth | `ghostty-web` replay/render | What does a pinned reference renderer show? | -| Native visual truth | Future native adapter | What does a real platform terminal show? | +| Layer | Source of truth | What it answers | +| ---------------------- | ---------------------------------- | --------------------------------------------------------- | +| Execution truth | PTY + event log | What bytes, signals, and resize events actually occurred? | +| Semantic renderer truth | `libghostty-vt` or fallback replay | What terminal cells/text does Ghostty's VT state expose? | +| Reference visual truth | `ghostty-web` replay/render | What does a pinned reference renderer show? | +| Native visual truth | Future native adapter | What does a real platform terminal show? | This prevents v1 from pretending reference rendering is identical to native platform rendering. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 11aa6ea0..c81b92c1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -35,13 +35,14 @@ Affected commands usually include: Check `doctor --json` for: +- `libghostty_vt_available` (preferred semantic renderer and dashboard) - `playwright_available` - `browser_cache_accessible` - `browser_launch` -- `ghostty_web_available` +- `ghostty_web_available` (visual renderer and semantic fallback) - `screenshot_viable` -If these fail in CI or a container, install Chromium during setup and make sure the cache is readable by the process running `agent-tty`. +If the browser-backed checks fail in CI or a container, install Chromium during setup and make sure the cache is readable by the process running `agent-tty`. If `libghostty_vt_available` is skipped or unavailable and no renderer is explicitly configured, semantic commands should fall back to `ghostty-web`; use `--renderer ghostty-web` to make that choice explicit. If you have `AGENT_TTY_RENDERER=libghostty-vt` or Home `config.json` sets `defaultRenderer` to `libghostty-vt`, clear that explicit configuration or override it with `ghostty-web` on machines without the optional native package. ## Isolated Homes @@ -77,10 +78,9 @@ or install a GitHub Release tarball as described in [`INSTALL.md`](./INSTALL.md) ## Reference Rendering Caveat -`ghostty-web` is the reference renderer for snapshots, screenshots, and replay video. -It gives repeatable artifacts for review and automation, but it does not guarantee exact native-terminal pixel parity. +`libghostty-vt` is the preferred default for semantic snapshots and render-backed waits when the optional native package is available. `ghostty-web` remains the reference visual renderer for screenshots and replay video, and it is the automatic semantic fallback when native rendering is unavailable and no renderer override is set. -If a bug depends on a specific native terminal emulator, keep the `agent-tty` artifact as reference evidence and capture native-terminal evidence separately when needed. +These renderers give repeatable artifacts for review and automation, but they do not guarantee exact native-terminal pixel parity. If a bug depends on a specific native terminal emulator, keep the `agent-tty` artifact as reference evidence and capture native-terminal evidence separately when needed. ## Stray `%` at the End of Captured Output diff --git a/docs/USAGE.md b/docs/USAGE.md index c73ae00f..56a76a5d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -190,8 +190,8 @@ The Wait Baseline fixes stale-match only. It does **not** fix echo-match: a `wai ## Screenshots And Recording Exports -Screenshots and WebM export use the `ghostty-web` reference renderer through Playwright/Chromium. -Run `doctor --json` first in new environments. +Screenshots and WebM export use the `ghostty-web` reference visual renderer through Playwright/Chromium. +Semantic `snapshot`, screen-hash, and render-backed `wait` paths prefer `libghostty-vt` when the optional native package is available and fall back to `ghostty-web` otherwise. Run `doctor --json` first in new environments. ```bash agent-tty screenshot --profile reference-dark --json @@ -202,6 +202,8 @@ agent-tty record export --format webm --timing accelerated --out ./ WebM export replays with recorded wall-clock timing by default. Pass `--timing accelerated` (idle gaps clamped to 400ms) or `--timing max-speed` for a time-compressed video. +Use `--renderer ghostty-web`, `AGENT_TTY_RENDERER=ghostty-web`, or Home `config.json` `{ "defaultRenderer": "ghostty-web" }` to force legacy all-browser rendering. Use `--renderer libghostty-vt` only when you intentionally want semantic and screenshot requests routed through the native backend; WebM requests still record `ghostty-web` as the actual video producer. + `ghostty-web` provides reference visual truth for reviewable artifacts; it does not promise exact pixel parity with native terminals. ## Isolation diff --git a/docs/prd/screen-hash/PRD.md b/docs/prd/screen-hash/PRD.md index ee154ddf..fc3fb2b4 100644 --- a/docs/prd/screen-hash/PRD.md +++ b/docs/prd/screen-hash/PRD.md @@ -31,7 +31,7 @@ Snapshot results and matched **Render Wait** results gain an optional **Screen H - Add an optional **Screen Hash** field — a lowercase 64-character SHA-256 hex digest — to the snapshot result (both structured and text formats) and to the matched render-wait result. - In scope: a **Batch Step** record for a matched **Render Wait** step also carries the **Screen Hash**, mirrored from that step's render-wait result, so a batch run exposes the same content identity per wait step that a standalone wait does. - The **Screen Hash** is the SHA-256 of the canonical visible-text string: the visible lines joined by newline, exactly as the host's screen-stability compare and the text matcher already build it. The shared canonical-text **definition** — `visibleLines[].text` joined by `\n`, sourced only from the snapshot (never `backend.getVisibleText()` or `cells[]`) — is unchanged by adding the hash. Cursor position, text styles, and scrollback are excluded. -- Converging the two renderer backends on one canonical screen form (Phase 1) intentionally changes the **default** `ghostty-web` backend's stability and text-wait **comparand** on screens with grapheme clusters, interior blank-cell gaps, or non-ASCII trailing characters: the canonical form is exactly `rows` lines, each decoded with full grapheme clusters with blank/zero cells as `' '`, then right-trimmed of trailing ASCII spaces (`0x20`) only. This is a deliberate, narrow change pinned by characterization tests, not a free behavior-preserving add; on plain ASCII screens the comparand is unchanged. +- Converging the two renderer backends on one canonical screen form (Phase 1) intentionally changed the then-default `ghostty-web` backend's stability and text-wait **comparand** on screens with grapheme clusters, interior blank-cell gaps, or non-ASCII trailing characters: the canonical form is exactly `rows` lines, each decoded with full grapheme clusters with blank/zero cells as `' '`, then right-trimmed of trailing ASCII spaces (`0x20`) only. This was a deliberate, narrow change pinned by characterization tests, not a free behavior-preserving add; on plain ASCII screens the comparand was unchanged. - Extract one shared canonical-screen-text helper and route the **Screen Hash**, the host **Screen Stability** compare, and the text **Render Wait** matcher through it, so the three share a single definition and cannot diverge. - The hash is keyed on whether a result holds an **observed** **Semantic Snapshot**, not on whether the wait matched. A result carries the **Screen Hash** of the snapshot it observed: a matched live wait, a snapshot capture, and the offline host-unreachable fallback that still observed a latest snapshot (even when it returns `matched: false` because the **Screen Stability** duration could not be proven offline). The hash is omitted only when no snapshot was observed: a live wait that times out, a consecutive-failure giveup, or a replay error throw. - Do not surface the **Screen Hash** on inspection or any path that does not already render a **Semantic Snapshot**; computing it must never force a renderer bootstrap that would not otherwise happen. @@ -55,7 +55,7 @@ Good tests assert external behavior, not implementation details. - A styled or per-cell hash. Transient style churn would make such a hash flap; the **Screen Hash** is text-content identity only. - Pixel-level identity, and any **Screen Hash** on the **Screenshot Result**. A **Screenshot Result** carries only its pixel `sha256`; the content hash lives on the snapshot and wait results. The **Screen Hash** is the semantic counterpart to the pixel digest and the two are not interchangeable. - New wait semantics built on the hash (for example, "wait until the screen content changes"). v1 only exposes the field; any hash-driven wait is future scope. -- Any change to the screen-stability behavior **beyond** the Phase 1 renderer-convergence change described in the Implementation Decisions. The canonical-text definition and the shared single-source unify are behavior-preserving; the only intended behavior change is the default `ghostty-web` backend's comparand on grapheme / interior-gap / non-ASCII-trailing screens, pinned by characterization tests. No new wait semantics are added. +- Any change to the screen-stability behavior **beyond** the Phase 1 renderer-convergence change described in the Implementation Decisions. The canonical-text definition and the shared single-source unify are behavior-preserving; the only intended behavior change was the then-default `ghostty-web` backend's comparand on grapheme / interior-gap / non-ASCII-trailing screens, pinned by characterization tests. No new wait semantics are added. ## Further Notes diff --git a/dogfood/20260616-default-semantic-renderer/README.md b/dogfood/20260616-default-semantic-renderer/README.md new file mode 100644 index 00000000..a412783d --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/README.md @@ -0,0 +1,59 @@ +# Default semantic renderer proof bundle + +This bundle proves the split renderer default: semantic actions (`wait`, `snapshot`, and screen-hash producing paths) now default to `libghostty-vt` when the optional native package is available, while visual artifacts still default to `ghostty-web`. + +## What this proves + +- `expected-renderer.json` records the automatic semantic renderer detected in this workspace. In this capture it is `libghostty-vt`. +- `default-wait.json` was captured without `--renderer`, exercising the automatic semantic render-wait path; `default-wait-inspect.json` records the live renderer runtime immediately after that wait. +- `default-snapshot.json` was captured without `--renderer`; `default-snapshot-artifact.json` records the snapshot artifact metadata with `rendererBackend: "libghostty-vt"`. +- `default-screenshot.json` was captured without `--renderer` and reports `result.rendererBackend: "ghostty-web"`; the PNG is copied to `screenshots/default-screenshot.png`. +- `default-webm.json` was captured without `--renderer` and reports `result.metadata.rendererBackend: "ghostty-web"`; the WebM is copied to `videos/default-webm.webm`. +- `explicit-ghostty-web-snapshot.json` proves the legacy override path; `explicit-ghostty-web-snapshot-artifact.json` records `rendererBackend: "ghostty-web"`. +- `explicit-libghostty-vt-screenshot.json` proves explicit native screenshot requests still produce honest `ghostty-web` PNG metadata via fallback when native support is available; in fallback-only environments the replay script writes an explicit skipped-evidence JSON instead. +- `explicit-libghostty-vt-webm.json` proves explicit native WebM requests are accepted while the actual video producer remains `ghostty-web`. +- `default-cast.json` and `recordings/default.cast` keep a terminal recording of the session. + +## Bundle contents + +- `commands.sh` — self-contained replay script using an isolated `AGENT_TTY_HOME` from `mktemp -d` and the repo-local CLI via `npx tsx src/cli/main.ts`. +- `environment.txt`, `version.json`, `doctor.json`, `expected-renderer.json` — environment and capability evidence. +- `default-*.json` — default renderer behavior envelopes. +- `explicit-*.json` — explicit override behavior envelopes. +- `*-artifact.json` and `artifact-manifest.json` — artifact metadata used to verify semantic snapshot producers. +- `screenshots/*.png` — visual proof artifacts. +- `videos/*.webm` — video proof artifacts. +- `recordings/default.cast` — asciicast recording. +- `artifact-file-info.txt` and `artifact-sha256.txt` — file type and checksum evidence for copied artifacts. + +## How to reproduce + +From the repository root: + +```bash +bash dogfood/20260616-default-semantic-renderer/commands.sh +``` + +The script requires `git`, `jq`, `node`, `npm`, `npx`, and the installed project dependencies. Native `@coder/libghostty-vt-node` support is required only for the explicit native screenshot proof; when it is unavailable, the script still exercises automatic semantic fallback and writes skipped evidence for that native-only screenshot step. It never writes to `~/.agent-tty`; every CLI command uses the temporary `AGENT_TTY_HOME` created at startup. + +## Reviewer checks + +```bash +jq -r '.expectedSemanticRenderer' dogfood/20260616-default-semantic-renderer/expected-renderer.json +jq -r '.result.rendererRuntime.backend' \ + dogfood/20260616-default-semantic-renderer/default-wait-inspect.json +jq -r '.metadata.rendererBackend' \ + dogfood/20260616-default-semantic-renderer/default-snapshot-artifact.json \ + dogfood/20260616-default-semantic-renderer/explicit-ghostty-web-snapshot-artifact.json +jq -r '.result.rendererBackend' \ + dogfood/20260616-default-semantic-renderer/default-screenshot.json \ + dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-screenshot.json +jq -r '.result.metadata.rendererBackend' \ + dogfood/20260616-default-semantic-renderer/default-webm.json \ + dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-webm.json +file dogfood/20260616-default-semantic-renderer/screenshots/*.png +file dogfood/20260616-default-semantic-renderer/videos/*.webm +file dogfood/20260616-default-semantic-renderer/recordings/*.cast +``` + +Expected output in this workspace: semantic snapshot metadata starts with `libghostty-vt`, the explicit legacy snapshot reports `ghostty-web`, and all screenshot/WebM producer fields report `ghostty-web`. diff --git a/dogfood/20260616-default-semantic-renderer/artifact-file-info.txt b/dogfood/20260616-default-semantic-renderer/artifact-file-info.txt new file mode 100644 index 00000000..a79084cd --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/artifact-file-info.txt @@ -0,0 +1,5 @@ +screenshots/default-screenshot.png: PNG image data, 640 x 384, 8-bit/color RGB, non-interlaced +screenshots/explicit-libghostty-vt-screenshot.png: PNG image data, 640 x 384, 8-bit/color RGB, non-interlaced +videos/default-webm.webm: WebM +videos/explicit-libghostty-vt-webm.webm: WebM +recordings/default.cast: JSON data diff --git a/dogfood/20260616-default-semantic-renderer/artifact-manifest.json b/dogfood/20260616-default-semantic-renderer/artifact-manifest.json new file mode 100644 index 00000000..22be4c4a --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/artifact-manifest.json @@ -0,0 +1,143 @@ +{ + "version": 1, + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "artifacts": [ + { + "id": "01KV89ZCN99JFHWA7DZ0FDQV3V", + "kind": "snapshot", + "filename": "snapshot-5-structured.json", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "createdAt": "2026-06-16T13:29:47.434Z", + "metadata": { + "format": "structured", + "rendererBackend": "libghostty-vt", + "cols": 80, + "rows": 24, + "cursorRow": 2, + "cursorCol": 7 + } + }, + { + "id": "01KV89ZEH0G4DE7PXFB84D7AYN", + "kind": "screenshot", + "filename": "screenshot-5-reference-dark.png", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "createdAt": "2026-06-16T13:29:49.344Z", + "sha256": "d299aedfc3dbbd19a780700059ba49aeecf732a6131e8aff62f02675399d785c", + "metadata": { + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "pngSizeBytes": 6810, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } + }, + { + "id": "01KV89ZG1TQ0APYCT5N9PAZGRJ", + "kind": "snapshot", + "filename": "snapshot-5-text.json", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "createdAt": "2026-06-16T13:29:50.906Z", + "metadata": { + "format": "text", + "rendererBackend": "ghostty-web", + "cols": 80, + "rows": 24, + "cursorRow": 2, + "cursorCol": 7 + } + }, + { + "id": "01KV89ZHZE0SRFM00KCZP8D36Q", + "kind": "screenshot", + "filename": "screenshot-5-reference-dark.png", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "createdAt": "2026-06-16T13:29:52.878Z", + "sha256": "d299aedfc3dbbd19a780700059ba49aeecf732a6131e8aff62f02675399d785c", + "metadata": { + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "pngSizeBytes": 6810, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } + }, + { + "id": "01KV89ZTJ6HD0FK5AXT6730B7P", + "kind": "video", + "filename": "default-webm.webm", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 14, + "createdAt": "2026-06-16T13:30:01.670Z", + "sha256": "c84b8d29605eb19a546a18fec735c921e1882e7d0a569e22d09dcde24afc74f1", + "bytes": 26115, + "metadata": { + "format": "webm", + "outputPath": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6/dogfood/20260616-default-semantic-renderer/videos/default-webm.webm", + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "accelerated", + "rendererBackend": "ghostty-web", + "outputEventCount": 10, + "resizeEventCount": 0 + } + }, + { + "id": "01KV89ZZRFB9FEM6H378JEM9PF", + "kind": "video", + "filename": "explicit-libghostty-vt-webm.webm", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 14, + "createdAt": "2026-06-16T13:30:06.992Z", + "sha256": "33b7fd9f3f8b00f95fd0cb26b011134a7fa405b3295035528316159b92ab7cf1", + "bytes": 27083, + "metadata": { + "format": "webm", + "outputPath": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6/dogfood/20260616-default-semantic-renderer/videos/explicit-libghostty-vt-webm.webm", + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "accelerated", + "rendererBackend": "ghostty-web", + "outputEventCount": 10, + "resizeEventCount": 0 + } + }, + { + "id": "01KV8A016HZYKE7P6KK3EANNJ6", + "kind": "recording", + "filename": "default.cast", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 14, + "createdAt": "2026-06-16T13:30:08.466Z", + "sha256": "20777abc4d55e0506cc685c13f08f31a376b8dd2124f6f7c3101b5a7945c5296", + "bytes": 468, + "metadata": { + "format": "asciicast", + "outputPath": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6/dogfood/20260616-default-semantic-renderer/recordings/default.cast", + "width": 80, + "height": 24, + "title": "01KV89Z29JPZD4EG29CHER1MN8", + "timestamp": 1781616577, + "outputEventCount": 10, + "resizeEventCount": 0, + "markerCount": 0 + } + } + ] +} diff --git a/dogfood/20260616-default-semantic-renderer/artifact-sha256.txt b/dogfood/20260616-default-semantic-renderer/artifact-sha256.txt new file mode 100644 index 00000000..c8bca000 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/artifact-sha256.txt @@ -0,0 +1,5 @@ +d299aedfc3dbbd19a780700059ba49aeecf732a6131e8aff62f02675399d785c screenshots/default-screenshot.png +d299aedfc3dbbd19a780700059ba49aeecf732a6131e8aff62f02675399d785c screenshots/explicit-libghostty-vt-screenshot.png +c84b8d29605eb19a546a18fec735c921e1882e7d0a569e22d09dcde24afc74f1 videos/default-webm.webm +33b7fd9f3f8b00f95fd0cb26b011134a7fa405b3295035528316159b92ab7cf1 videos/explicit-libghostty-vt-webm.webm +20777abc4d55e0506cc685c13f08f31a376b8dd2124f6f7c3101b5a7945c5296 recordings/default.cast diff --git a/dogfood/20260616-default-semantic-renderer/commands.sh b/dogfood/20260616-default-semantic-renderer/commands.sh new file mode 100755 index 00000000..d7b58495 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/commands.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" +BUNDLE_DIR="$SCRIPT_DIR" +SCREENSHOTS_DIR="$BUNDLE_DIR/screenshots" +VIDEOS_DIR="$BUNDLE_DIR/videos" +RECORDINGS_DIR="$BUNDLE_DIR/recordings" +CLI=(npx tsx src/cli/main.ts --timeout-ms 120000) +FIXTURE=(npx tsx test/fixtures/apps/hello-prompt/main.ts) +PROMPT_TEXT='READY>' +ECHO_TEXT='Default semantic renderer proof' +AGENT_TTY_HOME="$(mktemp -d -t agent-tty-default-renderer.XXXXXX)" +export AGENT_TTY_HOME +export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--no-warnings" +SESSION_ID='' + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + printf 'missing required command: %s\n' "$1" >&2 + exit 1 + } +} + +assert_file_nonempty() { + local path="$1" + [[ -s "$path" ]] || { + printf 'expected non-empty file: %s\n' "$path" >&2 + exit 1 + } +} + +run_json_file() { + local output_path="$1" + shift + local tmp_path="$output_path.tmp" + "$@" > "$tmp_path" + jq . "$tmp_path" > "$output_path" + rm -f "$tmp_path" + jq -e '.ok == true' "$output_path" >/dev/null +} + +capture_json_var() { + local __resultvar="$1" + shift + local raw_json + local pretty_json + raw_json="$($@)" + pretty_json="$(printf '%s\n' "$raw_json" | jq .)" + printf '%s\n' "$pretty_json" | jq -e '.ok == true' >/dev/null + printf -v "$__resultvar" '%s' "$pretty_json" +} + +run_json_check_only() { + "$@" | jq -e '.ok == true' >/dev/null +} + +session_dir() { + printf '%s/sessions/%s\n' "$AGENT_TTY_HOME" "$SESSION_ID" +} + +artifact_manifest_path() { + printf '%s/artifacts/manifest.json\n' "$(session_dir)" +} + +copy_artifact_manifest() { + jq . "$(artifact_manifest_path)" > "$BUNDLE_DIR/artifact-manifest.json" +} + +write_latest_artifact() { + local kind="$1" + local output_path="$2" + jq --arg kind "$kind" ' + .artifacts | map(select(.kind == $kind)) | last + ' "$(artifact_manifest_path)" > "$output_path" +} + +assert_latest_artifact_backend() { + local kind="$1" + local expected_backend="$2" + jq -e --arg kind "$kind" --arg backend "$expected_backend" ' + (.artifacts | map(select(.kind == $kind)) | last | .metadata.rendererBackend) == $backend + ' "$(artifact_manifest_path)" >/dev/null +} + +cleanup() { + local exit_code=$? + set +e + if [[ -n "${SESSION_ID:-}" ]]; then + "${CLI[@]}" --home "$AGENT_TTY_HOME" destroy "$SESSION_ID" --json >/dev/null 2>&1 || true + fi + if [[ -n "${AGENT_TTY_HOME:-}" && -d "${AGENT_TTY_HOME:-}" ]]; then + rm -rf "$AGENT_TTY_HOME" + fi + exit "$exit_code" +} +trap cleanup EXIT + +require_command git +require_command jq +require_command node +require_command npm +require_command npx +require_command uname + +cd "$REPO_ROOT" +CAPTURE_GIT_STATUS="$(git status --short)" +CAPTURE_GIT_DIFF_SHA256="$(git diff --binary | sha256sum | awk '{print $1}')" + +mkdir -p "$SCREENSHOTS_DIR" "$VIDEOS_DIR" "$RECORDINGS_DIR" +rm -f "$BUNDLE_DIR"/*.json "$BUNDLE_DIR/environment.txt" +rm -f "$SCREENSHOTS_DIR"/*.png "$VIDEOS_DIR"/*.webm "$RECORDINGS_DIR"/*.cast + +EXPECTED_SEMANTIC_RENDERER="$({ + node --input-type=module <<'NODE' +try { + const mod = await import('@coder/libghostty-vt-node'); + if (typeof mod.createTerminal === 'function') { + console.log('libghostty-vt'); + } else { + console.log('ghostty-web'); + } +} catch { + console.log('ghostty-web'); +} +NODE +} | tail -n 1)" +printf '{"expectedSemanticRenderer":"%s"}\n' "$EXPECTED_SEMANTIC_RENDERER" | jq . > "$BUNDLE_DIR/expected-renderer.json" + +{ + printf '$ node --version\n%s\n\n' "$(node --version)" + printf '$ npm --version\n%s\n\n' "$(npm --version)" + printf '$ git rev-parse HEAD\n%s\n\n' "$(git rev-parse HEAD)" + printf '$ git log --oneline -n 1\n%s\n\n' "$(git log --oneline -n 1)" + printf '$ git status --short (before capture)\n%s\n\n' "${CAPTURE_GIT_STATUS:-clean}" + printf '$ git diff --binary | sha256sum (before capture)\n%s\n\n' "$CAPTURE_GIT_DIFF_SHA256" + printf '$ uname -a\n%s\n\n' "$(uname -a)" + printf '$ expected semantic renderer\n%s\n\n' "$EXPECTED_SEMANTIC_RENDERER" + printf '$ npx tsx src/cli/main.ts version --json\n' + "${CLI[@]}" version --json | jq . + printf '\n$ npx tsx src/cli/main.ts doctor --json\n' + "${CLI[@]}" --home "$AGENT_TTY_HOME" doctor --json | jq . +} > "$BUNDLE_DIR/environment.txt" + +run_json_file "$BUNDLE_DIR/version.json" "${CLI[@]}" version --json +run_json_file "$BUNDLE_DIR/doctor.json" "${CLI[@]}" --home "$AGENT_TTY_HOME" doctor --json + +CREATE_JSON='' +capture_json_var \ + CREATE_JSON \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" create --json --cwd "$REPO_ROOT" \ + --cols 80 --rows 24 --name default-semantic-renderer -- "${FIXTURE[@]}" +SESSION_ID="$(printf '%s\n' "$CREATE_JSON" | jq -er '.result.sessionId')" +printf '%s\n' "$CREATE_JSON" > "$BUNDLE_DIR/create.json" + +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" wait "$SESSION_ID" --json --text "$PROMPT_TEXT" --timeout 10000 +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" type "$SESSION_ID" --json "$ECHO_TEXT" +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" send-keys "$SESSION_ID" --json Enter +run_json_file "$BUNDLE_DIR/default-wait.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" wait "$SESSION_ID" --json --text "ECHO: $ECHO_TEXT" --timeout 10000 +run_json_file "$BUNDLE_DIR/default-wait-inspect.json" "${CLI[@]}" --home "$AGENT_TTY_HOME" inspect "$SESSION_ID" --json +jq -e --arg backend "$EXPECTED_SEMANTIC_RENDERER" '.result.rendererRuntime.backend == $backend' "$BUNDLE_DIR/default-wait-inspect.json" >/dev/null +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" wait "$SESSION_ID" --json --screen-stable-ms 250 --timeout 10000 + +run_json_file "$BUNDLE_DIR/default-snapshot.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" snapshot "$SESSION_ID" --format structured --json +assert_latest_artifact_backend snapshot "$EXPECTED_SEMANTIC_RENDERER" +write_latest_artifact snapshot "$BUNDLE_DIR/default-snapshot-artifact.json" + +run_json_file "$BUNDLE_DIR/default-screenshot.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" screenshot "$SESSION_ID" --hide-cursor --json +jq -e '.result.rendererBackend == "ghostty-web"' "$BUNDLE_DIR/default-screenshot.json" >/dev/null +DEFAULT_SCREENSHOT_SOURCE="$(jq -er '.result.artifactPath' "$BUNDLE_DIR/default-screenshot.json")" +assert_file_nonempty "$DEFAULT_SCREENSHOT_SOURCE" +cp "$DEFAULT_SCREENSHOT_SOURCE" "$SCREENSHOTS_DIR/default-screenshot.png" +assert_file_nonempty "$SCREENSHOTS_DIR/default-screenshot.png" + +run_json_file "$BUNDLE_DIR/explicit-ghostty-web-snapshot.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" --renderer ghostty-web snapshot "$SESSION_ID" --format text --json +assert_latest_artifact_backend snapshot ghostty-web +write_latest_artifact snapshot "$BUNDLE_DIR/explicit-ghostty-web-snapshot-artifact.json" + +if [[ "$EXPECTED_SEMANTIC_RENDERER" == 'libghostty-vt' ]]; then + run_json_file "$BUNDLE_DIR/explicit-libghostty-vt-screenshot.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" --renderer libghostty-vt screenshot "$SESSION_ID" --hide-cursor --json + jq -e '.result.rendererBackend == "ghostty-web"' "$BUNDLE_DIR/explicit-libghostty-vt-screenshot.json" >/dev/null + EXPLICIT_SCREENSHOT_SOURCE="$(jq -er '.result.artifactPath' "$BUNDLE_DIR/explicit-libghostty-vt-screenshot.json")" + assert_file_nonempty "$EXPLICIT_SCREENSHOT_SOURCE" + cp "$EXPLICIT_SCREENSHOT_SOURCE" "$SCREENSHOTS_DIR/explicit-libghostty-vt-screenshot.png" + assert_file_nonempty "$SCREENSHOTS_DIR/explicit-libghostty-vt-screenshot.png" +else + jq -n '{ok: true, command: "explicit-libghostty-vt-screenshot", result: {skipped: true, reason: "libghostty-vt optional renderer unavailable"}}' \ + > "$BUNDLE_DIR/explicit-libghostty-vt-screenshot.json" +fi + +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" type "$SESSION_ID" --json 'exit' +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" send-keys "$SESSION_ID" --json Enter +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" wait "$SESSION_ID" --json --exit --timeout 10000 + +run_json_file "$BUNDLE_DIR/default-webm.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" record export "$SESSION_ID" --format webm --timing accelerated --out "$VIDEOS_DIR/default-webm.webm" --json +jq -e '.result.metadata.rendererBackend == "ghostty-web"' "$BUNDLE_DIR/default-webm.json" >/dev/null +DEFAULT_WEBM_SOURCE="$(jq -er '.result.artifactPath' "$BUNDLE_DIR/default-webm.json")" +[[ "$DEFAULT_WEBM_SOURCE" == "$VIDEOS_DIR/default-webm.webm" ]] +assert_file_nonempty "$VIDEOS_DIR/default-webm.webm" + +run_json_file "$BUNDLE_DIR/explicit-libghostty-vt-webm.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" --renderer libghostty-vt record export "$SESSION_ID" --format webm --timing accelerated --out "$VIDEOS_DIR/explicit-libghostty-vt-webm.webm" --json +jq -e '.result.metadata.rendererBackend == "ghostty-web"' "$BUNDLE_DIR/explicit-libghostty-vt-webm.json" >/dev/null +EXPLICIT_WEBM_SOURCE="$(jq -er '.result.artifactPath' "$BUNDLE_DIR/explicit-libghostty-vt-webm.json")" +[[ "$EXPLICIT_WEBM_SOURCE" == "$VIDEOS_DIR/explicit-libghostty-vt-webm.webm" ]] +assert_file_nonempty "$VIDEOS_DIR/explicit-libghostty-vt-webm.webm" + +run_json_file "$BUNDLE_DIR/default-cast.json" \ + "${CLI[@]}" --home "$AGENT_TTY_HOME" record export "$SESSION_ID" --format asciicast --out "$RECORDINGS_DIR/default.cast" --json +CAST_SOURCE="$(jq -er '.result.artifactPath' "$BUNDLE_DIR/default-cast.json")" +[[ "$CAST_SOURCE" == "$RECORDINGS_DIR/default.cast" ]] +assert_file_nonempty "$RECORDINGS_DIR/default.cast" + +run_json_file "$BUNDLE_DIR/inspect.json" "${CLI[@]}" --home "$AGENT_TTY_HOME" inspect "$SESSION_ID" --json +copy_artifact_manifest + +( + cd "$BUNDLE_DIR" + file screenshots/*.png > artifact-file-info.txt + file videos/*.webm >> artifact-file-info.txt + file recordings/*.cast >> artifact-file-info.txt + sha256sum screenshots/*.png videos/*.webm recordings/*.cast > artifact-sha256.txt +) + +run_json_check_only "${CLI[@]}" --home "$AGENT_TTY_HOME" destroy "$SESSION_ID" --json +SESSION_ID='' + +printf 'dogfood bundle written to %s\n' "$BUNDLE_DIR" diff --git a/dogfood/20260616-default-semantic-renderer/create.json b/dogfood/20260616-default-semantic-renderer/create.json new file mode 100644 index 00000000..1ad2d586 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-06-16T13:29:37.564Z", + "result": { + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "createdAt": "2026-06-16T13:29:36.822Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/20260616-default-semantic-renderer/default-cast.json b/dogfood/20260616-default-semantic-renderer/default-cast.json new file mode 100644 index 00000000..883f182d --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/default-cast.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-06-16T13:30:08.471Z", + "result": { + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6/dogfood/20260616-default-semantic-renderer/recordings/default.cast", + "bytes": 468, + "sha256": "20777abc4d55e0506cc685c13f08f31a376b8dd2124f6f7c3101b5a7945c5296", + "capturedAtSeq": 14, + "durationMs": 17608, + "metadata": { + "width": 80, + "height": 24, + "title": "01KV89Z29JPZD4EG29CHER1MN8", + "timestamp": 1781616577, + "outputEventCount": 10, + "resizeEventCount": 0, + "markerCount": 0 + } + } +} diff --git a/dogfood/20260616-default-semantic-renderer/default-screenshot.json b/dogfood/20260616-default-semantic-renderer/default-screenshot.json new file mode 100644 index 00000000..893dda74 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/default-screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-06-16T13:29:49.348Z", + "result": { + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/agent-tty-default-renderer.NIhHYj/sessions/01KV89Z29JPZD4EG29CHER1MN8/artifacts/screenshot-5-reference-dark.png", + "pngSizeBytes": 6810, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "sha256": "d299aedfc3dbbd19a780700059ba49aeecf732a6131e8aff62f02675399d785c", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260616-default-semantic-renderer/default-snapshot-artifact.json b/dogfood/20260616-default-semantic-renderer/default-snapshot-artifact.json new file mode 100644 index 00000000..c836945f --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/default-snapshot-artifact.json @@ -0,0 +1,16 @@ +{ + "id": "01KV89ZCN99JFHWA7DZ0FDQV3V", + "kind": "snapshot", + "filename": "snapshot-5-structured.json", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "createdAt": "2026-06-16T13:29:47.434Z", + "metadata": { + "format": "structured", + "rendererBackend": "libghostty-vt", + "cols": 80, + "rows": 24, + "cursorRow": 2, + "cursorCol": 7 + } +} diff --git a/dogfood/20260616-default-semantic-renderer/default-snapshot.json b/dogfood/20260616-default-semantic-renderer/default-snapshot.json new file mode 100644 index 00000000..1cb34b9d --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/default-snapshot.json @@ -0,0 +1,114 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-06-16T13:29:47.438Z", + "result": { + "format": "structured", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "cols": 80, + "rows": 24, + "cursorRow": 2, + "cursorCol": 7, + "isAltScreen": false, + "visibleLines": [ + { + "row": 0, + "text": "READY> Default semantic renderer proof" + }, + { + "row": 1, + "text": "ECHO: Default semantic renderer proof" + }, + { + "row": 2, + "text": "READY>" + }, + { + "row": 3, + "text": "" + }, + { + "row": 4, + "text": "" + }, + { + "row": 5, + "text": "" + }, + { + "row": 6, + "text": "" + }, + { + "row": 7, + "text": "" + }, + { + "row": 8, + "text": "" + }, + { + "row": 9, + "text": "" + }, + { + "row": 10, + "text": "" + }, + { + "row": 11, + "text": "" + }, + { + "row": 12, + "text": "" + }, + { + "row": 13, + "text": "" + }, + { + "row": 14, + "text": "" + }, + { + "row": 15, + "text": "" + }, + { + "row": 16, + "text": "" + }, + { + "row": 17, + "text": "" + }, + { + "row": 18, + "text": "" + }, + { + "row": 19, + "text": "" + }, + { + "row": 20, + "text": "" + }, + { + "row": 21, + "text": "" + }, + { + "row": 22, + "text": "" + }, + { + "row": 23, + "text": "" + } + ], + "screenHash": "63f713a31c9ee5a4c004fba7983dab96ef5352804cd0c2de408f5c3570ea03a5" + } +} diff --git a/dogfood/20260616-default-semantic-renderer/default-wait-inspect.json b/dogfood/20260616-default-semantic-renderer/default-wait-inspect.json new file mode 100644 index 00000000..4db38f38 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/default-wait-inspect.json @@ -0,0 +1,51 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-06-16T13:29:44.276Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "createdAt": "2026-06-16T13:29:36.822Z", + "updatedAt": "2026-06-16T13:29:37.550Z", + "status": "running", + "command": ["npx", "tsx", "test/fixtures/apps/hello-prompt/main.ts"], + "cwd": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6", + "name": "default-semantic-renderer", + "shell": "/bin/bash", + "term": "xterm-256color", + "cols": 80, + "rows": 24, + "creationCols": 80, + "creationRows": 24, + "hostPid": 3127342, + "childPid": 3127397, + "exitCode": null, + "exitSignal": null + }, + "eventCount": 6, + "uptime": 7453, + "lastEventSeq": 5, + "terminationCategory": "running", + "artifacts": { + "total": 0, + "byKind": {}, + "missingCount": 0, + "health": "no-artifacts" + }, + "usedOfflineReplay": false, + "rendererRuntime": { + "backend": "libghostty-vt", + "mode": "live-host", + "status": "healthy", + "profile": "reference-dark", + "booted": true, + "bootInFlight": false + }, + "host": { + "cliVersion": "0.4.3", + "rpcSocketPath": "/tmp/agent-tty/55fb6b14/40123e969ae3" + }, + "eventLogBytes": 616 + } +} diff --git a/dogfood/20260616-default-semantic-renderer/default-wait.json b/dogfood/20260616-default-semantic-renderer/default-wait.json new file mode 100644 index 00000000..f2999a10 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/default-wait.json @@ -0,0 +1,14 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-06-16T13:29:42.928Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "ECHO: Default semantic renderer proof", + "cursorRow": 2, + "cursorCol": 7, + "capturedAtSeq": 5, + "screenHash": "63f713a31c9ee5a4c004fba7983dab96ef5352804cd0c2de408f5c3570ea03a5" + } +} diff --git a/dogfood/20260616-default-semantic-renderer/default-webm.json b/dogfood/20260616-default-semantic-renderer/default-webm.json new file mode 100644 index 00000000..c8e5e05a --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/default-webm.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-06-16T13:30:01.673Z", + "result": { + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6/dogfood/20260616-default-semantic-renderer/videos/default-webm.webm", + "bytes": 26115, + "sha256": "c84b8d29605eb19a546a18fec735c921e1882e7d0a569e22d09dcde24afc74f1", + "capturedAtSeq": 14, + "durationMs": 17608, + "metadata": { + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "accelerated", + "rendererBackend": "ghostty-web", + "outputEventCount": 10, + "resizeEventCount": 0 + } + } +} diff --git a/dogfood/20260616-default-semantic-renderer/doctor.json b/dogfood/20260616-default-semantic-renderer/doctor.json new file mode 100644 index 00000000..7fd64cec --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/doctor.json @@ -0,0 +1,142 @@ +{ + "ok": true, + "command": "doctor", + "timestamp": "2026-06-16T13:29:35.518Z", + "result": { + "ok": true, + "checks": { + "environment": [ + { + "name": "node-runtime", + "status": "pass", + "message": "Node 26.2.0 ok", + "durationMs": 0 + }, + { + "name": "cwd-access", + "status": "pass", + "message": "cwd read/write: /home/coder/.mux/src/agent-terminal/vt-impl-5ds6", + "durationMs": 2 + }, + { + "name": "temp-dir", + "status": "pass", + "message": "temp dir ok: /tmp", + "durationMs": 1 + }, + { + "name": "home_isolation", + "status": "pass", + "message": "agent-tty home is isolated from system home: /tmp/agent-tty-default-renderer.NIhHYj", + "durationMs": 0 + }, + { + "name": "home-writable", + "status": "pass", + "message": "home writable: /tmp/agent-tty-default-renderer.NIhHYj", + "durationMs": 1 + }, + { + "name": "pty-spawn", + "status": "pass", + "message": "spawned /home/coder/.local/share/mise/installs/node/26.2.0/bin/node", + "durationMs": 32 + }, + { + "name": "socket-viable", + "status": "pass", + "message": "socket ok: /tmp/agent-tty/55fb6b14/95689a30d574", + "durationMs": 3 + }, + { + "name": "artifact-atomicity", + "status": "pass", + "message": "atomic rename ok: /tmp/agent-tty-default-renderer.NIhHYj/sessions/doctor-3126662-mqgoibi9-3/artifacts", + "durationMs": 1 + }, + { + "name": "event-log-writable", + "status": "pass", + "message": "append ok: /tmp/agent-tty-default-renderer.NIhHYj/sessions/doctor-3126662-mqgoibia-5/events.jsonl", + "durationMs": 1 + } + ], + "renderer": [ + { + "name": "playwright_available", + "status": "pass", + "message": "available", + "durationMs": 1 + }, + { + "name": "browser_cache_accessible", + "status": "pass", + "message": "browser cache accessible: /home/coder/.cache/ms-playwright", + "durationMs": 0 + }, + { + "name": "browser_launch", + "status": "pass", + "message": "chromium launches", + "durationMs": 109 + }, + { + "name": "ghostty_web_available", + "status": "pass", + "message": "WASM available", + "durationMs": 82 + }, + { + "name": "screenshot_viable", + "status": "pass", + "message": "viable", + "durationMs": 153 + }, + { + "name": "libghostty_vt_available", + "status": "pass", + "message": "@coder/libghostty-vt-node exposes createTerminal()", + "durationMs": 1 + } + ] + }, + "capabilities": [ + { + "name": "snapshot", + "status": "available", + "reason": "libghostty-vt semantic renderer available", + "detail": "@coder/libghostty-vt-node exposes createTerminal()" + }, + { + "name": "wait", + "status": "available", + "reason": "libghostty-vt semantic renderer available", + "detail": "@coder/libghostty-vt-node exposes createTerminal()" + }, + { + "name": "screenshot", + "status": "available", + "reason": "renderer smoke checks passed", + "detail": "playwright_available: available; browser_launch: chromium launches; ghostty_web_available: WASM available; screenshot_viable: viable" + }, + { + "name": "record-export-asciicast", + "status": "available", + "reason": "built-in capability", + "detail": "available without external renderer dependencies" + }, + { + "name": "record-export-webm", + "status": "available", + "reason": "browser-backed export dependencies available", + "detail": "playwright_available: available; browser_launch: chromium launches; ghostty_web_available: WASM available" + }, + { + "name": "dashboard", + "status": "available", + "reason": "libghostty-vt native module available", + "detail": "@coder/libghostty-vt-node exposes createTerminal()" + } + ] + } +} diff --git a/dogfood/20260616-default-semantic-renderer/environment.txt b/dogfood/20260616-default-semantic-renderer/environment.txt new file mode 100644 index 00000000..e4135aa7 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/environment.txt @@ -0,0 +1,213 @@ +$ node --version +v26.2.0 + +$ npm --version +11.13.0 + +$ git rev-parse HEAD +5b13076afa422ec9b6a0c66c20b21fd4d24d181e + +$ git log --oneline -n 1 +5b13076 fix: address renderer default review findings + +$ git status --short (before capture) +clean + +$ git diff --binary | sha256sum (before capture) +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + +$ uname -a +Linux aaaaaaa 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux + +$ expected semantic renderer +libghostty-vt + +$ npx tsx src/cli/main.ts version --json +{ + "ok": true, + "command": "version", + "timestamp": "2026-06-16T13:29:31.103Z", + "result": { + "cliVersion": "0.4.3", + "protocolVersion": "0.1.0", + "rendererBackends": [ + "ghostty-web", + "libghostty-vt" + ], + "runtime": { + "node": "v26.2.0", + "platform": "linux", + "arch": "x64" + }, + "capabilities": [ + { + "name": "snapshot", + "status": "available" + }, + { + "name": "wait", + "status": "available" + }, + { + "name": "screenshot", + "status": "available" + }, + { + "name": "record-export-asciicast", + "status": "available" + }, + { + "name": "record-export-webm", + "status": "available" + }, + { + "name": "dashboard", + "status": "available" + } + ] + } +} + +$ npx tsx src/cli/main.ts doctor --json +{ + "ok": true, + "command": "doctor", + "timestamp": "2026-06-16T13:29:32.658Z", + "result": { + "ok": true, + "checks": { + "environment": [ + { + "name": "node-runtime", + "status": "pass", + "message": "Node 26.2.0 ok", + "durationMs": 0 + }, + { + "name": "cwd-access", + "status": "pass", + "message": "cwd read/write: /home/coder/.mux/src/agent-terminal/vt-impl-5ds6", + "durationMs": 2 + }, + { + "name": "temp-dir", + "status": "pass", + "message": "temp dir ok: /tmp", + "durationMs": 1 + }, + { + "name": "home_isolation", + "status": "pass", + "message": "agent-tty home is isolated from system home: /tmp/agent-tty-default-renderer.NIhHYj", + "durationMs": 1 + }, + { + "name": "home-writable", + "status": "pass", + "message": "home writable: /tmp/agent-tty-default-renderer.NIhHYj", + "durationMs": 3 + }, + { + "name": "pty-spawn", + "status": "pass", + "message": "spawned /home/coder/.local/share/mise/installs/node/26.2.0/bin/node", + "durationMs": 34 + }, + { + "name": "socket-viable", + "status": "pass", + "message": "socket ok: /tmp/agent-tty/55fb6b14/c9e2d8289d92", + "durationMs": 4 + }, + { + "name": "artifact-atomicity", + "status": "pass", + "message": "atomic rename ok: /tmp/agent-tty-default-renderer.NIhHYj/sessions/doctor-3125475-mqgoi9ac-3/artifacts", + "durationMs": 2 + }, + { + "name": "event-log-writable", + "status": "pass", + "message": "append ok: /tmp/agent-tty-default-renderer.NIhHYj/sessions/doctor-3125475-mqgoi9ae-5/events.jsonl", + "durationMs": 1 + } + ], + "renderer": [ + { + "name": "playwright_available", + "status": "pass", + "message": "available", + "durationMs": 1 + }, + { + "name": "browser_cache_accessible", + "status": "pass", + "message": "browser cache accessible: /home/coder/.cache/ms-playwright", + "durationMs": 0 + }, + { + "name": "browser_launch", + "status": "pass", + "message": "chromium launches", + "durationMs": 103 + }, + { + "name": "ghostty_web_available", + "status": "pass", + "message": "WASM available", + "durationMs": 81 + }, + { + "name": "screenshot_viable", + "status": "pass", + "message": "viable", + "durationMs": 176 + }, + { + "name": "libghostty_vt_available", + "status": "pass", + "message": "@coder/libghostty-vt-node exposes createTerminal()", + "durationMs": 1 + } + ] + }, + "capabilities": [ + { + "name": "snapshot", + "status": "available", + "reason": "libghostty-vt semantic renderer available", + "detail": "@coder/libghostty-vt-node exposes createTerminal()" + }, + { + "name": "wait", + "status": "available", + "reason": "libghostty-vt semantic renderer available", + "detail": "@coder/libghostty-vt-node exposes createTerminal()" + }, + { + "name": "screenshot", + "status": "available", + "reason": "renderer smoke checks passed", + "detail": "playwright_available: available; browser_launch: chromium launches; ghostty_web_available: WASM available; screenshot_viable: viable" + }, + { + "name": "record-export-asciicast", + "status": "available", + "reason": "built-in capability", + "detail": "available without external renderer dependencies" + }, + { + "name": "record-export-webm", + "status": "available", + "reason": "browser-backed export dependencies available", + "detail": "playwright_available: available; browser_launch: chromium launches; ghostty_web_available: WASM available" + }, + { + "name": "dashboard", + "status": "available", + "reason": "libghostty-vt native module available", + "detail": "@coder/libghostty-vt-node exposes createTerminal()" + } + ] + } +} diff --git a/dogfood/20260616-default-semantic-renderer/expected-renderer.json b/dogfood/20260616-default-semantic-renderer/expected-renderer.json new file mode 100644 index 00000000..a0cb0bfe --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/expected-renderer.json @@ -0,0 +1,3 @@ +{ + "expectedSemanticRenderer": "libghostty-vt" +} diff --git a/dogfood/20260616-default-semantic-renderer/explicit-ghostty-web-snapshot-artifact.json b/dogfood/20260616-default-semantic-renderer/explicit-ghostty-web-snapshot-artifact.json new file mode 100644 index 00000000..2ded7b6f --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/explicit-ghostty-web-snapshot-artifact.json @@ -0,0 +1,16 @@ +{ + "id": "01KV89ZG1TQ0APYCT5N9PAZGRJ", + "kind": "snapshot", + "filename": "snapshot-5-text.json", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "createdAt": "2026-06-16T13:29:50.906Z", + "metadata": { + "format": "text", + "rendererBackend": "ghostty-web", + "cols": 80, + "rows": 24, + "cursorRow": 2, + "cursorCol": 7 + } +} diff --git a/dogfood/20260616-default-semantic-renderer/explicit-ghostty-web-snapshot.json b/dogfood/20260616-default-semantic-renderer/explicit-ghostty-web-snapshot.json new file mode 100644 index 00000000..e3552bf3 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/explicit-ghostty-web-snapshot.json @@ -0,0 +1,16 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-06-16T13:29:50.910Z", + "result": { + "format": "text", + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "cols": 80, + "rows": 24, + "cursorRow": 2, + "cursorCol": 7, + "text": "READY> Default semantic renderer proof\nECHO: Default semantic renderer proof\nREADY>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "screenHash": "63f713a31c9ee5a4c004fba7983dab96ef5352804cd0c2de408f5c3570ea03a5" + } +} diff --git a/dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-screenshot.json b/dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-screenshot.json new file mode 100644 index 00000000..412dca82 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-06-16T13:29:52.882Z", + "result": { + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "capturedAtSeq": 5, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/agent-tty-default-renderer.NIhHYj/sessions/01KV89Z29JPZD4EG29CHER1MN8/artifacts/screenshot-5-reference-dark.png", + "pngSizeBytes": 6810, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "sha256": "d299aedfc3dbbd19a780700059ba49aeecf732a6131e8aff62f02675399d785c", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-webm.json b/dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-webm.json new file mode 100644 index 00000000..36053c2f --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/explicit-libghostty-vt-webm.json @@ -0,0 +1,24 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-06-16T13:30:06.995Z", + "result": { + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6/dogfood/20260616-default-semantic-renderer/videos/explicit-libghostty-vt-webm.webm", + "bytes": 27083, + "sha256": "33b7fd9f3f8b00f95fd0cb26b011134a7fa405b3295035528316159b92ab7cf1", + "capturedAtSeq": 14, + "durationMs": 17608, + "metadata": { + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "accelerated", + "rendererBackend": "ghostty-web", + "outputEventCount": 10, + "resizeEventCount": 0 + } + } +} diff --git a/dogfood/20260616-default-semantic-renderer/inspect.json b/dogfood/20260616-default-semantic-renderer/inspect.json new file mode 100644 index 00000000..e4ed7312 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/inspect.json @@ -0,0 +1,67 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-06-16T13:30:09.883Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KV89Z29JPZD4EG29CHER1MN8", + "createdAt": "2026-06-16T13:29:36.822Z", + "updatedAt": "2026-06-16T13:29:55.519Z", + "status": "exited", + "command": ["npx", "tsx", "test/fixtures/apps/hello-prompt/main.ts"], + "cwd": "/home/coder/.mux/src/agent-terminal/vt-impl-5ds6", + "name": "default-semantic-renderer", + "shell": "/bin/bash", + "term": "xterm-256color", + "cols": 80, + "rows": 24, + "creationCols": 80, + "creationRows": 24, + "hostPid": 3127342, + "childPid": 3127397, + "exitCode": 0, + "exitSignal": null + }, + "eventCount": 15, + "uptime": 18697, + "lastEventSeq": 14, + "terminationCategory": "clean-exit", + "artifacts": { + "total": 7, + "byKind": { + "snapshot": 2, + "screenshot": 2, + "video": 2, + "recording": 1 + }, + "missingCount": 3, + "health": "missing-artifacts", + "missing": [ + { + "id": "01KV89ZTJ6HD0FK5AXT6730B7P", + "kind": "video", + "filename": "default-webm.webm" + }, + { + "id": "01KV89ZZRFB9FEM6H378JEM9PF", + "kind": "video", + "filename": "explicit-libghostty-vt-webm.webm" + }, + { + "id": "01KV8A016HZYKE7P6KK3EANNJ6", + "kind": "recording", + "filename": "default.cast" + } + ] + }, + "usedOfflineReplay": false, + "rendererRuntime": { + "backend": "ghostty-web", + "mode": "offline-replay", + "status": "fallback", + "reason": "session-not-running" + }, + "eventLogBytes": 1414 + } +} diff --git a/dogfood/20260616-default-semantic-renderer/recordings/default.cast b/dogfood/20260616-default-semantic-renderer/recordings/default.cast new file mode 100644 index 00000000..b6660ab6 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/recordings/default.cast @@ -0,0 +1,11 @@ +{"version":2,"width":80,"height":24,"timestamp":1781616577,"title":"01KV89Z29JPZD4EG29CHER1MN8","sessionId":"01KV89Z29JPZD4EG29CHER1MN8","env":{"TERM":"xterm-256color"},"toolVersion":"0.4.3"} +[0,"o","READY> "] +[2.46,"o","Default semantic renderer proof"] +[3.607,"o","\r\n"] +[3.608,"o","ECHO: Default semantic renderer proof\r\nREADY> "] +[16.419,"o","exit"] +[17.586,"o","\r\n"] +[17.587,"o","BYE\r\n"] +[17.597,"o","\\"] +[17.598,"o","\u001b[1G"] +[17.598,"o","\u001b[0K"] diff --git a/dogfood/20260616-default-semantic-renderer/screenshots/default-screenshot.png b/dogfood/20260616-default-semantic-renderer/screenshots/default-screenshot.png new file mode 100644 index 00000000..410439f3 Binary files /dev/null and b/dogfood/20260616-default-semantic-renderer/screenshots/default-screenshot.png differ diff --git a/dogfood/20260616-default-semantic-renderer/screenshots/explicit-libghostty-vt-screenshot.png b/dogfood/20260616-default-semantic-renderer/screenshots/explicit-libghostty-vt-screenshot.png new file mode 100644 index 00000000..410439f3 Binary files /dev/null and b/dogfood/20260616-default-semantic-renderer/screenshots/explicit-libghostty-vt-screenshot.png differ diff --git a/dogfood/20260616-default-semantic-renderer/version.json b/dogfood/20260616-default-semantic-renderer/version.json new file mode 100644 index 00000000..70e48355 --- /dev/null +++ b/dogfood/20260616-default-semantic-renderer/version.json @@ -0,0 +1,41 @@ +{ + "ok": true, + "command": "version", + "timestamp": "2026-06-16T13:29:33.794Z", + "result": { + "cliVersion": "0.4.3", + "protocolVersion": "0.1.0", + "rendererBackends": ["ghostty-web", "libghostty-vt"], + "runtime": { + "node": "v26.2.0", + "platform": "linux", + "arch": "x64" + }, + "capabilities": [ + { + "name": "snapshot", + "status": "available" + }, + { + "name": "wait", + "status": "available" + }, + { + "name": "screenshot", + "status": "available" + }, + { + "name": "record-export-asciicast", + "status": "available" + }, + { + "name": "record-export-webm", + "status": "available" + }, + { + "name": "dashboard", + "status": "available" + } + ] + } +} diff --git a/dogfood/20260616-default-semantic-renderer/videos/default-webm.webm b/dogfood/20260616-default-semantic-renderer/videos/default-webm.webm new file mode 100644 index 00000000..4eeb9a03 Binary files /dev/null and b/dogfood/20260616-default-semantic-renderer/videos/default-webm.webm differ diff --git a/dogfood/20260616-default-semantic-renderer/videos/explicit-libghostty-vt-webm.webm b/dogfood/20260616-default-semantic-renderer/videos/explicit-libghostty-vt-webm.webm new file mode 100644 index 00000000..eb7ef258 Binary files /dev/null and b/dogfood/20260616-default-semantic-renderer/videos/explicit-libghostty-vt-webm.webm differ diff --git a/dogfood/CATALOG.md b/dogfood/CATALOG.md index 9aa6c6df..5b897639 100644 --- a/dogfood/CATALOG.md +++ b/dogfood/CATALOG.md @@ -5,20 +5,21 @@ Paths below are relative to the repository root. ## Canonical scenarios -| Scenario | What it demonstrates | Bundle | -| ---------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| Hello prompt | Basic lifecycle, wait, screenshot, and recording flow | `dogfood/20260322-dogfood-hello-prompt/` | -| Run command | The higher-level `run` workflow for shell setup and command injection | `dogfood/run-command/` | -| Color rendering | ANSI color capture and screenshot review | `dogfood/20260322-dogfood-color/` | -| Alternate screen | Entering and leaving an alt-screen TUI while preserving the main screen | `dogfood/20260322-dogfood-alt-screen/` | -| Resize | PTY resizing and stable-screen verification | `dogfood/20260322-dogfood-resize/` | -| Scrollback | Scrollback-aware snapshots, screenshots, and recording export | `dogfood/20260322-dogfood-scrollback/` | -| Unicode | Unicode rendering plus snapshot/export review | `dogfood/20260322-dogfood-unicode/` | -| LazyVim | A real TUI scenario that exercises editor startup and reviewer-visible artifacts | `dogfood/20260322-lazyvim-scenario/` | -| Agent uses TTY | VHS-recorded Codex and Claude TUIs exploring `agent-tty`, driving Neovim, and exporting inner proof artifacts | `dogfood/agent-uses-agent-tty/` | -| Public skill | The shipped `skills/agent-terminal/` workflow and documentation surface | `dogfood/20260327-public-skill/` | -| Install flows | Pre-public tarball install proof plus the current local git-install caveat evidence | `dogfood/install-flows/` | -| Config parity | Configuration/profile behavior checks that remain useful as a standing scenario | `dogfood/week5-config-parity/` | +| Scenario | What it demonstrates | Bundle | +| ----------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| Hello prompt | Basic lifecycle, wait, screenshot, and recording flow | `dogfood/20260322-dogfood-hello-prompt/` | +| Run command | The higher-level `run` workflow for shell setup and command injection | `dogfood/run-command/` | +| Color rendering | ANSI color capture and screenshot review | `dogfood/20260322-dogfood-color/` | +| Alternate screen | Entering and leaving an alt-screen TUI while preserving the main screen | `dogfood/20260322-dogfood-alt-screen/` | +| Resize | PTY resizing and stable-screen verification | `dogfood/20260322-dogfood-resize/` | +| Scrollback | Scrollback-aware snapshots, screenshots, and recording export | `dogfood/20260322-dogfood-scrollback/` | +| Unicode | Unicode rendering plus snapshot/export review | `dogfood/20260322-dogfood-unicode/` | +| LazyVim | A real TUI scenario that exercises editor startup and reviewer-visible artifacts | `dogfood/20260322-lazyvim-scenario/` | +| Agent uses TTY | VHS-recorded Codex and Claude TUIs exploring `agent-tty`, driving Neovim, and exporting inner proof artifacts | `dogfood/agent-uses-agent-tty/` | +| Public skill | The shipped `skills/agent-terminal/` workflow and documentation surface | `dogfood/20260327-public-skill/` | +| Install flows | Pre-public tarball install proof plus the current local git-install caveat evidence | `dogfood/install-flows/` | +| Renderer defaults | Default semantic `libghostty-vt` behavior plus visual `ghostty-web` screenshots/WebM | `dogfood/20260616-default-semantic-renderer/` | +| Config parity | Configuration/profile behavior checks that remain useful as a standing scenario | `dogfood/week5-config-parity/` | ## Validation and release gates diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts index 23c3c1a2..a5c01455 100644 --- a/src/cli/commands/inspect.ts +++ b/src/cli/commands/inspect.ts @@ -91,7 +91,7 @@ function deriveRendererRuntimeSummary(options: { const hostInfo = options.hostInfo; return { - backend: RENDERER_BACKEND, + backend: hostInfo?.rendererBackend ?? RENDERER_BACKEND, mode: 'live-host', status: 'healthy', ...(hostInfo?.rendererProfile !== undefined diff --git a/src/cli/commands/record-export.ts b/src/cli/commands/record-export.ts index 77c278dd..afa0d19a 100644 --- a/src/cli/commands/record-export.ts +++ b/src/cli/commands/record-export.ts @@ -322,7 +322,7 @@ export async function runRecordExportCommand( ? { profileName: webmProfileName } : {}), ...(timingMode !== undefined ? { timingMode } : {}), - rendererName: options.context.rendererDefault, + rendererName: options.context.rendererVisualDefault, }); const resolvedProfile = resolveProfile(webmResult.profileName); diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 691b4f47..f77355ce 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -104,7 +104,7 @@ function formatScreenshotLines(result: ScreenshotResult): string[] { async function runOfflineScreenshot( sessionDirectory: string, - rendererName: CommandContext['rendererDefault'], + rendererName: CommandContext['rendererVisualDefault'], profile: string, showCursor: boolean | undefined, ): Promise { @@ -169,7 +169,7 @@ export async function runScreenshotCommand( 'screenshot', { profile, - rendererName: options.context.rendererDefault, + rendererName: options.context.rendererVisualDefault, ...(showCursor === undefined ? {} : { showCursor }), }, ); @@ -187,7 +187,7 @@ export async function runScreenshotCommand( result = await runOfflineScreenshot( sessionDirectory, - options.context.rendererDefault, + options.context.rendererVisualDefault, profile, showCursor, ); @@ -195,7 +195,7 @@ export async function runScreenshotCommand( } else { result = await runOfflineScreenshot( sessionDirectory, - options.context.rendererDefault, + options.context.rendererVisualDefault, profile, showCursor, ); diff --git a/src/cli/commands/version.ts b/src/cli/commands/version.ts index 952fe426..30d0e68e 100644 --- a/src/cli/commands/version.ts +++ b/src/cli/commands/version.ts @@ -4,6 +4,7 @@ import { emitSuccess } from '../output.js'; import type { CapabilityEntry } from '../../renderer/capabilities.js'; import { discoverCapabilities } from '../../renderer/capabilities.js'; +import { RendererNameSchema } from '../../renderer/names.js'; import { loadPackageMetadata } from '../../util/packageMetadata.js'; const COMMAND_NAME = 'version'; @@ -43,7 +44,7 @@ export async function buildVersionResult(options?: { return { cliVersion: packageMetadata.version, protocolVersion: PROTOCOL_VERSION, - rendererBackends: ['ghostty-web'], + rendererBackends: [...RendererNameSchema.options], runtime: { node: process.version, platform: process.platform, diff --git a/src/cli/context.ts b/src/cli/context.ts index 8b37c41d..72abbc67 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -8,9 +8,16 @@ import { loadConfigFile, type ConfigFile } from '../config/resolveConfig.js'; import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import { DEFAULT_RENDERER_NAME, + DEFAULT_SEMANTIC_RENDERER_NAME, + DEFAULT_VISUAL_RENDERER_NAME, + RendererNameSchema, resolveRendererName, type RendererName, } from '../renderer/names.js'; +import { + probeLibghosttyVt, + type LibghosttyVtProbe, +} from '../renderer/readiness.js'; import { resolveHome } from '../storage/home.js'; import { invariant } from '../util/assert.js'; import { @@ -41,10 +48,22 @@ export interface CommandContext { readonly logLevel: LogLevel; readonly logger: ReturnType; readonly profileDefault: string | undefined; + /** Default renderer for semantic operations: snapshot, render waits, hashes. */ readonly rendererDefault: RendererName; + /** Default renderer for visual artifacts: PNG screenshots and WebM exports. */ + readonly rendererVisualDefault: RendererName; readonly configFile: ConfigFile | null; } +interface RendererDefaultResolutionDeps { + probeLibghosttyVt?: () => Promise; +} + +interface RendererDefaults { + readonly semantic: RendererName; + readonly visual: RendererName; +} + interface CommandWithContext extends Command { [COMMAND_CONTEXT_SYMBOL]?: CommandContext; } @@ -108,6 +127,61 @@ export function resolveRendererDefault(raw?: string): RendererName { } } +let automaticSemanticRendererName: Promise | undefined; + +async function resolveAutomaticSemanticRenderer( + deps: RendererDefaultResolutionDeps, +): Promise { + const probe = deps.probeLibghosttyVt ?? probeLibghosttyVt; + const resolveFromProbe = async (): Promise => { + try { + const probeResult = await probe(); + return probeResult.available + ? DEFAULT_SEMANTIC_RENDERER_NAME + : DEFAULT_RENDERER_NAME; + } catch { + return DEFAULT_RENDERER_NAME; + } + }; + + if (deps.probeLibghosttyVt !== undefined) { + return resolveFromProbe(); + } + + automaticSemanticRendererName ??= resolveFromProbe(); + return automaticSemanticRendererName; +} + +export function clearRendererDefaultProbeCacheForTests(): void { + automaticSemanticRendererName = undefined; +} + +async function resolveRendererDefaults( + configuredRenderer: string | undefined, + deps: RendererDefaultResolutionDeps, +): Promise { + if (configuredRenderer !== undefined) { + const renderer = resolveRendererDefault(configuredRenderer); + invariant( + RendererNameSchema.safeParse(renderer).success, + 'configured renderer default must be a valid renderer name', + ); + return { semantic: renderer, visual: renderer }; + } + + const semantic = await resolveAutomaticSemanticRenderer(deps); + const visual = DEFAULT_VISUAL_RENDERER_NAME; + invariant( + RendererNameSchema.safeParse(semantic).success, + 'semantic renderer default must be a valid renderer name', + ); + invariant( + RendererNameSchema.safeParse(visual).success, + 'visual renderer default must be a valid renderer name', + ); + return { semantic, visual }; +} + export function resolveLogLevel(raw?: string): LogLevel { try { return resolveLoggerLevel(raw); @@ -125,6 +199,7 @@ export function resolveLogLevel(raw?: string): LogLevel { export async function resolveCommandContext( options: GlobalCliOptions, env: NodeJS.ProcessEnv = process.env, + deps: RendererDefaultResolutionDeps = {}, ): Promise { const configuredHome = options.home ?? env.AGENT_TTY_HOME; const explicitHome = configuredHome !== undefined; @@ -145,11 +220,9 @@ export async function resolveCommandContext( options.profile ?? env.AGENT_TTY_PROFILE ?? configFile?.defaultProfile; - const rendererDefault = resolveRendererDefault( - options.renderer ?? - env.AGENT_TTY_RENDERER ?? - configFile?.defaultRenderer ?? - DEFAULT_RENDERER_NAME, + const rendererDefaults = await resolveRendererDefaults( + options.renderer ?? env.AGENT_TTY_RENDERER ?? configFile?.defaultRenderer, + deps, ); return Object.freeze({ @@ -160,7 +233,8 @@ export async function resolveCommandContext( logLevel, logger, profileDefault, - rendererDefault, + rendererDefault: rendererDefaults.semantic, + rendererVisualDefault: rendererDefaults.visual, configFile, }); } diff --git a/src/cli/main.ts b/src/cli/main.ts index 02fd2ec2..665dc828 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -46,6 +46,7 @@ import { DEFAULT_ROWS, DEFAULT_SHELL, DEFAULT_TERM, + HOST_RENDERER_ENV_KEY, } from '../config/defaults.js'; import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import { invariant } from '../util/assert.js'; @@ -99,6 +100,7 @@ function wrapAction( context.logger.debug(`starting ${commandName} command`, { logLevel: context.logLevel, renderer: context.rendererDefault, + visualRenderer: context.rendererVisualDefault, }); const args = rawArgs.slice(0, -1) as Args; await fn(...([...args, context] as [...Args, CommandContext])); @@ -138,13 +140,14 @@ async function main(): Promise { .option('--profile ', 'Default render profile name') .option( '--renderer ', - 'Renderer backend (ghostty-web or libghostty-vt)', + 'Renderer backend override. Defaults vary by command: semantic actions prefer libghostty-vt when available; visual artifacts default to ghostty-web.', ); program.hook('preAction', async (_thisCommand, actionCommand) => { - const context = await resolveCommandContext( - actionCommand.optsWithGlobals(), - ); + const globalOptions = actionCommand.optsWithGlobals(); + const rendererConfiguredByEnv = + process.env.AGENT_TTY_RENDERER !== undefined; + const context = await resolveCommandContext(globalOptions); process.env.AGENT_TTY_HOME = context.home; // Propagate the resolved log level to the process environment so that // subsystems instantiated outside the CLI context (e.g., renderer backends, @@ -153,13 +156,24 @@ async function main(): Promise { // and constructor is a larger refactor with no user-visible benefit, since // the env var is set before any command handler runs. process.env.AGENT_TTY_LOG_LEVEL = context.logLevel; - process.env.AGENT_TTY_RENDERER = context.rendererDefault; + process.env[HOST_RENDERER_ENV_KEY] = context.rendererDefault; + const rendererConfiguredExplicitly = + globalOptions.renderer !== undefined || + rendererConfiguredByEnv || + context.configFile?.defaultRenderer !== undefined; + if (rendererConfiguredExplicitly) { + process.env.AGENT_TTY_RENDERER = context.rendererDefault; + } else { + delete process.env.AGENT_TTY_RENDERER; + } setColorEnabled(context.colorEnabled); setCommandContext(actionCommand, context); context.logger.debug('resolved command context', { command: actionCommand.name(), home: context.home, logLevel: context.logLevel, + renderer: context.rendererDefault, + visualRenderer: context.rendererVisualDefault, }); }); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 907a9c1e..ffcb5b14 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -6,6 +6,7 @@ export const DEFAULT_TERM = 'xterm-256color'; export const DEFAULT_SHELL = process.env.SHELL ?? '/bin/sh'; export { DEFAULT_LOG_LEVEL } from '../util/logger.js'; export const DEFAULT_IDLE_TIMEOUT_MS = 0 as const; +export const HOST_RENDERER_ENV_KEY = 'AGENT_TTY_HOST_RENDERER' as const; export const SOCKET_FILENAME = 'host.sock'; export const MANIFEST_FILENAME = 'session.json'; diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 2c322ab2..bdd65592 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -37,6 +37,7 @@ import { canonicalVisibleText, computeScreenHash, } from '../renderer/canonicalScreen.js'; +import { HOST_RENDERER_ENV_KEY } from '../config/defaults.js'; import { DEFAULT_RENDERER_NAME, resolveRendererName, @@ -113,14 +114,17 @@ function rethrowAsync(error: unknown): void { } function resolveHostRendererName(input: string | undefined): RendererName { + const rawRenderer = + input ?? + process.env[HOST_RENDERER_ENV_KEY] ?? + process.env.AGENT_TTY_RENDERER ?? + DEFAULT_RENDERER_NAME; try { - return resolveRendererName( - input ?? process.env.AGENT_TTY_RENDERER ?? DEFAULT_RENDERER_NAME, - ); + return resolveRendererName(rawRenderer); } catch (error) { throw makeCliError(ERROR_CODES.INVALID_INPUT, { message: 'Renderer must be one of: ghostty-web, libghostty-vt.', - details: { renderer: input ?? process.env.AGENT_TTY_RENDERER }, + details: { renderer: rawRenderer }, cause: error, }); } @@ -149,6 +153,7 @@ export async function runHost(sessionId: string): Promise { 'session manifest idleTimeoutMs must be a non-negative integer', ); + const hostDefaultRendererName = resolveHostRendererName(undefined); const state = new SessionState(manifest); invariant( Number.isInteger(process.pid) && process.pid > 0, @@ -256,15 +261,17 @@ export async function runHost(sessionId: string): Promise { const rendererName = resolveHostRendererName(undefined); const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); - const backend = await rendererManager.getBackend( + await rendererManager.withBackend( rendererName, profile, replayInput, - ); - const snapshot = await backend.snapshot(); - invariant( - snapshot.capturedAtSeq >= targetSeq, - 'renderer snapshot must include the run-complete event sequence', + async (backend) => { + const snapshot = await backend.snapshot(); + invariant( + snapshot.capturedAtSeq >= targetSeq, + 'renderer snapshot must include the run-complete event sequence', + ); + }, ); }; @@ -435,6 +442,8 @@ export async function runHost(sessionId: string): Promise { // reads, producing a result that does not correspond to any real // point in time. const sessionSnapshot = state.snapshot(); + const rendererBackend = + rendererManager.getCurrentRendererName() ?? hostDefaultRendererName; const rendererProfile = rendererManager.getCurrentProfileName(); const rendererBooted = rendererManager.isBooted(); const rendererBootInFlight = rendererManager.isBootInFlight(); @@ -446,6 +455,7 @@ export async function runHost(sessionId: string): Promise { ? { cliVersion: packageMetadata.version } : {}), rpcSocketPath: sPath, + rendererBackend, ...(rendererProfile !== null ? { rendererProfile } : {}), rendererBooted, rendererBootInFlight, @@ -475,20 +485,23 @@ export async function runHost(sessionId: string): Promise { const rendererName = resolveHostRendererName(requestedRendererName); const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); const replayInput = loadReplayInput(); - const backend = await rendererManager.getBackend( + const { snapshot, rendererBackend } = await rendererManager.withBackend( rendererName, profile, replayInput, + async (backend) => ({ + snapshot: await backend.snapshot({ + includeScrollback, + includeCells, + }), + rendererBackend: backend.rendererBackend, + }), ); - const snapshot = await backend.snapshot({ - includeScrollback, - includeCells, - }); return await captureSnapshotResult({ sessionDir: sessDir, format, snapshot, - rendererBackend: backend.rendererBackend, + rendererBackend, expectedSessionId: sessionId, }); }, @@ -520,19 +533,20 @@ export async function runHost(sessionId: string): Promise { const rendererName = resolveHostRendererName(requestedRendererName); const replayInput = loadReplayInput(); - const backend = await rendererManager.getBackend( + + return await rendererManager.withBackend( rendererName, profile, replayInput, + async (backend) => + await captureScreenshotResult({ + backend, + sessionDir: sessDir, + profileName: profile.name, + expectedSessionId: sessionId, + ...(showCursor === undefined ? {} : { showCursor }), + }), ); - - return await captureScreenshotResult({ - backend, - sessionDir: sessDir, - profileName: profile.name, - expectedSessionId: sessionId, - ...(showCursor === undefined ? {} : { showCursor }), - }); }, type: async (params: unknown) => { const { text } = params as TypeParams; @@ -897,14 +911,16 @@ export async function runHost(sessionId: string): Promise { try { throwIfAborted(signal); const replayInput = loadReplayInput(); - const backend = await rendererManager.getBackend( + const snapshot = await rendererManager.withBackend( rendererName, profile, replayInput, + async (backend) => { + throwIfAborted(signal); + return await backend.snapshot(); + }, ); throwIfAborted(signal); - const snapshot = await backend.snapshot(); - throwIfAborted(signal); const visibleText = canonicalVisibleText(snapshot); const capturedAtSeq = snapshot.capturedAtSeq; latestCapturedAtSeq = capturedAtSeq; diff --git a/src/host/renderer.ts b/src/host/renderer.ts index 582feafd..d4ab27da 100644 --- a/src/host/renderer.ts +++ b/src/host/renderer.ts @@ -64,8 +64,26 @@ export class HostRendererManager { profile: RenderProfileConfig, replayInput: ReplayInput | null, ): Promise { + return await this.withBackend( + rendererName, + profile, + replayInput, + (backend) => backend, + ); + } + + async withBackend( + rendererName: RendererName, + profile: RenderProfileConfig, + replayInput: ReplayInput | null, + operation: (backend: RendererBackend) => T | Promise, + ): Promise { assertNonEmptyString(rendererName, 'rendererName'); assertNonEmptyString(profile.name, 'profile name'); + invariant( + typeof operation === 'function', + 'backend operation must be a function', + ); if (replayInput !== null) { invariant( @@ -94,7 +112,7 @@ export class HostRendererManager { this.cachedInitialRows = replayInput.initialRows; } - return backend; + return await operation(backend); }); } @@ -146,6 +164,10 @@ export class HostRendererManager { return this.bootPromise !== null; } + getCurrentRendererName(): string | null { + return this.currentBackend?.rendererBackend ?? null; + } + getCurrentProfileName(): string | null { return this.currentProfileName; } diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index fe429e32..efad2a9a 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -115,6 +115,7 @@ export const HostInspectResultSchema = z // Populated only when the host has reached the inspect handler. An // older host or one whose renderer has never bootstrapped omits // these and the CLI surfaces them as absent on `rendererRuntime`. + rendererBackend: z.string().min(1).optional(), rendererProfile: z.string().min(1).optional(), rendererBooted: z.boolean().optional(), rendererBootInFlight: z.boolean().optional(), diff --git a/src/pty/createPty.ts b/src/pty/createPty.ts index 2e3f0b11..fbf50057 100644 --- a/src/pty/createPty.ts +++ b/src/pty/createPty.ts @@ -5,6 +5,8 @@ import process from 'node:process'; import type { IPty } from 'node-pty'; import { spawn } from 'node-pty'; + +import { HOST_RENDERER_ENV_KEY } from '../config/defaults.js'; import { invariant } from '../util/assert.js'; export interface PtyOptions { @@ -82,8 +84,8 @@ const PROMPT_EOL_MARK_ENV_KEY = 'PROMPT_EOL_MARK'; /** * Resolves the environment handed to the spawned PTY shell. * - * Precedence, lowest to highest: the inherited process environment, then the - * `PROMPT_EOL_MARK=''` default, then the caller-supplied `env` (so a `--env` + * Precedence, lowest to highest: the inherited process environment (minus + * host-only internals), then the `PROMPT_EOL_MARK=''` default, then the caller-supplied `env` (so a `--env` * value always wins — even an explicit empty one), then `TERM`. The default sits * after the inherited environment so it also overrides any inherited * `PROMPT_EOL_MARK`, keeping captures deterministic regardless of the launching @@ -97,6 +99,9 @@ export function resolvePtyEnv( ): Record { const resolved: Record = {}; for (const [key, value] of Object.entries(baseEnv)) { + if (key === HOST_RENDERER_ENV_KEY) { + continue; + } if (value !== undefined) { resolved[key] = value; } diff --git a/src/renderer/capabilities.ts b/src/renderer/capabilities.ts index 98c3363b..f5eebde3 100644 --- a/src/renderer/capabilities.ts +++ b/src/renderer/capabilities.ts @@ -84,9 +84,9 @@ const CAPABILITY_NAMES: ReadonlyArray = Object.freeze([ 'record-export-webm', 'dashboard', ]); +const SEMANTIC_RENDER_CAPABILITY_NAMES: ReadonlyArray<'snapshot' | 'wait'> = + Object.freeze(['snapshot', 'wait']); const BUILTIN_CAPABILITY_NAMES: ReadonlyArray = Object.freeze([ - 'snapshot', - 'wait', 'record-export-asciicast', ]); @@ -165,9 +165,9 @@ async function probePlaywrightAvailability( } /** - * Built-in capabilities (snapshot, wait, record-export-asciicast) are always - * reported as 'available' because they depend only on the event log and - * built-in text processing; no external renderer or browser is needed. + * Built-in capabilities are always reported as 'available' because they depend + * only on the event log and built-in text processing; no external renderer or + * browser is needed. * * This reflects runtime feature availability, not guaranteed success for any * particular session. Corrupted session data or an invalid event log will fail @@ -216,6 +216,154 @@ function buildUnknownCapability(name: CapabilityName): CapabilityEntry { }; } +function buildSemanticUnavailableDetail( + libghosttyVtProbe: LibghosttyVtProbe, + playwrightProbe: PlaywrightProbeResult, +): string | undefined { + const details = [libghosttyVtProbe.detail, playwrightProbe.detail].filter( + (detail): detail is string => detail !== undefined && detail.length > 0, + ); + return details.length === 0 ? undefined : details.join('; '); +} + +function buildUnavailableSemanticRenderCapability( + name: 'snapshot' | 'wait', + detail: string | undefined, +): CapabilityEntry { + if (name === 'wait') { + return { + name, + status: 'degraded', + reason: 'render waits unavailable', + detail: + detail === undefined + ? 'legacy --exit and --idle-ms wait modes remain available' + : `legacy --exit and --idle-ms wait modes remain available; ${detail}`, + }; + } + + return { + name, + status: 'unavailable', + reason: 'semantic renderer unavailable', + detail, + }; +} + +async function buildSemanticRenderCapability( + name: 'snapshot' | 'wait', + mode: DiscoveryMode, + deps: CapabilityDiscoveryDependencies, +): Promise { + if (mode === 'full' && deps.rendererChecks !== undefined) { + return buildFullSemanticRenderCapabilityFromChecks( + name, + deps.rendererChecks, + ); + } + + const probeLibghostty = deps.probeLibghosttyVt ?? probeLibghosttyVt; + const probePlaywright = deps.probePlaywright ?? probePlaywrightAvailability; + const [libghosttyVtProbe, playwrightProbe] = await Promise.all([ + probeLibghostty(), + probePlaywright(mode), + ]); + + if (libghosttyVtProbe.available) { + return mode === 'full' + ? { + name, + status: 'available', + reason: libghosttyVtProbe.reason, + detail: libghosttyVtProbe.detail, + } + : { name, status: 'available' }; + } + + if (playwrightProbe.available) { + return mode === 'full' + ? { + name, + status: 'available', + reason: 'ghostty-web semantic fallback available', + detail: playwrightProbe.detail, + } + : { name, status: 'available' }; + } + + return buildUnavailableSemanticRenderCapability( + name, + buildSemanticUnavailableDetail(libghosttyVtProbe, playwrightProbe), + ); +} + +function buildFullSemanticRenderCapabilityFromChecks( + name: 'snapshot' | 'wait', + checks: ReadonlyArray, +): CapabilityEntry { + const libghosttyVtCheck = findRendererCheck( + checks, + 'libghostty_vt_available', + ); + const playwrightCheck = findRendererCheck(checks, 'playwright_available'); + const browserLaunchCheck = findRendererCheck(checks, 'browser_launch'); + const ghosttyWebCheck = findRendererCheck(checks, 'ghostty_web_available'); + + if (libghosttyVtCheck === undefined) { + return buildUnknownCapability(name); + } + + if (libghosttyVtCheck.status === 'pass') { + return { + name, + status: 'available', + reason: 'libghostty-vt semantic renderer available', + detail: libghosttyVtCheck.message, + }; + } + + if ( + playwrightCheck === undefined || + browserLaunchCheck === undefined || + ghosttyWebCheck === undefined + ) { + return buildUnknownCapability(name); + } + + if (playwrightCheck.status === 'fail') { + return buildUnavailableSemanticRenderCapability( + name, + `libghostty-vt: ${libghosttyVtCheck.message}; playwright: ${playwrightCheck.message}`, + ); + } + + if (ghosttyWebCheck.status === 'fail') { + return buildUnavailableSemanticRenderCapability( + name, + `libghostty-vt: ${libghosttyVtCheck.message}; ghostty-web: ${ghosttyWebCheck.message}`, + ); + } + + if (browserLaunchCheck.status === 'fail') { + return buildUnavailableSemanticRenderCapability( + name, + `libghostty-vt: ${libghosttyVtCheck.message}; browser: ${browserLaunchCheck.message}`, + ); + } + + return { + name, + status: 'available', + reason: 'ghostty-web semantic fallback available', + detail: buildAvailableDetail([ + libghosttyVtCheck, + playwrightCheck, + browserLaunchCheck, + ghosttyWebCheck, + ]), + }; +} + function buildFullScreenshotCapabilityFromChecks( checks: ReadonlyArray, ): CapabilityEntry { @@ -437,16 +585,45 @@ export async function discoverCapabilities( deps: CapabilityDiscoveryDependencies = {}, ): Promise { const capabilities: CapabilityEntry[] = []; + const baseProbePlaywright = + deps.probePlaywright ?? probePlaywrightAvailability; + const baseProbeLibghosttyVt = deps.probeLibghosttyVt ?? probeLibghosttyVt; + let cachedPlaywrightProbe: Promise | undefined; + let cachedLibghosttyVtProbe: Promise | undefined; + const cachedDeps: CapabilityDiscoveryDependencies = { + ...deps, + probePlaywright: (requestedMode) => { + assert.equal( + requestedMode, + mode, + 'capability discovery probe mode must stay consistent', + ); + cachedPlaywrightProbe ??= baseProbePlaywright(requestedMode); + return cachedPlaywrightProbe; + }, + probeLibghosttyVt: () => { + cachedLibghosttyVtProbe ??= baseProbeLibghosttyVt(); + return cachedLibghosttyVtProbe; + }, + }; + + for (const name of SEMANTIC_RENDER_CAPABILITY_NAMES) { + capabilities.push( + await buildSemanticRenderCapability(name, mode, cachedDeps), + ); + } for (const name of BUILTIN_CAPABILITY_NAMES) { capabilities.push(buildBuiltinCapability(name, mode)); } - capabilities.push(await buildPlaywrightCapability('screenshot', mode, deps)); capabilities.push( - await buildPlaywrightCapability('record-export-webm', mode, deps), + await buildPlaywrightCapability('screenshot', mode, cachedDeps), + ); + capabilities.push( + await buildPlaywrightCapability('record-export-webm', mode, cachedDeps), ); - capabilities.push(await discoverDashboardCapability(mode, deps)); + capabilities.push(await discoverDashboardCapability(mode, cachedDeps)); const sortedCapabilities: CapabilityEntry[] = []; for (const name of CAPABILITY_NAMES) { diff --git a/src/renderer/index.ts b/src/renderer/index.ts index dd9439ec..394ecbbf 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -17,6 +17,8 @@ export { VisibleLineSchema } from '../protocol/schemas.js'; export type { RendererBackend, SnapshotOptions } from './backend.js'; export { DEFAULT_RENDERER_NAME, + DEFAULT_SEMANTIC_RENDERER_NAME, + DEFAULT_VISUAL_RENDERER_NAME, RendererNameSchema, resolveRendererName, } from './names.js'; diff --git a/src/renderer/libghosttyVt/backend.ts b/src/renderer/libghosttyVt/backend.ts index 5f68a9ff..fc17adb1 100644 --- a/src/renderer/libghosttyVt/backend.ts +++ b/src/renderer/libghosttyVt/backend.ts @@ -33,6 +33,8 @@ export interface LibghosttyVtNativeModule { getNativeInfo?: () => NativeInfo; } +const DEFAULT_SCROLLBACK_LIMIT = 10_000; + export interface LibghosttyVtBackendOptions { initialCols?: number; initialRows?: number; @@ -369,14 +371,13 @@ export class LibghosttyVtBackend implements RendererBackend { >; private readonly logger: Logger; private readonly profile: RenderProfileConfig; - private readonly scrollbackLimit: number | undefined; + private readonly scrollbackLimit: number; private readonly sessionId: string; private bootPromise: Promise | null = null; private currentCols: number; private currentRows: number; private disposed = false; - private fallbackBackend: RendererBackend | null = null; private initialReplayCols: number | null = null; private initialReplayRows: number | null = null; private lastAppliedSeq = -1; @@ -411,7 +412,7 @@ export class LibghosttyVtBackend implements RendererBackend { this.initialRows = initialRows; this.currentCols = initialCols; this.currentRows = initialRows; - this.scrollbackLimit = options.scrollbackLimit; + this.scrollbackLimit = options.scrollbackLimit ?? DEFAULT_SCROLLBACK_LIMIT; this.loadNative = options.loadNative ?? (() => @@ -603,9 +604,13 @@ export class LibghosttyVtBackend implements RendererBackend { ); invariant(isAbsolute(outputPath), 'screenshot outputPath must be absolute'); - const fallback = await this.ensureFallbackBackend(); - await fallback.replayTo(this.latestReplayInput); - return await fallback.screenshot(outputPath, options); + const fallback = await this.createFallbackBackend(); + try { + await fallback.replayTo(this.latestReplayInput); + return await fallback.screenshot(outputPath, options); + } finally { + await fallback.dispose(); + } } public async getVisibleText(): Promise { @@ -616,9 +621,9 @@ export class LibghosttyVtBackend implements RendererBackend { return visibleText; } - public async dispose(): Promise { + public dispose(): Promise { if (this.disposed) { - return; + return Promise.resolve(); } this.disposed = true; @@ -628,12 +633,7 @@ export class LibghosttyVtBackend implements RendererBackend { if (terminal !== null) { terminal.dispose(); } - - const fallback = this.fallbackBackend; - this.fallbackBackend = null; - if (fallback !== null) { - await fallback.dispose(); - } + return Promise.resolve(); } private async bootInternal(): Promise { @@ -659,9 +659,7 @@ export class LibghosttyVtBackend implements RendererBackend { this.terminal = native.createTerminal({ cols: this.initialCols, rows: this.initialRows, - ...(this.scrollbackLimit === undefined - ? {} - : { scrollbackLimit: this.scrollbackLimit }), + scrollbackLimit: this.scrollbackLimit, }); this.assertTerminalShape(this.terminal); this.currentCols = this.initialCols; @@ -677,20 +675,23 @@ export class LibghosttyVtBackend implements RendererBackend { } } - private async ensureFallbackBackend(): Promise { - if (this.fallbackBackend === null) { - const fallback = this.fallbackFactory(this.sessionId, this.profile); - invariant( - fallback.rendererBackend !== this.rendererBackend, - 'libghostty-vt screenshot fallback must use a different renderer backend', - ); - this.fallbackBackend = fallback; - } - - if (!this.fallbackBackend.isBooted) { - await this.fallbackBackend.boot(); + private async createFallbackBackend(): Promise { + const fallback = this.fallbackFactory(this.sessionId, this.profile); + invariant( + fallback.rendererBackend !== this.rendererBackend, + 'libghostty-vt screenshot fallback must use a different renderer backend', + ); + try { + await fallback.boot(); + return fallback; + } catch (error) { + try { + await fallback.dispose(); + } catch { + // Preserve the original fallback boot error; dispose is best effort. + } + throw error; } - return this.fallbackBackend; } private assertNotDisposed(methodName: string): void { diff --git a/src/renderer/names.ts b/src/renderer/names.ts index 7e0f58fd..af4dcd2c 100644 --- a/src/renderer/names.ts +++ b/src/renderer/names.ts @@ -3,7 +3,11 @@ import { z } from 'zod'; export const RendererNameSchema = z.enum(['ghostty-web', 'libghostty-vt']); export type RendererName = z.infer; +// Legacy/safe single-renderer fallback for internal paths that cannot yet +// distinguish semantic from visual defaults. export const DEFAULT_RENDERER_NAME: RendererName = 'ghostty-web'; +export const DEFAULT_SEMANTIC_RENDERER_NAME: RendererName = 'libghostty-vt'; +export const DEFAULT_VISUAL_RENDERER_NAME: RendererName = 'ghostty-web'; export function resolveRendererName(input: string | undefined): RendererName { const candidate = input ?? DEFAULT_RENDERER_NAME; diff --git a/test/e2e/hello-prompt.test.ts b/test/e2e/hello-prompt.test.ts index 7c347253..9a2d865c 100644 --- a/test/e2e/hello-prompt.test.ts +++ b/test/e2e/hello-prompt.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { probeLibghosttyVt } from '../../src/renderer/readiness.js'; import { cleanupHome, createIsolatedHome, @@ -59,6 +60,9 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { }); it('full interaction flow', async () => { + const expectedRenderer = (await probeLibghosttyVt()).available + ? 'libghostty-vt' + : 'ghostty-web'; const env = testEnv(testHome); const createEnvelope = runCliJson>( ['create', '--', ...fixtureCommand('hello-prompt')], @@ -142,7 +146,7 @@ describe('hello-prompt e2e', { timeout: 30_000 }, () => { expect(inspectRunning.result.session.exitCode).toBeNull(); expect(inspectRunning.result.rendererRuntime).toEqual( expect.objectContaining({ - backend: 'ghostty-web', + backend: expectedRenderer, mode: 'live-host', status: 'healthy', }), diff --git a/test/integration/backend-selection.test.ts b/test/integration/backend-selection.test.ts index ee3d16fa..6cbacf40 100644 --- a/test/integration/backend-selection.test.ts +++ b/test/integration/backend-selection.test.ts @@ -8,8 +8,12 @@ import { cleanupHome, createSession, destroySession, + readEvents, runCli, } from '../helpers.js'; +import { probeLibghosttyVt } from '../../src/renderer/readiness.js'; +import { readArtifactManifest } from '../../src/storage/artifactManifest.js'; +import { sessionDir } from '../../src/storage/sessionPaths.js'; interface FailureEnvelope { ok: false; @@ -87,6 +91,75 @@ describe('backend selection integration', () => { }); }); + it('defaults semantic snapshot rendering to libghostty-vt when available', async () => { + const expectedRenderer = (await probeLibghosttyVt()).available + ? 'libghostty-vt' + : 'ghostty-web'; + sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + 'printf default-renderer-ready; exec cat', + ]); + + const result = runCli( + ['snapshot', sessionId, '--format', 'structured', '--json'], + { AGENT_TTY_HOME: testHome }, + 60_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + result: { sessionId }, + }); + await expect( + readArtifactManifest(sessionDir(testHome, sessionId)), + ).resolves.toMatchObject({ + artifacts: [ + { + kind: 'snapshot', + metadata: { rendererBackend: expectedRenderer }, + }, + ], + }); + }); + + it('does not leak automatic renderer defaults into PTY environments', async () => { + sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + 'printf "public=%s private=%s\\n" "${AGENT_TTY_RENDERER-unset}" "${AGENT_TTY_HOST_RENDERER-unset}"; exec cat', + ]); + + const result = runCli( + [ + 'wait', + sessionId, + '--text', + 'public=unset private=unset', + '--timeout', + '10000', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 60_000, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + result: { matched: true, timedOut: false }, + }); + + const output = (await readEvents(testHome, sessionId)) + .filter((event) => event.type === 'output') + .map((event) => event.payload.data) + .join(''); + expect(output).toContain('public=unset private=unset'); + }); + it('threads --renderer ghostty-web through live snapshot RPC paths', () => { sessionId = createSession(testHome, [ '/bin/sh', diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index c97b5dd8..f8d8f43a 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -71,7 +71,10 @@ describe('CLI integration', () => { expect(parsed.ok).toBe(true); expect(parsed.command).toBe('version'); expect(parsed.result.cliVersion).toMatch(SEMVER_WITH_OPTIONAL_PRERELEASE); - expect(parsed.result.rendererBackends).toEqual(['ghostty-web']); + expect(parsed.result.rendererBackends).toEqual([ + 'ghostty-web', + 'libghostty-vt', + ]); }); it('lists bundled skills in human output', () => { diff --git a/test/integration/host-renderer-rpc.test.ts b/test/integration/host-renderer-rpc.test.ts index ce95fbd5..7ec4b147 100644 --- a/test/integration/host-renderer-rpc.test.ts +++ b/test/integration/host-renderer-rpc.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { probeLibghosttyVt } from '../../src/renderer/readiness.js'; import { sendRpc } from '../../src/host/rpcClient.js'; import type { ScreenshotResult, @@ -79,11 +80,16 @@ describe( let testHome = ''; let sessionId = ''; let rpcSocketPath = ''; + let expectedSemanticRenderer = 'ghostty-web'; + let sessDir = ''; beforeEach(async () => { // oxfmt-ignore testHome = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-host-renderer-'))); + expectedSemanticRenderer = (await probeLibghosttyVt()).available + ? 'libghostty-vt' + : 'ghostty-web'; sessionId = createSession(testHome, [ '/bin/sh', '-c', @@ -157,7 +163,7 @@ describe( rows: result.rows, cursorRow: result.cursorRow, cursorCol: result.cursorCol, - rendererBackend: 'ghostty-web', + rendererBackend: expectedSemanticRenderer, }), ); }); @@ -200,7 +206,7 @@ describe( rows: result.rows, cursorRow: result.cursorRow, cursorCol: result.cursorCol, - rendererBackend: 'ghostty-web', + rendererBackend: expectedSemanticRenderer, }), ); }); @@ -278,7 +284,7 @@ describe( rows: result.rows, cursorRow: result.cursorRow, cursorCol: result.cursorCol, - rendererBackend: 'ghostty-web', + rendererBackend: expectedSemanticRenderer, scrollbackLineCount: scrollbackLines.length, }), ); @@ -298,7 +304,7 @@ describe( rows: result.rows, cursorRow: result.cursorRow, cursorCol: result.cursorCol, - rendererBackend: 'ghostty-web', + rendererBackend: expectedSemanticRenderer, }), ); expect(manifest.artifacts[0]?.metadata).not.toHaveProperty( @@ -367,10 +373,18 @@ describe( sendRpc( rpcSocketPath, 'snapshot', - { format: 'structured' }, + { + format: 'structured', + rendererName: expectedSemanticRenderer, + }, + SNAPSHOT_TIMEOUT_MS, + ), + sendRpc( + rpcSocketPath, + 'screenshot', + { rendererName: 'ghostty-web' }, SNAPSHOT_TIMEOUT_MS, ), - sendRpc(rpcSocketPath, 'screenshot', {}, SNAPSHOT_TIMEOUT_MS), ])) as [SnapshotResult, ScreenshotResult]; const screenshotStats = await stat(screenshot.artifactPath); const manifest = await readArtifactManifest(sessDir); diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index ad23ede0..56005da6 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -11,6 +11,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { probeLibghosttyVt } from '../../src/renderer/readiness.js'; import { SessionRecordSchema } from '../../src/protocol/schemas.js'; import { cleanupHome, @@ -77,7 +78,7 @@ describe('lifecycle integration', { timeout: 30000 }, () => { await cleanupHome(testHome); }); - it('full lifecycle: create → list → inspect → destroy', () => { + it('full lifecycle: create → list → inspect → destroy', async () => { const createResult = runCli( ['create', '--json', '--', '/bin/sh', '-c', 'echo ready; sleep 30'], { AGENT_TTY_HOME: testHome }, @@ -112,6 +113,9 @@ describe('lifecycle integration', { timeout: 30000 }, () => { expect(listedSession?.name).toBeUndefined(); expect(listedSession?.pid).toBeTypeOf('number'); + const expectedRenderer = (await probeLibghosttyVt()).available + ? 'libghostty-vt' + : 'ghostty-web'; const inspectResult = runCli(['inspect', sessionId, '--json'], { AGENT_TTY_HOME: testHome, }); @@ -135,7 +139,7 @@ describe('lifecycle integration', { timeout: 30000 }, () => { expect(inspectEnvelope.result.session.childPid).toBeTypeOf('number'); expect(inspectEnvelope.result.rendererRuntime).toEqual( expect.objectContaining({ - backend: 'ghostty-web', + backend: expectedRenderer, mode: 'live-host', status: 'healthy', }), diff --git a/test/unit/cli/context.test.ts b/test/unit/cli/context.test.ts index 614df6eb..f901da7e 100644 --- a/test/unit/cli/context.test.ts +++ b/test/unit/cli/context.test.ts @@ -1,9 +1,11 @@ import { Command } from 'commander'; import type * as ResolveConfigModule from '../../../src/config/resolveConfig.js'; +import type * as RendererReadinessModule from '../../../src/renderer/readiness.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ loadConfigFile: vi.fn(), + probeLibghosttyVt: vi.fn(), })); vi.mock('../../../src/config/resolveConfig.js', async () => { @@ -16,7 +18,18 @@ vi.mock('../../../src/config/resolveConfig.js', async () => { }; }); +vi.mock('../../../src/renderer/readiness.js', async () => { + const actual = await vi.importActual( + '../../../src/renderer/readiness.js', + ); + return { + ...actual, + probeLibghosttyVt: mocks.probeLibghosttyVt, + }; +}); + import { + clearRendererDefaultProbeCacheForTests, getCommandContext, parseTimeoutMsOption, resolveCommandContext, @@ -33,7 +46,12 @@ const TEST_FLAG_HOME = '/tmp/from-flag'; describe('CLI context resolution', () => { beforeEach(() => { vi.clearAllMocks(); + clearRendererDefaultProbeCacheForTests(); mocks.loadConfigFile.mockResolvedValue(null); + mocks.probeLibghosttyVt.mockResolvedValue({ + available: false, + reason: 'libghostty-vt not installed', + }); }); it('prefers --home over AGENT_TTY_HOME', async () => { @@ -157,7 +175,7 @@ describe('CLI context resolution', () => { ).resolves.toMatchObject({ profileDefault: undefined }); }); - it('resolves rendererDefault from flag, env, config, and default precedence', async () => { + it('resolves explicit renderer defaults from flag, env, and config precedence', async () => { mocks.loadConfigFile.mockResolvedValue({ defaultRenderer: 'libghostty-vt', }); @@ -167,7 +185,10 @@ describe('CLI context resolution', () => { { home: TEST_FLAG_HOME, renderer: 'ghostty-web' }, {}, ), - ).resolves.toMatchObject({ rendererDefault: 'ghostty-web' }); + ).resolves.toMatchObject({ + rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', + }); await expect( resolveCommandContext( { home: TEST_FLAG_HOME }, @@ -176,15 +197,58 @@ describe('CLI context resolution', () => { AGENT_TTY_RENDERER: 'ghostty-web', }, ), - ).resolves.toMatchObject({ rendererDefault: 'ghostty-web' }); + ).resolves.toMatchObject({ + rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', + }); await expect( resolveCommandContext({ home: TEST_FLAG_HOME }, {}), - ).resolves.toMatchObject({ rendererDefault: 'libghostty-vt' }); + ).resolves.toMatchObject({ + rendererDefault: 'libghostty-vt', + rendererVisualDefault: 'libghostty-vt', + }); + }); + + it('prefers libghostty-vt for automatic semantic defaults when available', async () => { + mocks.probeLibghosttyVt.mockResolvedValue({ available: true }); + + await expect( + resolveCommandContext({ home: TEST_FLAG_HOME }, {}), + ).resolves.toMatchObject({ + rendererDefault: 'libghostty-vt', + rendererVisualDefault: 'ghostty-web', + }); + }); + + it('falls back to ghostty-web automatic semantic defaults when libghostty-vt is unavailable', async () => { + mocks.probeLibghosttyVt.mockResolvedValue({ available: false }); - mocks.loadConfigFile.mockResolvedValue(null); await expect( resolveCommandContext({ home: TEST_FLAG_HOME }, {}), - ).resolves.toMatchObject({ rendererDefault: 'ghostty-web' }); + ).resolves.toMatchObject({ + rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', + }); + }); + + it('falls back to ghostty-web automatic semantic defaults when probing fails', async () => { + mocks.probeLibghosttyVt.mockRejectedValue(new Error('probe failed')); + + await expect( + resolveCommandContext({ home: TEST_FLAG_HOME }, {}), + ).resolves.toMatchObject({ + rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', + }); + }); + + it('memoizes the automatic semantic renderer probe', async () => { + mocks.probeLibghosttyVt.mockResolvedValue({ available: true }); + + await resolveCommandContext({ home: TEST_FLAG_HOME }, {}); + await resolveCommandContext({ home: TEST_FLAG_HOME }, {}); + + expect(mocks.probeLibghosttyVt).toHaveBeenCalledTimes(1); }); it('rejects invalid renderer names', async () => { @@ -231,6 +295,7 @@ describe('CLI context resolution', () => { logger: createLogger('info', () => undefined), profileDefault: 'default-profile', rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', configFile: null, }); setCommandContext(command, cachedContext); diff --git a/test/unit/commands/create.test.ts b/test/unit/commands/create.test.ts index d5cb9683..4d69d462 100644 --- a/test/unit/commands/create.test.ts +++ b/test/unit/commands/create.test.ts @@ -56,6 +56,7 @@ describe('create command', () => { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/doctor.test.ts b/test/unit/commands/doctor.test.ts index 3a79647f..893c9595 100644 --- a/test/unit/commands/doctor.test.ts +++ b/test/unit/commands/doctor.test.ts @@ -138,14 +138,12 @@ describe('doctor command', () => { 'record-export-webm', 'dashboard', ]); - expect(result.capabilities.find(({ name }) => name === 'snapshot')).toEqual( - { - name: 'snapshot', - status: 'available', - reason: 'built-in capability', - detail: 'available without external renderer dependencies', - }, - ); + expect( + result.capabilities.find(({ name }) => name === 'snapshot'), + ).toMatchObject({ + name: 'snapshot', + status: 'available', + }); expect( result.capabilities.find(({ name }) => name === 'screenshot'), ).toMatchObject({ diff --git a/test/unit/commands/gc.test.ts b/test/unit/commands/gc.test.ts index 66c9e497..5abd8c63 100644 --- a/test/unit/commands/gc.test.ts +++ b/test/unit/commands/gc.test.ts @@ -506,6 +506,7 @@ describe('runGcCommand (cross-Home sweep)', () => { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', configFile: null, } as const; } diff --git a/test/unit/commands/inspect.test.ts b/test/unit/commands/inspect.test.ts index f300344a..6e406ec0 100644 --- a/test/unit/commands/inspect.test.ts +++ b/test/unit/commands/inspect.test.ts @@ -67,6 +67,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; @@ -520,6 +521,7 @@ describe('inspect command', () => { session: liveSession, cliVersion: '0.2.1', rpcSocketPath: '/tmp/agent-tty/sessions/session-01/rpc.sock', + rendererBackend: 'libghostty-vt', rendererProfile: 'reference-dark', rendererBooted: true, rendererBootInFlight: false, @@ -551,7 +553,7 @@ describe('inspect command', () => { rpcSocketPath: '/tmp/agent-tty/sessions/session-01/rpc.sock', }); expect(emitted.result.rendererRuntime).toEqual({ - backend: 'ghostty-web', + backend: 'libghostty-vt', mode: 'live-host', status: 'healthy', profile: 'reference-dark', @@ -562,7 +564,7 @@ describe('inspect command', () => { expect.arrayContaining([ 'Host CLI Version: 0.2.1', 'RPC Socket: /tmp/agent-tty/sessions/session-01/rpc.sock', - 'Renderer: ghostty-web (live-host, healthy) [profile: reference-dark, booted: yes]', + 'Renderer: libghostty-vt (live-host, healthy) [profile: reference-dark, booted: yes]', ]), ); }); diff --git a/test/unit/commands/mark.test.ts b/test/unit/commands/mark.test.ts index 291285d3..9e6fb3cb 100644 --- a/test/unit/commands/mark.test.ts +++ b/test/unit/commands/mark.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/paste.test.ts b/test/unit/commands/paste.test.ts index da5f1297..3d5a7079 100644 --- a/test/unit/commands/paste.test.ts +++ b/test/unit/commands/paste.test.ts @@ -34,6 +34,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index 30481ff0..f80304d0 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -102,7 +102,8 @@ const TEST_CONTEXT = { logLevel: 'info', logger: createLogger('info', () => undefined), profileDefault: undefined, - rendererDefault: 'ghostty-web', + rendererDefault: 'libghostty-vt', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/resize.test.ts b/test/unit/commands/resize.test.ts index e1be4d7c..2139eb9f 100644 --- a/test/unit/commands/resize.test.ts +++ b/test/unit/commands/resize.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/run.test.ts b/test/unit/commands/run.test.ts index 3bc966a9..bee3bba3 100644 --- a/test/unit/commands/run.test.ts +++ b/test/unit/commands/run.test.ts @@ -35,6 +35,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/screenshot.test.ts b/test/unit/commands/screenshot.test.ts index eadcb3cf..2db0b064 100644 --- a/test/unit/commands/screenshot.test.ts +++ b/test/unit/commands/screenshot.test.ts @@ -79,7 +79,8 @@ const TEST_CONTEXT = { logLevel: 'info', logger: createLogger('info', () => undefined), profileDefault: undefined, - rendererDefault: 'ghostty-web', + rendererDefault: 'libghostty-vt', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/send-keys.test.ts b/test/unit/commands/send-keys.test.ts index c9528732..a132315e 100644 --- a/test/unit/commands/send-keys.test.ts +++ b/test/unit/commands/send-keys.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/signal.test.ts b/test/unit/commands/signal.test.ts index 341b64be..b8414409 100644 --- a/test/unit/commands/signal.test.ts +++ b/test/unit/commands/signal.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/snapshot.test.ts b/test/unit/commands/snapshot.test.ts index 74289316..a8adbf22 100644 --- a/test/unit/commands/snapshot.test.ts +++ b/test/unit/commands/snapshot.test.ts @@ -73,6 +73,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/type.test.ts b/test/unit/commands/type.test.ts index 39c56cf6..f66c48d0 100644 --- a/test/unit/commands/type.test.ts +++ b/test/unit/commands/type.test.ts @@ -34,6 +34,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/version.test.ts b/test/unit/commands/version.test.ts index 422a1c3d..8fc3f01b 100644 --- a/test/unit/commands/version.test.ts +++ b/test/unit/commands/version.test.ts @@ -19,7 +19,7 @@ describe('version command', () => { expect(result.cliVersion).toMatch(SEMVER_WITH_OPTIONAL_PRERELEASE); expect(result.protocolVersion).toBe('0.1.0'); - expect(result.rendererBackends).toEqual(['ghostty-web']); + expect(result.rendererBackends).toEqual(['ghostty-web', 'libghostty-vt']); expect(result.runtime.node).toMatch(/^v\d+\.\d+\.\d+$/); expect('capabilities' in result).toBe(false); }); @@ -56,7 +56,7 @@ describe('version command', () => { expect(result.cliVersion).toMatch(SEMVER_WITH_OPTIONAL_PRERELEASE); expect(result.protocolVersion).toBe('0.1.0'); - expect(result.rendererBackends).toEqual(['ghostty-web']); + expect(result.rendererBackends).toEqual(['ghostty-web', 'libghostty-vt']); expect(result.runtime.node).toMatch(/^v\d+\.\d+\.\d+$/); expect(result.runtime.platform).toBe(process.platform); expect(result.runtime.arch).toBe(process.arch); diff --git a/test/unit/commands/wait.test.ts b/test/unit/commands/wait.test.ts index f9022bcc..fb63e0bd 100644 --- a/test/unit/commands/wait.test.ts +++ b/test/unit/commands/wait.test.ts @@ -52,6 +52,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + rendererVisualDefault: 'ghostty-web', explicitHome: false, configFile: null, } as const; @@ -414,6 +415,30 @@ describe('wait command', () => { ); }); + it('passes the semantic renderer default to render wait RPCs', async () => { + const result = { + matched: true, + timedOut: false, + matchedText: 'hello', + capturedAtSeq: 7, + }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand( + createOptions({ + context: { ...TEST_CONTEXT, rendererDefault: 'libghostty-vt' }, + text: 'hello', + }), + ); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-tty/sessions/session-01/rpc.sock', + 'waitForRender', + expect.objectContaining({ rendererName: 'libghostty-vt' }), + 605_000, + ); + }); + it('routes --regex waits to the render wait RPC', async () => { const result = { matched: true, diff --git a/test/unit/host/renderer.test.ts b/test/unit/host/renderer.test.ts index a506015b..8b4cba3a 100644 --- a/test/unit/host/renderer.test.ts +++ b/test/unit/host/renderer.test.ts @@ -210,6 +210,46 @@ describe('HostRendererManager', () => { expect(getCreatedBackend(backends, 1)).toBe(secondBackend); }); + it('keeps a backend leased until the operation completes', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + const operationStarted = createDeferred(); + const releaseOperation = createDeferred(); + + const firstOperation = manager.withBackend( + 'libghostty-vt', + createProfile('dark'), + null, + async () => { + operationStarted.resolve(undefined); + await releaseOperation.promise; + return 'leased'; + }, + ); + await operationStarted.promise; + + const replacementOperation = manager.withBackend( + 'ghostty-web', + createProfile('dark'), + null, + (backend) => backend.rendererBackend, + ); + await flushAsyncQueue(); + + expect(backendFactory).toHaveBeenCalledTimes(1); + expect(getCreatedBackend(backends, 0).disposeMock).not.toHaveBeenCalled(); + + releaseOperation.resolve(undefined); + + await expect(firstOperation).resolves.toBe('leased'); + await expect(replacementOperation).resolves.toBe('fake-renderer'); + expect(backendFactory).toHaveBeenCalledTimes(2); + expect(getCreatedBackend(backends, 0).disposeMock).toHaveBeenCalledTimes(1); + }); + it('skips replay when the replay target sequence is -1', async () => { const manager = new HostRendererManager({ sessionId: 'session-01', diff --git a/test/unit/pty/createPty.test.ts b/test/unit/pty/createPty.test.ts index 51c8b535..62c0e711 100644 --- a/test/unit/pty/createPty.test.ts +++ b/test/unit/pty/createPty.test.ts @@ -44,6 +44,16 @@ describe('resolvePtyEnv', () => { expect(resolved.TERM).toBe('vt100'); }); + it('strips host-only renderer defaults from inherited env', () => { + const resolved = resolvePtyEnv({}, 'xterm-256color', { + AGENT_TTY_HOST_RENDERER: 'libghostty-vt', + AGENT_TTY_RENDERER: 'ghostty-web', + }); + + expect(resolved.AGENT_TTY_HOST_RENDERER).toBeUndefined(); + expect(resolved.AGENT_TTY_RENDERER).toBe('ghostty-web'); + }); + it('passes through inherited and caller env entries and drops undefined values', () => { const resolved = resolvePtyEnv({ FOO: 'bar' }, 'xterm-256color', { BAZ: 'qux', diff --git a/test/unit/renderer/capabilities.test.ts b/test/unit/renderer/capabilities.test.ts index 6a2c5d83..c63e3516 100644 --- a/test/unit/renderer/capabilities.test.ts +++ b/test/unit/renderer/capabilities.test.ts @@ -19,7 +19,7 @@ describe('discoverCapabilities', () => { probeLibghosttyVt, }); - expect(probePlaywright).toHaveBeenCalledTimes(2); + expect(probePlaywright).toHaveBeenCalledTimes(1); expect(probeLibghosttyVt).toHaveBeenCalledTimes(1); expect(capabilities).toHaveLength(6); expect(capabilities.map((capability) => capability.name)).toEqual([ @@ -100,12 +100,70 @@ describe('discoverCapabilities', () => { }); }); + it('uses ghostty-web as the quick semantic fallback when libghostty-vt is missing', async () => { + const capabilities = await discoverCapabilities('quick', { + probeLibghosttyVt: () => + Promise.resolve({ + available: false, + reason: 'libghostty-vt not installed', + detail: 'missing optional native package', + }), + probePlaywright: () => Promise.resolve({ available: true }), + }); + + expect(getCapability(capabilities, 'snapshot')).toEqual({ + name: 'snapshot', + status: 'available', + }); + expect(getCapability(capabilities, 'wait')).toEqual({ + name: 'wait', + status: 'available', + }); + }); + + it('marks quick semantic capabilities unavailable when no renderer can serve them', async () => { + const capabilities = await discoverCapabilities('quick', { + probeLibghosttyVt: () => + Promise.resolve({ + available: false, + reason: 'libghostty-vt not installed', + detail: 'missing optional native package', + }), + probePlaywright: () => + Promise.resolve({ + available: false, + reason: 'playwright not installed', + detail: 'missing browser renderer package', + }), + }); + + expect(getCapability(capabilities, 'snapshot')).toEqual({ + name: 'snapshot', + status: 'unavailable', + reason: 'semantic renderer unavailable', + detail: + 'missing optional native package; missing browser renderer package', + }); + expect(getCapability(capabilities, 'wait')).toEqual({ + name: 'wait', + status: 'degraded', + reason: 'render waits unavailable', + detail: + 'legacy --exit and --idle-ms wait modes remain available; missing optional native package; missing browser renderer package', + }); + }); + it('uses full doctor renderer checks without re-probing playwright', async () => { const probePlaywright = vi.fn(() => Promise.resolve({ available: true })); const capabilities = await discoverCapabilities('full', { probePlaywright, rendererChecks: [ + { + name: 'libghostty_vt_available', + status: 'pass', + message: 'native available', + }, { name: 'playwright_available', status: 'pass', @@ -133,8 +191,8 @@ describe('discoverCapabilities', () => { expect(getCapability(capabilities, 'snapshot')).toEqual({ name: 'snapshot', status: 'available', - reason: 'built-in capability', - detail: 'available without external renderer dependencies', + reason: 'libghostty-vt semantic renderer available', + detail: 'native available', }); expect(getCapability(capabilities, 'screenshot')).toMatchObject({ name: 'screenshot', @@ -151,6 +209,44 @@ describe('discoverCapabilities', () => { }); }); + it('keeps full wait degraded when render dependencies are unavailable', async () => { + const capabilities = await discoverCapabilities('full', { + rendererChecks: [ + { + name: 'libghostty_vt_available', + status: 'skip', + message: 'native missing', + }, + { + name: 'playwright_available', + status: 'fail', + message: 'playwright missing', + }, + { + name: 'browser_launch', + status: 'skip', + message: 'not attempted', + }, + { + name: 'ghostty_web_available', + status: 'skip', + message: 'not attempted', + }, + ], + }); + + expect(getCapability(capabilities, 'snapshot')).toMatchObject({ + name: 'snapshot', + status: 'unavailable', + reason: 'semantic renderer unavailable', + }); + expect(getCapability(capabilities, 'wait')).toMatchObject({ + name: 'wait', + status: 'degraded', + reason: 'render waits unavailable', + }); + }); + it('reports degraded full browser-backed capabilities from failing renderer checks', async () => { const capabilities = await discoverCapabilities('full', { rendererChecks: [ diff --git a/test/unit/renderer/libghosttyVtBackend.test.ts b/test/unit/renderer/libghosttyVtBackend.test.ts index bf01833f..f9eeee76 100644 --- a/test/unit/renderer/libghosttyVtBackend.test.ts +++ b/test/unit/renderer/libghosttyVtBackend.test.ts @@ -170,6 +170,7 @@ describe('LibghosttyVtBackend', () => { expect(fixture.createTerminal).toHaveBeenCalledWith({ cols: 100, rows: 30, + scrollbackLimit: 10_000, }); expect(backend.isBooted).toBe(true); }); @@ -480,10 +481,11 @@ describe('LibghosttyVtBackend', () => { showCursor: true, }, ); + expect(fallback.disposeMock).toHaveBeenCalledTimes(1); expect(result.rendererBackend).toBe('ghostty-web'); }); - it('disposes native and fallback resources idempotently', async () => { + it('disposes native resources idempotently after screenshot fallback cleanup', async () => { const fixture = createNativeFixture(); const fallback = createFakeBackend({ rendererBackend: 'ghostty-web', diff --git a/test/unit/renderer/registry.test.ts b/test/unit/renderer/registry.test.ts index ab06cfe9..825ceb62 100644 --- a/test/unit/renderer/registry.test.ts +++ b/test/unit/renderer/registry.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'; import { DEFAULT_RENDERER_NAME, + DEFAULT_SEMANTIC_RENDERER_NAME, + DEFAULT_VISUAL_RENDERER_NAME, createRendererBackend, resolveRendererName, } from '../../../src/renderer/index.js'; @@ -22,8 +24,10 @@ function createProfile(): RenderProfileConfig { } describe('renderer registry', () => { - it('resolves the default renderer name', () => { + it('resolves renderer default constants', () => { expect(DEFAULT_RENDERER_NAME).toBe('ghostty-web'); + expect(DEFAULT_SEMANTIC_RENDERER_NAME).toBe('libghostty-vt'); + expect(DEFAULT_VISUAL_RENDERER_NAME).toBe('ghostty-web'); expect(resolveRendererName(undefined)).toBe('ghostty-web'); });