feat(renderer): add selectable libghostty-vt backend#42
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
@coder/libghostty-vt-nodeas an optional, selectable semantic renderer backend. Selection is per-command via a new global--renderer <ghostty-web|libghostty-vt>flag,AGENT_TTY_RENDERERenv var, ordefaultRendererconfig key.ghostty-webremains the default;libghostty-vtis opt-in and loaded lazily.When
libghostty-vtis selected, semantic operations (snapshot,wait,getVisibleText) flow through the native addon; PNG screenshots and WebM exports transparently fall back toghostty-web, and artifact metadata (rendererBackend) honestly reports the actual producer.User-facing changes
--renderer <ghostty-web|libghostty-vt>.AGENT_TTY_RENDERER.defaultRenderer.ghostty-web(default).--renderer ghostty-webas recovery.record export --format webm --renderer libghostty-vtworks via explicit fallback toghostty-web(documented as semantic-only in v1).Implementation highlights
src/renderer/names.ts,src/renderer/registry.ts,src/renderer/index.ts: renderer registry withz.enum-validated names and a lazy factory.src/renderer/libghosttyVt/backend.ts(+index.ts): semantic backend implementingRendererBackend. Lazy-imports@coder/libghostty-vt-node, maps nativeTerminalSnapshot→SemanticSnapshot, delegates screenshots to an injectableGhosttyWebBackendfallback factory, and returns fallback PNG metadata unchanged.src/host/renderer.ts:HostRendererManagercache keyed by(rendererName, profile); disposes and recreates on either change (one live backend at a time).src/host/hostMain.ts: per-request renderer resolution (params.rendererName ?? AGENT_TTY_RENDERER ?? default).src/protocol/schemas.ts: optionalrendererNameadded to snapshot / screenshot / wait-for-render params.src/replay/offlineReplay.ts,src/export/webm.ts, and thesnapshot/screenshot/wait/record-exportCLI commands now thread the renderer selection end to end.package.json:@coder/libghostty-vt-node@0.1.0-beta.0added tooptionalDependencies; imports remain lazy so default users and unsupported platforms never load the native addon.Validation
Ran
npm run verifyon the final tree:format:check+lint+typechecktest/e2e/libghostty-vt-renderer.test.tsbuildsmoke:install(tarball route)New test coverage:
test/unit/renderer/registry.test.ts,test/unit/renderer/libghosttyVtBackend.test.ts(injectedloadNative+fallbackFactory— no real native load),test/integration/backend-selection.test.ts, andtest/e2e/libghostty-vt-renderer.test.ts(native-guarded withtest.skipfallback).Dogfood proof
dogfood/20260424-libghostty-vt-renderer/contains a replayable proof bundle:commands.sh— self-contained script usingmktemp -dforAGENT_TTY_HOMEand localnpx tsx src/cli/main.ts ...invocations.environment.txt,native-info.json—@coder/libghostty-vt-node@0.1.0-beta.0, Ghostty0.1.0-dev, NAPI 10, linux x64.ghostty-web-{wait,snapshot,screenshot}.json.libghostty-vt-{wait,snapshot,screenshot,record-cast,record-webm}.json.libghostty-vt-screenshot.jsonreportsresult.rendererBackend == "ghostty-web";libghostty-vt-record-webm.jsonreportsresult.metadata.rendererBackend == "ghostty-web"— honest fallback metadata.screenshots/ghostty-web.pngandscreenshots/libghostty-vt-fallback.pngare byte-identical (enforced bycmp -s).videos/libghostty-vt-fallback.webmandrecordings/terminal-session.castvalidated viafile.Notes / deviations
record exportaccepts--format asciicast(not--format cast); the saved file is still namedterminal-session.cast.--versionis not a registered CLI flag;environment.txtrecordsversion --jsonoutput instead.inspect.jsonin the dogfood bundle is captured after the fixture exited, so it reports offline-replay runtime state; the semanticlibghostty-vtproof is in the--renderer libghostty-vtwait/snapshot envelopes.Risks
0.1.0-beta.0; missing on a platform → lazy import error with actionable recovery hint. No hard dependency on the addon.libghostty-vtis semantic-only in v1; PNG/WebM useghostty-web. A follow-up could add an explicitrequestedRendererfield if reviewers want both the requested and produced renderer recorded on every artifact.📋 Implementation Plan
Plan: Add selectable
libghostty-vtrenderer backendGoal
Implement
@coder/libghostty-vt-nodeas an optional semantic renderer backend for agent-tty, selectable through:--renderer <ghostty-web|libghostty-vt>AGENT_TTY_RENDERERdefaultRendererThe new backend should use
libghostty-vtfor terminal state semantics (snapshot,wait,getVisibleText) while preserving the existingghostty-web+ Playwright path for PNG screenshots and WebM export fallback.Verified context and constraints
RendererBackendinsrc/renderer/backend.tswithboot,replayTo,snapshot,screenshot,getVisibleText,dispose,rendererBackend, andisBooted.GhosttyWebBackendinsrc/renderer/ghosttyWeb/backend.ts.src/host/hostMain.tscreatesHostRendererManagerwithnew GhosttyWebBackend(...).src/replay/offlineReplay.tsdefaults offline replay tonew GhosttyWebBackend(...).src/export/webm.tsdefaults WebM export tonew GhosttyWebBackend(...).HostRendererManagerinsrc/host/renderer.tsalready accepts a backend factory, but it is currently effectively fixed per host instance and keyed around profile changes rather than renderer-name changes.AGENT_TTY_*; useAGENT_TTY_RENDERER, notAGENT_TERMINAL_RENDERER.@coder/libghostty-vt-node@0.1.0-beta.0currently exports:createTerminal({ cols, rows, scrollbackLimit? })feed,resize,snapshot,getVisibleText, optionalformatPlain, optionalformatHtml,disposecols,rows,cursorRow,cursorCol,isAltScreen,visibleLines, optionalscrollbackLines, optionalcellsgetNativeInfo()Design decisions
ghostty-webas default.libghostty-vtshould start as opt-in because it is new, native, and semantically scoped.--renderer libghostty-vtas a hybrid renderer choice. It means semantic operations use nativelibghostty-vt; screenshot/video operations transparently delegate toghostty-web.--profile/AGENT_TTY_PROFILE.snapshot --renderer libghostty-vt <session>should affect the RPC request, not only sessions originally started with that env var.ghostty-webusers should not pay startup cost or fail due to native addon loading when they did not request the backend.Advisor review refinements
An advisor review confirmed the plan direction and recommended these refinements, which are incorporated below:
HostRendererManagerminimal with one live backend at a time, keyed by(rendererName, profile). Recreate on renderer/profile changes; do not introduce a backend pool in v1.libghostty-vtsemantic-only in v1. Use it forsnapshot,getVisibleText, and render waits; useghostty-webfor PNG/WebM fallback.rendererBackendshould remain the backend that actually created the artifact; a fallback screenshot requested withlibghostty-vtshould still reportghostty-webas the PNG producer.Phase 1 — Renderer selection plumbing
1. Add renderer registry
Create
src/renderer/registry.tswith:RendererNameSchema = z.enum(['ghostty-web', 'libghostty-vt'])type RendererName = z.infer<typeof RendererNameSchema>DEFAULT_RENDERER_NAME = 'ghostty-web'resolveRendererName(input: string | undefined): RendererNamecreateRendererBackend(rendererName, sessionId, profile, options?)Implementation notes:
GhosttyWebBackendforghostty-webLibghosttyVtBackendforlibghostty-vt2. Add CLI/env/config resolution
Update:
src/cli/main.ts.option('--renderer <name>', 'Default renderer backend name')process.env.AGENT_TTY_RENDERERafter context resolution, mirroringAGENT_TTY_HOME/AGENT_TTY_LOG_LEVELpropagation.src/cli/context.tsrenderer?: stringtoGlobalCliOptionsrendererDefault: RendererNameorstringtoCommandContextAGENT_TTY_RENDERER→ configdefaultRenderer→ghostty-webresolveRendererName.src/config/resolveConfig.tsdefaultRenderer: z.string().optional()toConfigFileSchema.Quality gate after Phase 1:
npm run typecheck npm run test -- context resolveConfig rendererPhase 2 — Make host/offline replay renderer-aware
1. Update host renderer manager selection
Update
src/host/renderer.tssoHostRendererManagercan select by renderer and profile.Minimal approach:
getBackend(...)to acceptrendererName:rendererNamein the cache key along with the profile identity/hash. If either changes, dispose the old backend and create a new one.(rendererName, profileHash).Defensive checks:
2. Thread renderer through live RPC requests
Update internal protocol schemas in
src/protocol/schemas.tsfor render-affecting RPC params:SnapshotParamsSchema: add optionalrendererNameScreenshotParamsSchema: add optionalrendererNamein addition to existingprofileWaitForRenderParamsSchema: add optionalrendererNameThen update callers/handlers:
src/cli/commands/snapshot.tsrendererName: context.rendererDefaultin RPC params and offline replay options.src/cli/commands/screenshot.tsrendererName: context.rendererDefaultin RPC params and offline replay options.--profilebehavior unchanged; profile and renderer are separate.src/cli/commands/wait.tsrendererName: context.rendererDefaultinwaitForRenderRPC params and offline replay options when render wait is used.src/host/hostMain.tsparams.rendererName ?? process.env.AGENT_TTY_RENDERER ?? DEFAULT_RENDERER_NAME.rendererManager.getBackend(rendererName, profile, replayInput).src/replay/offlineReplay.tsrendererName?: RendererNamein options/deps.backendFactoryoverride is provided.src/export/webm.tsandsrc/cli/commands/record-export.tsrendererName === 'libghostty-vt', use the hybrid backend’s video fallback or explicitly resolve to the existingghostty-webvideo-capable backend.Quality gate after Phase 2:
Phase 3 — Add
LibghosttyVtBackendCreate:
src/renderer/libghosttyVt/backend.tssrc/renderer/libghosttyVt/index.tsExport it from
src/renderer/index.tsif that file is the project’s public renderer export surface.Backend behavior
LibghosttyVtBackendshould implementRendererBackendonly in v1. Do not make it implementVideoCapableRendererBackendinitially; WebM/export should explicitly useghostty-webwhile the selected semantic renderer remainslibghostty-vt.Core fields:
readonly rendererBackend = 'libghostty-vt'isBooted: boolean@coder/libghostty-vt-nodeGhosttyWebBackendfor screenshot/video-only operationsConstructor:
The options are for tests and should not leak into the public CLI surface.
Native loading
@coder/libghostty-vt-nodeinboot().libghostty-vt@coder/libghostty-vt-node--renderer ghostty-webgetNativeInfo()at debug level if available so dogfood logs can record the native package and Ghostty commit.Replay mapping
In
replayTo(input: ReplayInput):GhosttyWebBackend.terminal.resize(cols, rows).ReplayInputor enough replay state to lazily replay into screenshot fallback.ReplayStateconsistent with current backend expectations:lastSeq,cols,rows, cursor position when available.Defensive checks:
cols/rowsbefore creating/resizing the native terminal.dispose().Snapshot mapping
Map
@coder/libghostty-vt-nodeTerminalSnapshotinto this repo’sSemanticSnapshot:{ row, text }mappingcursorRow/cursorColisAltScreencols/rowslastSeqsessionIdDo not silently coerce missing or malformed native data. Validate through existing schemas where practical.
Screenshot and WebM fallback
screenshot(outputPath, options):GhosttyWebBackendwith the same session/profile.screenshot(outputPath, options).rendererBackend: 'ghostty-web'becauseghostty-webproduced the PNG.WebM/export in v1:
LibghosttyVtBackend.record export --format webm --renderer libghostty-vtexplicitly resolve the export backend toghostty-web.libghostty-vtis a semantic renderer, not a video renderer.dispose():GhosttyWebBackendif created.isBooted = false.Quality gate after Phase 3:
npm run typecheck npm run test -- libghosttyVtBackend backend-selectionArtifact metadata guidance
rendererBackendsemantics as actual producer metadata.libghostty-vtwhere the result shape supports backend metadata.ghostty-webbecause the PNG was created byghostty-web.ghostty-webfor the same reason.requestedRendererfield in a follow-up schema migration. Do not overloadrendererBackendin this change.Phase 4 — Dependency and packaging
Update
package.json:@coder/libghostty-vt-nodeas anoptionalDependency, pinned initially to0.1.0-beta.0or the version selected by the implementer.npm run builddoes not require the native addon to load.Update package/release docs only if needed:
libghostty-vtis optional/experimental.Quality gate after Phase 4:
npm install # or mise run bootstrap, depending on repo workflow npm run build npm run typecheckPhase 5 — Tests
Unit tests
Add/extend:
test/unit/renderer/registry.test.tsghostty-webtest/unit/renderer/libghosttyVtBackend.test.tsboot()boot()creates terminal with positive dimensionsreplayTo()feeds output and resize eventssnapshot()maps visible lines, scrollback, cursor, alt-screen, and cellsgetVisibleText()delegates to native terminalGhosttyWebBackendand returns PNG metadatadispose()is idempotenttest/unit/host/renderer.test.tstest/unit/commands/*or existing command testsrendererNamefrom contextUse constructor dependency injection/mocks rather than loading the real native addon in unit tests.
Integration tests
Add/extend:
test/integration/backend-selection.test.ts--renderer ghostty-webpreserves current behaviorAGENT_TTY_RENDERER=ghostty-webpreserves current behaviordefaultRendereris honoredFor real native integration, use one guarded test block:
@coder/libghostty-vt-node.snapshotandwait --textagainst a simple fixture withAGENT_TTY_RENDERER=libghostty-vt.E2E tests
Add a focused E2E test only if the native package loads in CI/dev:
test/fixtures/apps/hello-promptor equivalentFull validation before handoff:
mise run format-check mise run lint mise run typecheck mise run test mise run buildFallback if
miseis unavailable:npm run format:check npm run lint npm run typecheck npm run test npm run buildPhase 6 — Dogfooding and reviewer proof
Create a proof bundle, for example:
Dogfood setup:
Baseline renderer run:
Native semantic renderer run:
Proof requirements:
screenshots/.asciinema recif available, otherwisescript.native-info.jsonfrom@coder/libghostty-vt-node.getNativeInfo()so reviewers know exactly which native binding/Ghostty build was used.Dogfood quality gate:
Acceptance criteria
ghostty-webremains the default renderer and existing tests continue to pass.--renderer ghostty-webandAGENT_TTY_RENDERER=ghostty-webbehave like current default behavior.--renderer libghostty-vtuses@coder/libghostty-vt-nodeforsnapshot,wait, and visible text.--renderer libghostty-vt screenshot ...succeeds by replaying intoghostty-webfallback for PNG rendering.record export --format webmcontinues to work whenlibghostty-vtis selected, either through hybrid fallback or explicitghostty-webresolution.libghostty-vtis selected.defaultRendererand env varAGENT_TTY_RENDERERare documented and tested.Risks and mitigations
getNativeInfo()in dogfood, and keep mapping code isolated insrc/renderer/libghosttyVt/.ghostty-webandlibghostty-vt: start with text/snapshot assertions, not pixel parity; screenshots remainghostty-web.rendererNamein render RPC params and by makingHostRendererManagerrenderer-aware.ghostty-weband document thatlibghostty-vtis not a video renderer.Generated with
mux• Model:anthropic:claude-opus-4-7• Thinking:max