Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
b0a1529
📦 vendor freedom source into packages/freedom/src
cowboyd Jun 25, 2026
6c8e9bc
🔧 add Node manifest and tsconfig for @bomb.sh/freedom
cowboyd Jun 25, 2026
1ffe7b7
♻️ port freedom test harness to vitest
cowboyd Jun 25, 2026
ad68ce0
🎨 adopt house const style and disable require-yield in freedom
cowboyd Jun 25, 2026
96e0bae
🎨 disable no-generic-error and max-params in impacted freedom files
cowboyd Jun 25, 2026
d105cbc
🐛 point exports at built .mjs entry
cowboyd Jun 25, 2026
08069f8
📌 lock effection for @bomb.sh/freedom
cowboyd Jun 25, 2026
6f9e161
🐛 fix eval re-entrancy detection to scope dispatch middleware correctly
cowboyd Jun 26, 2026
c0880c1
➕ add @bomb.sh/freedom dep to demo package
cowboyd Jun 26, 2026
c20e9f3
✨ add stdin/input Effection helpers to demo
cowboyd Jun 26, 2026
7a1aac8
✨ port freedom focus/text-input demo to node
cowboyd Jun 26, 2026
3a149ad
✨ add `pnpm demo <name>` runner with TTY inheritance
cowboyd Jun 26, 2026
0758092
📝 spec: event-driven focus via eval-event extension
cowboyd Jun 26, 2026
087d137
✨ add DispatchEvent eval-event extension
cowboyd Jun 27, 2026
400defc
Revert "✨ add DispatchEvent eval-event extension"
cowboyd Jun 27, 2026
b108c4d
Revert "📝 spec: event-driven focus via eval-event extension"
cowboyd Jun 27, 2026
ffcb997
📝 spec: node owns a scope, eval runs inline (non-serial)
cowboyd Jun 27, 2026
dd8a923
♻️ run eval inline via per-node scope; remove the eval loop
cowboyd Jun 27, 2026
6991b54
📝 spec: synchronous freedom:node API, imperative Root/Node, drop comp…
cowboyd Jun 28, 2026
228ab19
✨ add imperative createRoot + synchronous Node methods (additive)
cowboyd Jun 28, 2026
f39c8d0
✨ synchronous node mutation interception via NodeApi + scope.around
cowboyd Jun 29, 2026
6d806d0
✅ rewrite freedom.test.ts imperatively; buffer createRoot events via …
cowboyd Jun 29, 2026
0b763be
♻️ rewrite focus as synchronous; remove old async API (FreedomApi/use…
cowboyd Jun 29, 2026
e9e63da
📝 spec: reconcile remove() Promise + synchronous focus API
cowboyd Jun 29, 2026
4d8553a
convert demo to sync api
cowboyd Jun 29, 2026
55129a8
ignore agent-shell
cowboyd Jun 29, 2026
7f95abc
✨ createChild positional insert via { before }
cowboyd Jun 29, 2026
3e0dea3
add freedom-react with counter example
cowboyd Jun 29, 2026
10a1f3b
📝 spec: root is a non-participating focus container
cowboyd Jun 29, 2026
857c97b
✨ focus: seed first descendant; root no longer in the ring
cowboyd Jun 30, 2026
6927d00
🔧 demo: seed focus after building the input tree
cowboyd Jun 30, 2026
f3229f3
Don't cycle focus through root
cowboyd Jun 30, 2026
cc4a0c5
make a general input model
cowboyd Jul 2, 2026
e3780a4
refactor form demo to use shared input
cowboyd Jul 2, 2026
d429346
Extract abstract editing api
cowboyd Jul 3, 2026
cdb23b6
Add a readline keymap and use it in the demo
cowboyd Jul 5, 2026
fb75d39
initial pizza demo
cowboyd Jul 6, 2026
8be97e7
add pizza dialog
cowboyd Jul 20, 2026
53963dd
Merge origin/main into focus-stack for ghostwright acceptance tests
rauhryan Jul 25, 2026
a7aa28e
test(demo): scaffold Ghostwright acceptance tests for the pizza demo
rauhryan Jul 25, 2026
b8f7f18
fix(ghostwright): make the artifact build idempotent and clean-clone …
rauhryan Jul 25, 2026
08a9107
test(demo): assert Tab focus traversal between the pizza text fields
rauhryan Jul 25, 2026
8476c90
feat(ghostwright): style/cursor assertions, key parsing, safer predic…
rauhryan Jul 25, 2026
ddd9a0b
spike(demo): locate a tty element through in-band Clay metadata
rauhryan Jul 25, 2026
1247417
spike(demo): query the live pizza modal through Freedom+tty semantics
rauhryan Jul 25, 2026
d4429be
spike(demo): measure Clay layout bounds against visible terminal geom…
rauhryan Jul 25, 2026
d5b4b50
spike(demo): replace handwritten semantic selectors with css-select
rauhryan Jul 25, 2026
ed00f9f
feat(ghostwright): add Freedom tty semantic extension
rauhryan Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ jspm_packages/

# Stores VSCode versions used for testing VSCode extensions
.vscode-test
/.agent-shell/
53 changes: 52 additions & 1 deletion experiments/ghostwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,46 @@ Ghostwright assertions are revision-driven rather than polling-based:
| First visible appearance / readiness | `toBePresent()` |
| Final visually settled state | `toBeStable()` |
| Stable disappearance | `toBeAbsent()` |
| Text is drawn with a given style | `toHaveStyle()` |
| The cursor sits on the match | `toContainCursor()` |
| Compound stable screen condition | `toSatisfy()` |
| Fleeting screen state after an action | `toHaveShown()` |
| Fleeting text after an action | `toHaveShownText()` |

Text locators are lazy, current-visible-viewport only, grapheme-aware, and strict. Zero matches wait; multiple matches fail with candidate geometry. Use `.nth()` or `.region()` to disambiguate deliberately.
Text locators are lazy, current-visible-viewport only, grapheme-aware, and strict. Zero matches wait; multiple matches fail with candidate geometry. Use `.nth()`, `.region()`, or a `style` filter to disambiguate deliberately.

Assertions default to `DEFAULT_ASSERTION_TIMEOUT_MS` (4000 ms), deliberately below the 5000 ms default of Bun, Jest, and Vitest. If they were equal the runner's own timeout would win the race and report a bare "timed out" instead of Ghostwright's screen diagnostic. Raise it per assertion with `{ timeoutMs }`, or for a session with `assertionTimeoutMs`.

## Inspecting styles, cursor, and cells

Focus, selection, and error states in a TUI are usually expressed visually rather than as text. Locators can filter and assert on style:

```ts
// Assert how something is drawn.
await expectTerminal(terminal.getByText('Save')).toHaveStyle({ foreground: '#ffffff' });

// Disambiguate identical text by appearance.
const active = terminal.getByText('Save', { style: { inverse: true } });

// Assert where the caret is.
await expectTerminal(terminal.getByText('Name')).toContainCursor();
```

Colours accept `'#rrggbb'`, `'rgb(r,g,b)'`, `'default'`, `'palette:N'`, or the structured `TerminalColor`. Any omitted `StyleQuery` field is ignored.

For geometry and raw cells, `matches()` returns each hit's `range` and backing `cells`, and `screen.getCells(rect)` returns a rectangle:

```ts
const [match] = terminal.getByText('Name').matches();
match.range; // { column, row, width, height }
match.cells; // ScreenCell[], each with .style

const border = terminal.screen.getCells({ column: 4, row: 7, width: 40, height: 3 });
import { cellsMatchStyle } from 'ghostwright';
cellsMatchStyle(border, { foreground: '#ffffff' });
```

`screen.snapshot()` is an alias of `screen.current()`, matching `AsyncRegion.snapshot()`.

See [Choosing locators and assertions](docs/choosing-assertions.md).

Expand Down Expand Up @@ -136,6 +171,22 @@ Failure tracing defaults to `retain-on-failure`. Common secret-like environment

Generated `dist/`, `artifacts/`, Rust `target/`, and candidate host binaries are Git-ignored and assembled before packaging.

Working on Ghostwright itself (as opposed to consuming it) requires building those artifacts once from a clean clone:

```sh
bun run setup
```

That fetches the pinned Ghostty source, builds `ghostty-vt.wasm` and the native PTY host, compiles terminfo, refreshes checksums, and verifies the result. It needs the exact Zig version recorded in `ghostty.lock.json` (currently 0.15.2) on `PATH`; nothing else is required. The command is idempotent and safe to re-run.

Then run the tests:

```sh
bun test examples
```

`ghostty.lock.json` is the source of truth for the build contract and is edited by hand. `bun run update:manifest` only refreshes the `artifacts` checksum map, and only for targets built on the current machine; entries for targets built elsewhere (for example the Linux hosts when building on macOS) are preserved. `bun run verify:artifacts` skips and reports artifacts that are absent locally, and fails hard on any artifact that is present but does not match.

The PTY host has two side-by-side implementations:

- `native/pty-host-c`: packaged pure-C default, compiled with Apple Clang or native `musl-gcc`
Expand Down
16 changes: 16 additions & 0 deletions experiments/ghostwright/docs/agent-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,22 @@ The vi examples prove Ghostwright is exercising raw input, alternate-screen rest

The second example prints `hello world` in interactive Bash, enters vi's alternate screen, exits vi, and verifies Bash's primary screen still contains the original output.

## Inspect what is on screen

When an assertion is not enough and you need the underlying data, locators expose geometry and cells, and the screen exposes rectangles:

```ts
const [match] = terminal.getByText('Name').matches();
match.range; // { column, row, width, height }
match.cells; // ScreenCell[], each with .style

terminal.screen.snapshot(); // whole screen (alias of screen.current())
terminal.screen.getCells({ column: 0, row: 7, width: 40, height: 3 });
terminal.screen.getText({ column: 0, row: 7, width: 40, height: 3 });
```

Prefer an assertion when one exists: `toHaveStyle()` and `toContainCursor()` wait for convergence, whereas `matches()` and `snapshot()` read the current instant and will not wait.

## Next references

- Choose synchronization correctly: [`choosing-assertions.md`](choosing-assertions.md)
Expand Down
30 changes: 30 additions & 0 deletions experiments/ghostwright/docs/choosing-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ Ghostwright separates first appearance, visual convergence, stable absence, and
| Has this text appeared yet? | `toBePresent()` |
| Has the final visible UI settled? | `toBeStable()` |
| Has this text remained gone? | `toBeAbsent()` |
| Is this text drawn with a given style? | `toHaveStyle()` |
| Is the cursor on this text? | `toContainCursor()` |
| Have several visible conditions converged together? | `toSatisfy()` |
| Did a fleeting screen state occur after an action? | `toHaveShown()` |
| Did fleeting text occur after an action? | `toHaveShownText()` |

All waits evaluate current state and subscribe to revisions. They do not use fixed-interval polling.

The default timeout is `DEFAULT_ASSERTION_TIMEOUT_MS` (4000 ms), chosen to stay below the 5000 ms default of Bun, Jest, and Vitest so that a failure reports Ghostwright's screen diagnostic rather than the runner's bare timeout.

## `toBePresent`: readiness and first appearance

```ts
Expand Down Expand Up @@ -72,8 +76,34 @@ await expectTerminal(terminal).toSatisfy(

The predicate is evaluated against immutable `ScreenSnapshot` values and must remain true through visual settlement.

Predicates run against **every** revision, including the blank frames before the application has painted anything. A predicate that throws is treated as "not satisfied" rather than aborting the assertion, so reading a not yet rendered layout is safe. If the assertion never converges, the most recent thrown error is included in the diagnostic:

```
expected: screen predicate to converge
predicate threw (treated as unsatisfied): Cannot read properties of undefined
```

Prefer multiple locators when the conditions are independently meaningful. Use `toSatisfy` when their atomic relationship is the behavior under test.

## `toHaveStyle` and `toContainCursor`: visual state

TUIs express focus, selection, and severity through styling rather than text. Assert it directly instead of scraping cells:

```ts
await expectTerminal(terminal.getByText('Submit')).toHaveStyle({ inverse: true });
await expectTerminal(terminal.getByText('Error')).toHaveStyle({ foreground: '#ff0000' });
await expectTerminal(terminal.getByText('Name')).toContainCursor();
```

`toHaveStyle` requires every cell of the match to satisfy the query, and reports the actual style on failure. Omitted fields are ignored, so `{ bold: true }` says nothing about colour.

A `style` filter on the locator itself disambiguates repeated text:

```ts
// Two "Save" labels, one highlighted.
terminal.getByText('Save', { style: { inverse: true } });
```

## `toHaveShown`: transient revision history

Terminal applications can paint a state and replace it before the test resumes:
Expand Down
12 changes: 12 additions & 0 deletions experiments/ghostwright/docs/interaction-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ await terminal.keyboard.press({ key: 'Tab', shift: true });
await terminal.keyboard.press({ key: 'x', alt: true });
```

The equivalent string form is also accepted:

```ts
await terminal.keyboard.press('Ctrl+c');
await terminal.keyboard.press('Shift+Tab');
await terminal.keyboard.press('Alt+x');
```

Recognized modifier prefixes are `Shift+`, `Ctrl+`/`Control+`, `Alt+`/`Option+`, and `Cmd+`/`Command+`/`Super+`/`Meta+`, in any case. The key itself keeps its case, since case is significant for characters.

Unknown key names throw `InvalidKeyError` immediately rather than encoding nothing and surfacing later as an assertion timeout. Valid keys are the functional names (`Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Home`, `End`, `PageUp`, `PageDown`, the four arrows), `F1`-`F25`, or any single character. Note that `KeyName` widens to `string`, so TypeScript cannot catch a typo for you.

Keyboard encoding uses current Ghostty terminal modes, including application cursor keys, backarrow mode, and Kitty keyboard flags.

User Control-C is terminal input. In canonical mode with `ISIG`, line discipline normally delivers `SIGINT`; in raw mode the application receives byte `0x03`. Administrative signaling is separate:
Expand Down
2 changes: 2 additions & 0 deletions experiments/ghostwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
}
},
"scripts": {
"setup": "bun run build:artifacts && bun run verify:artifacts",
"build": "rm -rf dist && bun build src/index.ts src/async.ts src/pty/protocol.ts --outdir dist --target node --format esm --packages external --sourcemap=external && bunx tsc -p tsconfig.build.json && bun scripts/fix-declarations.ts",
"fetch:ghostty": "bun scripts/fetch-ghostty.ts",
"build:ghostty-vt": "bun scripts/build-ghostty-vt.ts",
Expand All @@ -43,6 +44,7 @@
"test:host:rust:full": "GHOSTWRIGHT_CONTRACT_HOST=.cache/hosts/pty-host-rust bun test --preload ./test/preload-host.ts .",
"compare:hosts": "bun scripts/compare-hosts.ts",
"build:artifacts": "bun run fetch:ghostty && bun run build:ghostty-vt && bun scripts/build-artifacts.ts",
"update:manifest": "bun scripts/update-manifest.ts",
"verify:artifacts": "bun scripts/verify-artifacts.ts",
"test": "bun test",
"test:examples": "bun test examples"
Expand Down
18 changes: 17 additions & 1 deletion experiments/ghostwright/scripts/build-ghostty-vt.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { $ } from 'bun';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { GhostwrightError } from '../src/errors.ts';

const root = new URL('..', import.meta.url).pathname,
source = `${root}/.cache/ghostty`,
lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, 'utf8')),
lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, 'utf8'));
if (!existsSync(source))
throw new GhostwrightError({
code: 'GW_GHOSTTY_SOURCE',
message: `Ghostty source checkout missing at ${source}; run \`bun run fetch:ghostty\` first (or \`bun run setup\` to do everything)`,
});
let zig: string;
try {
zig = (await $`zig version`.text()).trim();
} catch {
throw new GhostwrightError({
code: 'GW_ZIG_MISSING',
message: `Ghostwright artifact build requires Zig ${lock.zigVersion} on PATH, but \`zig\` was not found`,
});
}
if (zig !== lock.zigVersion)
throw new GhostwrightError({
code: 'GW_ZIG_VERSION',
Expand All @@ -25,4 +39,6 @@ if (lock.graphics?.freestandingPatchSha256 !== patchSha256)
message: 'Ghostwright freestanding Kitty patch checksum mismatch',
});
await $`cd ${source} && zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`;
// artifacts/ is gitignored, so it does not exist in a clean clone.
await $`mkdir -p ${root}/artifacts`;
await $`cp ${source}/zig-out/bin/ghostty-vt.wasm ${root}/artifacts/ghostty-vt.wasm`;
Loading
Loading