Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
108 changes: 108 additions & 0 deletions experiments/ghostwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,114 @@ const matches = await terminal.history.findText('tool completed', { direction: '
- [C versus Rust PTY-host comparison](HOST-COMPARISON.md)
- [Performance report](PERFORMANCE.md)

## Composable execution

Keyboard input runs through an operation-based driver and typed middleware.
The first middleware is outermost; capture-local middleware runs inside inherited
middleware. Existing keyboard methods use this path in both async and Effection APIs.

```ts
import {
launchTerminal,
captureProgram,
regionLocator,
textContains,
type KeyboardMiddleware,
type TerminalProgram,
} from 'ghostwright';

const logKeys: KeyboardMiddleware = {
*press(args, next) {
const receipt = yield* next(...args);
console.log('acknowledged', receipt.actionSequence);
return receipt;
},
};

const submit: TerminalProgram<void> = function* ({ keyboard }) {
yield* keyboard.type('hello');
yield* keyboard.press('Enter');
};

await using terminal = await launchTerminal({
command: 'my-cli',
keyboardMiddleware: [logKeys],
});
await terminal.screen.findByText('Ready');

const viewport = regionLocator({ column: 0, row: 0, width: 80, height: 24 });
const recording = await terminal.run(
captureProgram(
{
until: viewport.satisfies(textContains('Complete')),
},
submit,
),
);
```

The same program runs as `yield* terminal.run(program)` on the Effection terminal.
Programs receive a capability facade with keyboard operations and nested capture.
They return `Operation<T>`; the async runner returns `Promise<T>`. `captureProgram`
returns the recording and waits for both the condition and the continuation.

Use `keyboardMiddleware` in capture options to add behavior only within that
capture and its children. Register asynchronous cleanup with Effection `ensure`;
cancellation awaits that release and cancels pending input without closing the
parent session. Effection 4.2 can interrupt a `finally` block that is already
yielding when cancellation arrives, so reserve ordinary `finally` for synchronous
cleanup. Middleware is trusted: calling `next` twice sends two actions. Ghostwright
does not retry keyboard actions automatically.

`createKeyboardOperations({ driver, middleware })` provides the same typed
composition for an explicit driver. `composeOperation` is the underlying function
combinator. This initial operation core covers keyboard input and capture; other
terminal APIs continue using their existing adapters.

## Terminal backend

Ghostty remains the default and sole shipped emulator. A session can supply a
`backend: CreateBackend` factory to substitute or decorate it:

```ts
import { createGhosttyBackend, launchTerminal, type CreateBackend } from 'ghostwright';

const backend: CreateBackend = function* (options) {
const ghostty = yield* createGhosttyBackend(options);
return {
...ghostty,
write(bytes) {
const update = ghostty.write(bytes);
return update; // detached snapshot and terminal effects
},
};
};

await using terminal = await launchTerminal({ command: 'my-cli', backend });
```

The factory returns an Effection `Operation<TerminalBackend>` and acquires fresh
state for each session. Use Effection resources to register release with the
factory's scope. The session keeps that scope alive through process/output drain
and awaits its cleanup on close or failed launch. Factories invoked through the
Effection API inherit the caller's context. Async and local component sessions
own their own scopes; pass their configuration explicitly in the factory closure.

Backend reads, writes, resize and input encoders are synchronous. A write or
resize returns a `BackendUpdate` containing the resulting immutable snapshot and
an ordered list of effects. The session queues PTY responses; the emulator never
performs transport I/O or reenters application output. The existing semantic OSC
splitter, CSS locator adapters, revision retention and assertions remain shared.
History and graphics inspection are part of the contract; unsupported graphics
must be reported explicitly in the snapshot.

The same factory option works with trace replay and the experimental local
component harness. Traces record `backend.identity` (name and behavior version).
Replay requires a matching identity and an explicit factory for custom backends;
it never loads code named in a recording. A decorator that changes emulator or
input behavior must use a distinct identity. The terminal environment still uses
Ghostwright's deterministic profile; this seam does not add alternate profiles.

## Compatibility

- Node 22+
Expand Down
98 changes: 98 additions & 0 deletions experiments/ghostwright/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ Ghostty is the sole authority for:

JavaScript does not interpret terminal control sequences. It extracts only registered description OSC messages before forwarding ordinary bytes to Ghostty.

## Backend boundary

`CreateBackend` is `(options: BackendOptions) => Operation<TerminalBackend>`.
The default `createGhosttyBackend` acquires the existing Ghostty WASM engine as
an Effection resource and releases it with its scope, including late acquisition
and initialization failure. Its returned functions close over that instance and
reject use after release. The async session bridge owns a child scope, inheriting
the Effection caller when present, and explicitly closes it after transport drain.
The factory is explicit per session; there is no global backend registry.

`write` and `resize` return one `BackendUpdate`: a detached screen snapshot and
terminal effects. These operations, input encoding, and reads cannot suspend.
That keeps each ordinary byte segment and its resulting screen atomic relative
to registered OSC boundaries. Effects are values; session-owned queues deliver
PTY responses without blocking parsing. Session clocks and revision numbering
remain above the emulator boundary.

Live PTY sessions, local appIO sessions and replay consume the same contract.
The backend owns VT semantics; the shared output pipeline owns metadata pairing
and freshness. Replay consumes snapshots but does not deliver terminal effects.
Trace identity checks reject a different emulator/version before processing
recorded output. Existing Ghostty traces retain their artifact hash check.

## Session resource tree

```text
Expand Down Expand Up @@ -185,3 +208,78 @@ Explicit overrides of profile-owned environment keys are rejected. Other explici
Maintainers use pinned Zig for upstream Ghostty WASM and Cargo plus the target linker for the Rust host.

Artifact metadata pins source commit, toolchains, build flags, protocol/binding versions, ABI layouts, and checksums. Independent verification checks files without rebuilding them.

## Operation composition

Keyboard execution has an explicit capability boundary in `src/operations.ts`.
`KeyboardOperations` is the port contract. `createKeyboardOperations` accepts a
driver and an ordered list of typed middleware; callers receive the composed
facade. Each middleware receives the original argument tuple and a `next`
continuation with the same result type. The first entry is outermost.

The session adapter implements this contract using the existing PTY action
machinery. Encoding, command serialization, action receipts, and shutdown guards
remain in the session. Middleware can surround an operation's execution and
cleanup, but the raw driver is not exposed by the resulting execution capability.
Middleware is trusted application code: forwarding twice performs two actions;
there is no implicit retry or exactly-once enforcement.

`TerminalProgram<T>` is a function from `ExecutionOperations` to `Operation<T>`.
`AsyncExecution.run` executes a program in its owned scope; the Effection
facade's `run` invokes the same program in the caller's current scope. Both use
the same composed keyboard operations. Native keyboard calls no longer travel
through the Promise facade, so middleware can resolve the caller's Effection
context. The driver combines current-operation cancellation with owner
cancellation, including interruption of blocked PTY writes.

`captureProgram(options, body)` wraps a continuation with the existing capture
kernel. Recording subscribes before the body starts; completion still requires
both the condition and the body, and the deadline bounds both. Capture-local
keyboard middleware is appended inside inherited middleware and passed to nested
captures. It does not modify the parent. Escaped child capabilities reject calls
after the capture closes, and cleanup registered with `ensure` completes before cancellation
returns. The existing callback capture APIs use the same inheritance rules.

This first boundary covers keyboard operations and capture programs. Mouse,
process, query, and graphics APIs retain their existing adapters. Driver injection
is explicit; no scope-global port registry or experimental Effection API is needed.

## Scope ownership and cancellation

Native `withTerminal` creates an explicit `scoped` boundary. Delegating to a
resource with `yield*` alone shares the caller's scope; it does not delimit the
session lifetime. On success, failure, or halt, the session now drains and closes
before control returns to the outer operation. `useSession` remains a lower-level
resource whose lifetime intentionally belongs to the acquiring scope.

| Boundary | Context and lifetime |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native keyboard operation / program | Child of the invoking scope; inherits current context and also observes terminal-owner cancellation. |
| Native Promise facade (queries, mouse, process, history, graphics) | Creates an invocation-local executor, so its async tasks belong to the caller's subtree instead of being siblings under the terminal owner. |
| Async terminal methods | Explicitly execute in the terminal/capture owner scope. A foreign Effection caller's context does not cross a Promise call automatically. |
| Capture | Owns observation subscriptions, timers, callback tasks, and child capabilities. Local middleware is an explicit inherited list, not a dynamic Effection API registry. |
| Backend scope | Explicit async bridge with an optional native parent for inherited context. The session keeps it alive through PTY output drain and explicitly awaits its release. |

`useAbortSignal` is itself a resource. Its abortion follows cleanup registration
order, so cleanup must not assume that every signal is already aborted when it
starts. Native Promise bridges pass an invocation signal to actual cancellable
work. Cancelling only the Effection `call` would abandon the Promise while its
subscriptions, timers, or input remained active. Legacy assertions, locator
clicks, revision collection, and process-exit timeouts release their waits on
abort as well. Synchronous snapshots remain evidence values and do not acquire
new scopes.

Middleware functions and `composeOperation` do not capture a `Scope`. Explicit
owner binding exists only at the async facade; native invocation preserves the
caller's context. Concurrent callers can shadow context values without affecting
each other, and operation resources release before the action returns.

For asynchronous middleware release, use `yield* ensure(function* () { ... })`.
Effection 4.2 has characterized limitations around yielding `finally`: a halt
arriving while a finally block is already yielding can skip its remaining work,
and a yielding delegated finally can resume its caller after halt. Ghostwright
does not claim to repair arbitrary user generators. Its framework-owned resource
finalizers enter cleanup when their suspended resource is released; synchronous
capture unsubscribe/stop paths do not yield. The ownership tests gate `ensure`
cleanup and verify that repeated disposal and shutdown during an already-running
release wait for completion.
51 changes: 29 additions & 22 deletions experiments/ghostwright/spikes/in-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { parseKey } from '../src/keys.ts';
import { normalizeViewport } from '../src/profile.ts';
import { createQueries } from '../src/queries.ts';
import { TerminalOutput } from '../src/terminal/output.ts';
import { GhosttyWasmTerminal } from '../src/terminal/wasm.ts';
import type { TerminalBackend, BackendUpdate } from '../src/backend.ts';
import { openBackend, type BackendScope } from '../src/terminal/backend-scope.ts';
import { waitForOperation, type WaitForOptions } from '../src/wait-for.ts';
import type {
KeyName,
Expand All @@ -20,20 +21,23 @@ import type {
/** Experimental connection: no command, child process, native host, or global stream replacement. */
export async function createTerminal(options: InProcessOptions = {}): Promise<InProcessTerminal> {
const viewport = normalizeViewport(options.viewport);
const engine = await GhosttyWasmTerminal.create(viewport);
const owner = await openBackend(
{ viewport, graphicsStorageLimitBytes: options.graphics?.storageLimitBytes },
{ create: options.backend },
);
try {
const terminal = new InProcessTerminal(engine, options);
const terminal = new InProcessTerminal(owner, options);
await terminal.installExtensions();
return terminal;
} catch (error) {
engine.free();
await owner[Symbol.asyncDispose]();
throw error;
}
}

type InProcessOptions = Pick<
TerminalLaunchOptions,
'viewport' | 'extensions' | 'selector' | 'assertionTimeoutMs'
'viewport' | 'extensions' | 'selector' | 'assertionTimeoutMs' | 'backend' | 'graphics'
>;

/** Buffer early input until the app installs its reader, even if it calls resume first. */
Expand Down Expand Up @@ -71,7 +75,8 @@ class InProcessTerminal implements AsyncDisposable {
readonly appIO: { input: Input; output: Output };
readonly screen;
readonly keyboard;
readonly #engine: GhosttyWasmTerminal;
readonly #engine: TerminalBackend;
readonly #backendScope: BackendScope;
readonly #extensions: ExtensionLifecycle;
readonly #output: TerminalOutput;
readonly #controller = new AbortController();
Expand All @@ -83,30 +88,24 @@ class InProcessTerminal implements AsyncDisposable {
#failure: unknown;
#closing?: Promise<void>;

constructor(engine: GhosttyWasmTerminal, options: InProcessOptions) {
this.#engine = engine;
constructor(owner: BackendScope, options: InProcessOptions) {
this.#backendScope = owner;
const engine = (this.#engine = owner.backend);
this.#options = options;
this.#snapshot = engine.snapshot();
this.#extensions = new ExtensionLifecycle(() => this.#snapshot);
this.#output = new TerminalOutput(
options.extensions ?? [],
() => this.#snapshot,
(bytes) => {
engine.write(bytes);
return this.#observe();
return this.#observe(engine.write(bytes));
},
);
this.stdout = new Output({
write: (chunk: Buffer, _encoding, done) => {
try {
this.#ensureOpen();
this.#output.push(chunk);
for (const effect of engine.takeEffects()) {
if (effect.type === 'write-pty') {
// Never reenter app rendering from a Ghostty output callback.
void this.#send(effect.data).catch((error) => this.#fail(error));
}
}
done();
} catch (error) {
done(error instanceof Error ? error : new Error(String(error)));
Expand Down Expand Up @@ -173,8 +172,14 @@ class InProcessTerminal implements AsyncDisposable {
}
}

#observe(): ScreenSnapshot {
this.#snapshot = Object.freeze({ ...this.#engine.snapshot(), sequence: ++this.#revision });
#observe(update: BackendUpdate): ScreenSnapshot {
this.#snapshot = Object.freeze({ ...update.snapshot, sequence: ++this.#revision });
for (const effect of update.effects) {
if (effect.type === 'write-pty') {
// Queue responses without reentering application rendering.
void this.#send(effect.data).catch((error) => this.#fail(error));
}
}
this.#extensions.frame(this.#snapshot);
return this.#snapshot;
}
Expand Down Expand Up @@ -229,8 +234,7 @@ class InProcessTerminal implements AsyncDisposable {
this.#ensureOpen();
const size = normalizeViewport(viewport);
this.#extensions.action({ kind: 'resize', ...size });
this.#engine.resize(size);
this.#output.observations.screen(this.#observe());
this.#output.observations.screen(this.#observe(this.#engine.resize(size)));
Object.assign(this.stdout, size);
this.stdout.emit('resize');
}
Expand Down Expand Up @@ -262,8 +266,11 @@ class InProcessTerminal implements AsyncDisposable {
clearTimeout(deadline);
this.stdin.destroy();
this.stdout.destroy();
this.#engine.free();
await this.#extensions.close();
try {
await this.#backendScope[Symbol.asyncDispose]();
} finally {
await this.#extensions.close();
}
}
}
}
Loading
Loading