Skip to content

feat(ghostwright): add Freedom tty semantic inspection#6

Draft
rauhryan wants to merge 48 commits into
mainfrom
ghostwright-acceptance
Draft

feat(ghostwright): add Freedom tty semantic inspection#6
rauhryan wants to merge 48 commits into
mainfrom
ghostwright-acceptance

Conversation

@rauhryan

Copy link
Copy Markdown
Contributor

Summary

  • add framework-neutral ordered OSC extension hooks and semantic revisions to Ghostwright
  • add the opt-in Freedom+tty producer and CSS selector consumer extension
  • migrate the pizza modal semantic scenario from spike support to the production extension
  • validate OSC chunking, oversized-sequence discard, ANSI frame preservation, and geometry handling

Validation

  • bun test experiments/ghostwright/test/extensions.test.ts
  • bun test packages/freedom-tty/test/geometry.test.ts
  • bun test packages/demo/test/freedom-tty-modal-spike.test.ts
  • bun build packages/freedom-tty/src/index.ts --outdir /tmp/gw-semantic-build --target node --format esm --packages external
  • bun build packages/freedom-tty/src/producer.ts --outdir /tmp/gw-semantic-build --target node --format esm --packages external

Follow-up

visibleBounds is intentionally omitted unless tty supplies authoritative clipping geometry. Extending tty RenderInfo with resolved clipping bounds is tracked as follow-up work.

cowboyd added 30 commits June 24, 2026 21:40
Self-eval deadlock avoidance used NodeContext to detect re-entrant calls,
but NodeContext spans the whole node scope — including the root event loop
task — so the event loop's dispatch wrongly short-circuited into its own
scope, where middleware (registered in the eval-loop scope) was invisible.
Use a dedicated EvalOwner context set inside spawnEvalLoop, which is present
only while executing within that loop. Genuine re-entrant self-evals still
run inline; the event loop's dispatch routes through the channel as before.

Adds a regression test for re-entrant self-eval (previously uncovered).
Each node captures its task scope (useScope); eval repoints the current
routine's scope at the node's scope, runs the op inline, and restores —
immediate, non-serial, re-entrant by nesting. Removes the channel/eval-loop,
CallEval, box, and EvalOwner. Focus stays synchronous and no longer deadlocks
on cross-node setFocused during dispatch.

GA3 now waits a turn (sleep 0) for concurrent component init, like its siblings —
inline eval no longer grants the incidental scheduler turn the channel did.
…Tree/append/eval)

- focus.ts: sync functions taking a node; useFocus installs a remove-interceptor
  via node.scope.around(NodeApi)
- NodeApi gains sync remove; Node.remove() returns the teardown Promise
- delete freedom.ts (FreedomApi/append/operations) and tree.ts (useTree)
- drop Component/Tree/Node.eval from types; mod.ts exports the new surface
- focus.test.ts rewritten imperatively (synchronous)
Add an options arg to createChild so a fresh child can be spliced before an
existing sibling: createChild(name, { before }). Rebuilds the children Set to
splice in position. Throws when before is not a current child. options is left
open for future positioning hints (after, at).

Spec: C14/C15, N7, N22.
cowboyd and others added 18 commits June 30, 2026 08:40
Brings in the examples/ + experiments/ restructure and the Ghostwright
package (experiments/ghostwright) while preserving the focus-stack work
under packages/.

Conflict resolutions:
- packages/demo/package.json: kept focus-stack version (holds pizza.ts,
  use-input.ts, use-stdin.ts, freedom-focus-text-input.ts). Renamed the
  package "demo" -> "focus-demo" to avoid a duplicate workspace package
  name with main's examples/demo.
- package.json: took main's newer root config (playground script, bsh
  test excluding ghostwright, catalog deps); re-added the `demo` script.
- pnpm-lock.yaml: took main's and regenerated via pnpm install.
Adds ghostwright as a workspace devDependency of focus-demo and a smoke
test proving the loop works: pizza.ts boots under a real PTY, paints its
form, and echoes typed input.

This is scaffolding, not real coverage - replace/extend with actual
acceptance tests.
…safe

Three defects made `bun run build:artifacts` unusable from a clean clone.

1. build:artifacts destroyed ghostty.lock.json. update-manifest.ts
   rebuilt the whole lockfile from a hardcoded copy that had drifted from
   the committed one, so every run downgraded bindingVersion 2 -> 1,
   deleted the `graphics` block, dropped the 8 Kitty entries from
   requiredWasmExports, and discarded checksums for targets not built
   locally. The lockfile is now the source of truth: only the `artifacts`
   checksum map is rewritten, only for artifacts built on this machine,
   and entries for other targets are preserved.

   This also made the build non-idempotent. Because build-ghostty-vt.ts
   reads graphics.freestandingPatchSha256, a second build:artifacts died
   with GW_PATCH_CHECKSUM.

2. build:ghostty-vt failed on first run. It copied the wasm into
   artifacts/, which is gitignored and absent in a clean clone, so it
   died at `cp` after a successful ~1min Zig build. It now creates the
   directory.

3. verify:artifacts could never pass locally. It required every artifact
   in the manifest to exist, which is false on any single platform, and
   it demanded bindingVersion 2 while update-manifest wrote 1. It now
   skips and reports absent artifacts, and still fails hard on any
   present artifact whose checksum, protocol marker, exports, or ABI
   does not match.

Also adds `bun run setup` (build + verify) and `bun run update:manifest`,
friendly preflight errors when Zig or the Ghostty checkout is missing,
and documents the maintainer path in the README. Manifest writes are
normalized through `bsh format` so a no-op build produces no diff.

Verified: two consecutive build:artifacts runs from a wiped artifacts/
and .cache/ both succeed with zero lockfile churn, verify:artifacts
passes, and a full rebuild reproduces the committed checksums bit for
bit. Lint and format gates match the pre-change baseline.
Drives the demo through a real PTY and asserts focus purely from visible
terminal state, with no application instrumentation.

pizza.ts expresses text-field focus two independent ways: the field's box
border is WHITE when focused and GRAY otherwise, and only the focused
field renders a caret so the terminal cursor sits inside its box. Both
are asserted, so the tests fail if either indicator regresses.

Field boxes are located by walking down from their label row rather than
by hardcoded coordinates, so layout changes above them do not break the
tests. Predicates passed to toSatisfy are total, since they are evaluated
against pre-paint frames as well.

Verified by mutation: removing the Tab keypress, and swapping the
focused/unfocused border colours, each make all three tests fail.
…ates

Addresses the friction found while writing the pizza focus tests, where
asserting "is this field focused" required abandoning locators and
hand-rolling cell scraping.

Locators can now express visual state:
- expectTerminal(locator).toHaveStyle(query) asserts how text is drawn,
  and reports the actual style on failure
- expectTerminal(locator).toContainCursor() asserts caret position
- getByText(text, { style }) filters by appearance, which disambiguates
  repeated text without positional .nth()
- LocatorMatch now carries the cells backing the match, so styles are
  reachable without re-deriving geometry from the raw snapshot
- screen.getCells(rect) returns a rectangle of cells
Colours accept '#rrggbb', 'rgb(r,g,b)', 'default', 'palette:N', or the
structured TerminalColor. Omitted StyleQuery fields are ignored.

Default assertion timeout is now DEFAULT_ASSERTION_TIMEOUT_MS (4000ms),
below the 5000ms default of Bun/Jest/Vitest. At equal values the runner
won the race and reported a bare "timed out", discarding Ghostwright's
screen diagnostic as an unattributed unhandled error.

toSatisfy/toHaveShown predicates run against every revision, including
blank pre-paint frames. A throwing predicate is now treated as
unsatisfied instead of aborting the assertion, and the last error is
included in the diagnostic. Previously a predicate that read a not yet
rendered layout failed the whole assertion for an unrelated reason.

keyboard.press() accepts 'Shift+Tab', 'Ctrl+c', 'Alt+x', 'Cmd+k', and
rejects unknown key names with InvalidKeyError. KeyName widens to string,
so a typo could not be caught at compile time and previously encoded
nothing, surfacing much later as an assertion timeout. The functional key
table is shared with the encoder so validation cannot drift from it.

screen.snapshot() is added as an alias of screen.current(), matching
AsyncRegion.snapshot(); the two names for one concept caused repeated
wrong guesses.

Docs: matches(), getCells(), style/cursor assertions, key syntax, and the
timeout rationale were undocumented or absent.

29 new tests. The pizza focus test is rewritten onto the new API as a
usability check, and still fails under both mutations.
Proves the minimum end-to-end path for semantic tty locators:

- a tty program renders a named 30x5 Clay element
- result.info.get(id) supplies its computed Clay bounding box
- the program appends a versioned, namespaced custom OSC to the same
  stdout write as the visible rendering
- a test-side stand-in for a future Ghostwright extension parses the
  accumulated raw PTY stream and exposes getById(id)
- the resulting element locator scopes Ghostwright text locators and
  exposes the corresponding screen cells

The acceptance test proves the metadata rect is not merely plausible: it
matches all four visible box corners and scopes the expected text. It
also proves the unknown OSC remains available in screen.rawOutput() while
Ghostty ignores it and does not paint its namespace or payload.

The parser tolerates an OSC split before its ST terminator. It reparses
the accumulated stream for simplicity; a real extension should keep an
incremental parser and attach completed metadata frames to screen
revisions.

OSC 7777 is explicitly experimental for this spike, not a proposed
permanent allocation.
Proves a browser-like semantic locator can be built by joining two
existing authoritative sources on Freedom's generated node ID:

- Freedom contributes name, parent/child order, safe semantic attributes,
  current focus, and the active modal focus root
- tty/Clay contributes the computed bounding rectangle through
  RenderInfo.get(node.id)
- an in-band, namespaced OSC carries one complete snapshot per render
- a test-side stand-in for a Ghostwright extension exposes lazy CSS-like
  locators that re-resolve against the newest complete frame

The selector spike supports semantic names, generated #IDs, *, attribute
selectors, :root, :focus, :focus-root, descendant combinators, direct
children, and nth() disambiguation.

The acceptance test drives the real pizza modal lifecycle. It proves:

- initial `form > name:focus`
- opening Add card creates `card-modal:focus-root`
- `[role=dialog]:focus-root` and the generated ID locate the same node
- three `[role=textbox]` children have Clay geometry
- focus is trapped inside the modal and wraps through five controls
- no form node is focused while the modal is active
- Cancel removes the modal from a pre-existing lazy locator
- focus is restored to `form > card:focus`
- every remaining semantic node has corresponding Clay geometry
- complete frame numbers are monotonic

The emitted schema deliberately whitelists role, label, input, and
focusability. `value` and `caret` are excluded; the test enters a card
number and verifies it is absent from decoded semantic metadata.

The spike also found a critical ordering constraint. Although Node makes
one stdout.write, the PTY split visual bytes and a trailing OSC into
separate host frames. Ghostwright publishes no revision for an unknown
OSC that causes no terminal-state change, so semantic waits never woke.
Metadata now precedes the visual bytes it describes. A future streaming
extension can hold that frame as pending and attach it to the next visual
revision without retroactively mutating an already-published revision.

Adds inspectFocus(), a read-only Freedom API for active root, focused
node, focus boundaries, and restoration targets. Freedom tests prove the
inspection before push, during modal capture, and after pop.
…etry

Exercises four geometry cases through real tty output and Ghostwright's
terminal grid:

1. RenderOptions.row shifts emitted CUP rows but Clay_GetElementData
   remains local to the render surface. A 1-based row origin must be
   applied before using Clay bounds as a Ghostwright region.

2. Centering an odd 5x3 element in an even 20x10 surface yields
   fractional Clay coordinates [7.5, 3.5, 5, 3]. clayterm rasterizes by
   casting each edge to int, producing visible bounds [7, 3, 5, 3].

3. A 20x7 child under a 12x5 clipping ancestor retains its full Clay
   layout bounds while only the ancestor's 12x5 intersection is painted.
   Logical bounds and visible bounds must be represented separately.

4. A floating element at x=-5 with width 40 retains negative/oversized
   Clay bounds while viewport clipping paints only columns 0..29.

The tests locate actual visible geometry by cell background and box
corners, so metadata is checked against terminal output rather than
trusted at face value. All four measurements pass through the custom OSC
spike transport.
Removes the spike's custom CSS parser and recursive matcher. Lazy
Freedom+tty locators now parse with css-what and compile once with
css-select, then re-run the compiled predicate against each newest
immutable semantic frame.

The custom adapter maps Freedom metadata to css-select's generic tree
contract: semantic names as element names, generated keys as IDs,
whitelisted attributes, ordered parent/children/siblings, safe labels as
text, and identity by frame-local key. Custom pseudos provide :focus,
:focus-root, and :visible. xmlMode preserves semantic-name case and
cacheResults is disabled because frames are immutable replacements.

The extension owns an explicit supported subset. It rejects
pseudo-elements, the nonstandard parent combinator and != operator,
unknown/jQuery pseudos, selectors over 4096 bytes, overlarge selector
lists/token counts, nesting beyond 8, and :has nesting beyond 2.

The unchanged pizza lifecycle remains green. Additional coverage proves
:has(), :nth-child(), adjacent/general siblings, selector lists,
attribute-prefix matching, :not(), custom pseudos, generated IDs, and
rejection/complexity diagnostics.

Adds css-select 7 and css-what 8 as demo-only dev dependencies. Direct
research verified the adapter approach under Node/Bun/Deno; keeping the
dependencies in the extension avoids imposing CSS semantics on
Ghostwright core.
@socket-security

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants