Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
79da704
docs(plans): settle Phase 4 decisions 2, 5, and 8, add the colour-rep…
robfeldmann Oct 23, 2026
ea513af
feat(terminal): add the package seams the headless harness drives
robfeldmann Oct 23, 2026
46f0d65
docs(plans): record Phase 4 Slice 2 evidence, seam decisions, and dis…
robfeldmann Oct 23, 2026
0bb02e7
refactor(terminal): draw in the caller's isolation
robfeldmann Oct 24, 2026
7ef4b92
Merge remote-tracking branch 'origin/main' into tmp-merge-58
robfeldmann Oct 24, 2026
0539b57
Merge origin/docs/phase-4-decisions into phase-4/02-seams
robfeldmann Oct 24, 2026
05df29c
Merge origin/main into phase-4/02-seams
robfeldmann Oct 24, 2026
7d9eb46
Merge origin/main into phase-4/02-seams
robfeldmann Oct 24, 2026
08174d2
fix(terminal): reject a draw that commits after its session closed
robfeldmann Oct 24, 2026
38cc7fd
feat(harness): add TesseraRuntime with Application and TesseraHarness
robfeldmann Oct 24, 2026
53b7a25
chore(examples): retire the Showcase
robfeldmann Oct 24, 2026
e5de1ab
docs(plans): record Slice 3, the runtime, harness, and Showcase retir…
robfeldmann Oct 24, 2026
e05d6e9
Merge origin/phase-4/02-seams into phase-4/03-harness-runtime
robfeldmann Oct 24, 2026
2c28bb6
fix(harness): qualify the isolation docs, guard import Tessera, drop …
robfeldmann Oct 24, 2026
096cec9
docs(harness): state the isolation rule by compiler mode, not by module
robfeldmann Oct 24, 2026
338bd6f
feat(harness): add the application registry, the main-actor driver, a…
robfeldmann Oct 24, 2026
b2ee4f1
docs(plans): record Slice 4, the registry, viewports, driver, and tes…
robfeldmann Oct 24, 2026
e707aa0
docs(plans): record the Windows leg of Slice 4's CI
robfeldmann Oct 24, 2026
40fea7d
fix(harness): address the Slice 4 review nits
robfeldmann Oct 24, 2026
ee30fd7
Merge remote-tracking branch 'origin/main' into phase-4/03-harness-ru…
robfeldmann Oct 24, 2026
8eb1466
fix(harness): address review feedback on the runtime and harness
robfeldmann Oct 24, 2026
d37e358
Merge remote-tracking branch 'origin/phase-4/03-harness-runtime' into…
robfeldmann Oct 24, 2026
86a1cb5
refactor(harness): apply review conventions to the driver and registry
robfeldmann Oct 24, 2026
2c463f5
docs(skills): split the devtools skill into a published tessera skill…
robfeldmann Oct 24, 2026
324f760
feat(harness): declare the tessera command line with swift-argument-p…
robfeldmann Oct 24, 2026
4bc3717
feat(dev): explain dev:launch without a name and complete its app names
robfeldmann Oct 24, 2026
04ef8a5
fix(dev): build tessera once per dev task and run the binary
robfeldmann Oct 24, 2026
dd6ac69
fix(harness): keep help with an extra word a usage error and test com…
robfeldmann Oct 24, 2026
ee42d75
fix(dev): exit 2 from a dev task given no argument unless stdout is a…
robfeldmann Oct 24, 2026
b710b88
Merge remote-tracking branch 'origin/main' into phase-4/04-driver
robfeldmann Oct 24, 2026
a3cbacc
chore: drop files the squash merge of #64 re-added
robfeldmann Oct 24, 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
56 changes: 56 additions & 0 deletions .agents/skills/tessera-contributing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
name: tessera-contributing
description: Use Tessera's own demos and developer tools while working in this repository. Use when running `mise run dev:*`, launching or snapshot-testing a demo, adding a demo, or fixing a library defect a demo shows. Read with the `tessera` skill, which it extends.
version: 0.1.0
---

# Contributing to Tessera with the developer tools

Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tessera` commands, the exit codes, and the pty recipe. This skill adds what only a Tessera contributor has: the `mise` tasks, the demos, and the demo snapshot tests. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design behind the tools.

## Commands

| mise | tessera | Does |
| --- | --- | --- |
| `mise run dev:apps` | `tessera apps` | List the registered demos: name, minimum size, viewports. |
| `mise run dev:apps --json` or `--pretty` | `tessera apps --json` or `--pretty` | Force the JSON object or the text table. |
| `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. |
| `mise run dev:launch` | `tessera help launch`, then `tessera apps` | With no name, print the command's help and the demos. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran and it exits 2, as `tessera` does. |

`mise run dev:*` builds the `tessera` executable once, quietly (`swift build --quiet --package-path Examples --product tessera`), and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors; the binary keeps the task's terminal. It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos.

With mise's shell completions enabled, `mise run dev:launch <Tab>` offers the demo names. Completion never builds: `scripts/dev-app-names.sh` asks the `Examples/.build/debug/tessera` that an earlier `dev:*` run built, and offers nothing before there is one. Run `mise run dev:apps` once after a fresh checkout, or after registering a demo. To complete `tessera` itself in your shell, see the `tessera` skill.

## Run a demo

`mise run dev:launch counter` opens the counter in your terminal. Each demo quits on `q`, matched by `isQuitKey` in `Examples/Sources/DemoSupport`. An agent without a terminal uses the pty recipe from the `tessera` skill with `mise run dev:launch counter` in place of `swift run tessera launch counter`; a headless snapshot test is the cheaper way to read a screen.

## Snapshot-test a demo

A demo's snapshot test is a few lines in `Examples/Tests/DemoSupportTests`, run by `swift test --package-path Examples`:

```swift
@Test @MainActor
func `the counter counts`() async throws {
try await Demos.registry().withHeadlessHost("counter", at: .mobile) { host in
try await host.send(.key(Key(code: .character("+"))))
#expect(host.screenRows[2].hasSuffix("1"))
}
}
```

`withHeadlessHost` opens a terminal session over a headless device, steps the demo through the same driver as `launch`, and feeds every frame to one Ghostty virtual terminal, so `host.screenRows` is the screen a terminal would show. `@testable import TesseraHarness` reaches it; it is not API for app authors. The viewport names are the ones the `tessera` skill lists.

## Add a demo

1. Write a `final class` conforming to `Demo` in `Examples/Sources/DemoSupport`: a kebab-case `name`, a `minimum` size, `viewports`, and `fixtures` (empty when there are none). None has a default.
2. Handle the quit key in `apply(_:)` with `isQuitKey`, and report it through `isFinished`.
3. Register it with one line in `Demos.registry()`.
4. Add its snapshot test beside the counter's.

A demo is interactive and shows every style or variant that can differ. It uses only what the library provides.

## Rules

- When a demo shows a library defect, fix the library and add a regression test. Never bend the demo to hide it.
- A name registered twice fails every command that reads the registry. Never rely on the later registration winning.
124 changes: 124 additions & 0 deletions .agents/skills/tessera/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
name: tessera
description: Drive and check an app built on Tessera with the `tessera` command line. Use when writing a Tessera `Application`, registering it in an `ApplicationRegistry`, wiring the executable that calls `HarnessCLI.main(registry:)`, listing or launching the app with `tessera apps` and `tessera launch`, or reading what the harness reports about it.
version: 0.1.0
---

# Tessera for app authors

`TesseraHarness` runs a Tessera app live in a terminal and headlessly through one driver, so what a check renders is what a terminal shows. The `tessera` command line is how you and an agent reach it: you register your apps, wire one executable, and run commands against it. Use only the commands listed here.

## Wire the executable

1. Conform your app to `Application` where the type is declared: a `final class` with a `body`, an `apply(_:)` that receives each input event the views left unhandled (and every resize), and an `isFinished` that tells the harness to stop. The harness has no quit key of its own; the app decides which key quits.
2. Register it in an `ApplicationRegistry` under a kebab-case name, with its minimum size and the viewports it is checked at. A viewport is `.desktop` (80x24), `.mobile` (40 columns at the height the app's content needs), `.mobileScreen` (40x16), or `.custom(TerminalSize)`. There is no default viewport list. An app whose content grows to any height it is offered has no natural height, so `.mobile` fails and names `.mobileScreen`.
3. Make an executable whose body is `HarnessCLI.main(registry:)`, and name its product `tessera` so every command below runs as `swift run tessera COMMAND`.

```swift
// Package.swift
let package = Package(
name: "MyApplication",
platforms: [.macOS(.v26)],
products: [.executable(name: "tessera", targets: ["MyAppDev"])],
dependencies: [
.package(url: "https://github.com/robfeldmann/tessera.git", branch: "main")
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "Tessera", package: "tessera")]),
.executableTarget(
name: "MyAppDev",
dependencies: ["MyApp", .product(name: "TesseraHarness", package: "tessera")]
),
]
)
```

```swift
// Sources/MyApp/Counter.swift
import Tessera

public final class Counter: Application {
public private(set) var count = 0
public private(set) var isFinished = false

public init() {}

public var body: some View {
VStack {
Text("Count: \(count)")
Text("+ up q quit")
}
}

public func apply(_ event: InputEvent) {
guard case .key(let key) = event, key.kind == .press else { return }
switch key.code {
case .character("+"): count += 1
case .character("q"): isFinished = true
default: break
}
}
}
```

```swift
// Sources/MyAppDev/main.swift
import MyApp
import Tessera
import TesseraHarness

var registry = ApplicationRegistry()
registry.register(
"counter",
minimum: TerminalSize(columns: 20, rows: 3),
viewports: [.desktop, .mobile]
) { Counter() }
await HarnessCLI.main(registry: registry)
```

The factory closure runs on the main actor once per run, so every run starts from a fresh app. A further registration of the same app in another starting state is one more `register` call under another name.

## Commands

| tessera | Does |
| --- | --- |
| `tessera apps` | List the registered apps: name, minimum size, viewports. |
| `tessera apps --json` or `--pretty` | Force the JSON object or the text table. |
| `tessera launch NAME` | Run one app in this terminal until the app quits. |
| `tessera help [COMMAND]` | Print the commands, or one command's usage. `tessera COMMAND --help` prints the same. |
| `tessera --generate-completion-script SHELL` | Print a completion script for `fish`, `zsh`, or `bash`. |

Standard output is one JSON object when it is not a terminal, and a text table when it is. `apps` prints `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, sorted by name, where `minimum` is `null` for an app that declares none. A failed command prints `{"command", "exitCode", "message"}` instead.

Exit codes: 0 success, 2 a usage error (an unknown command, app name, or flag, a missing or extra argument, or `--json` with `--pretty`; the message lists the accepted commands or app names, or the command's usage), 3 a harness error (a name registered twice, or `launch` without a terminal). `--json` and `--pretty` go after the command or before it.

## Complete commands and app names

The executable prints a completion script that completes the commands, their flags, and the registered app names, so `tessera launch <Tab>` offers your apps. Install it once per shell, with `tessera` on your `PATH` (the script runs it to ask for the names):

```sh
# fish
tessera --generate-completion-script fish > ~/.config/fish/completions/tessera.fish
# zsh: a directory on $fpath, then restart the shell
tessera --generate-completion-script zsh > ~/.zfunc/_tessera
# bash: with bash-completion's per-command directory
tessera --generate-completion-script bash > ~/.local/share/bash-completion/completions/tessera
```

The script names the executable as it was invoked, so a product that is not called `tessera` generates a script for its own name. The names come from the executable you built, so rebuild after registering an app.

## Run the app

`swift run tessera launch counter` opens the app in your terminal. It needs a real terminal on standard input and output. An agent without one runs it under a pty, after building so compiler output stays out of the capture:

```sh
swift build --product tessera
expect -c 'set timeout 20; spawn sh -c "stty rows 24 cols 80; exec swift run tessera launch counter"; expect "Count"; send "+"; send "q"; expect eof'
```

Read the screen from the bytes the run wrote: cursor addressing, then text.

## Rules

- When Tessera itself misbehaves (a crash, a wrong render, an input it drops), report a Tessera bug with the command, its output, and the smallest app that shows it. Do not work around it in the app.
- A name registered twice fails every command that reads the registry. Never rely on the later registration winning.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ jobs:
if: needs.ci-gate.outputs.vouch-only != 'true'
run: swift test --only-use-versions-from-resolved-file --no-parallel

- name: Test Examples
if: needs.ci-gate.outputs.vouch-only != 'true'
run:
swift test --only-use-versions-from-resolved-file --no-parallel --package-path
Examples

- name: Check the resolved files are unchanged
if: needs.ci-gate.outputs.vouch-only != 'true'
run: git diff --exit-code -- Package.resolved Examples/Package.resolved
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- Ratatui crossterm backend: `~/Developer/ratatui/ratatui/main/ratatui-crossterm/`
- Crossterm sources: `~/.local/share/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/crossterm-0.29.0/`
- Project memory is the IWE workspace in `docs/` (`docs/index.md` maps it): `plans/` (one plan per piece of work; `docs/plans.md` is rendered from their frontmatter by `scripts/plans.py`), `research/` (research records), `architecture/` (durable decisions), `design/` (the component catalog), `spec/` (the spec, one page per section, hub `docs/spec.md`), and `guides/` (contributor how-tos). Search it at the start of a task and whenever unsure, from `docs/`: `iwe find --fuzzy <terms> --lexical <terms>`, then `iwe retrieve -k <key>` (`iwe` comes from `mise install`; run it as `mise exec -- iwe` when mise is not activated, and without it fall back to `docs/index.md`, the hub pages, and `grep` over `docs/`, which hold the same content as plain Markdown). Record what you learn where it belongs as part of the change (a plan's Progress, Decisions, or Discoveries; a research record; an architecture page), and fix stale entries you find.
- Plans and research records follow the `planning` and `investigations` skills in `.agents/skills/`; the design catalog follows `design-catalog`. Slash commands for the recurring workflows live in `.agents/commands/`.
- Plans and research records follow the `planning` and `investigations` skills in `.agents/skills/`; the design catalog follows `design-catalog`; driving and checking a Tessera app with the `tessera` command line follows the `tessera` skill, and running, launching, and snapshot-testing Tessera's own demos follows `tessera-contributing`, which extends it. Slash commands for the recurring workflows live in `.agents/commands/`.
- Markdown prose is one paragraph per line (no hard wrapping); relative `.md` links; no raw HTML. Swift code keeps the formatter's 90-column width.
- Swift Testing tests should use backticked, sentence-style function names.
- Prefer snapshots for structured state humans inspect as a whole; prefer direct assertions for small scalar/API-shape behavior.
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Added runtime protocol controls and capability reconciliation with bounded active probes, serialized failure-safe mode transactions, live policy changes, emergency cleanup storage, repaint behavior, and a runtime-control demo.
- Added `package`-level seams the headless harness drives, with no new public API: `InMemoryTerminalDevice` can stay open and deliver `.resize` mid-session (`init(…keepsInputOpen:)`, `deliverResize(to:)`, `closeInput()`); `TerminalSession.withApplicationTerminal(configuration:io:…)` forwards `probeImageID` and `probeSleep`; `TerminalSession` exposes `lastDrawn`, `size`, and `outputTap`; and `ViewGraph` reads an injected clock.
- Added the `TesseraRuntime` module with the `Application` protocol, re-exported by `Tessera`: a reference type that owns its model, whose `body` is the root view, whose `apply(_:)` receives each event the view graph left unhandled and every resize, and whose `isFinished` (default `false`) tells a host to stop. It is class-only, inheriting from `AnyObject` and no other protocol, so plain classes, `@Observable` models, default-main-actor types, and `@MainActor` models (through `: @MainActor Application`) all adopt it where the type is declared.
- Added the `TesseraHarness` library product, the first home of the developer tooling: the scenario parser, runner, and report that the HeadlessRunner spike proved, ported with its scenario corpus and tests as `package` code. It declares no public API.
- Added the `TesseraHarness` library product, the first home of the developer tooling: the scenario parser, runner, and report that the HeadlessRunner spike proved, ported with its scenario corpus and tests as `package` code.
- Added the application registry, the viewports, and the main-actor driver to `TesseraHarness`, and the `tessera` command line over them. `ApplicationRegistry` selects an app by name through a main-actor factory that the harness opens once per run; registering a name twice makes every read of the registry fail rather than letting the later registration win. `Viewport` names `desktop` (80x24), `mobile` (40 columns at the app's natural height), `mobileScreen` (40x16), and `custom`; an app whose root grows with any height it is offered has no natural height, so running it at `mobile` fails and names `mobileScreen`. A `package` `Step.run(_:graph:event:isolation:)` in `TesseraRuntime` is the one loop body every host runs, and the `package` `Driver` is its main-actor shell, shared by a headless host (a terminal session over a headless device into a Ghostty virtual terminal) and a live host (a session over the real terminal), so a screen a test reads is the screen a terminal shows. `HarnessCLI.main(registry:)` runs `apps`, `launch NAME`, and `help [COMMAND]`.
- Added the `tessera` executable (`Examples/Sources/TesseraDev`) with `mise run dev:apps` and `mise run dev:launch NAME`, and the `DemoSupport` library with its first demo, a counter that quits on `q`; its snapshot tests step it through the same driver as `tessera launch`.
- Added the `tessera` agent skill for app authors (writing an `Application`, registering it, wiring the executable over `HarnessCLI.main(registry:)`, and `tessera apps` and `tessera launch`) and the `tessera-contributing` skill for contributors (the `mise run dev:*` tasks, the demos, and their snapshot tests), with a pointer from `AGENTS.md`.
- Added swift-argument-parser (`from: "1.8.2"`) as a dependency of `TesseraHarness` alone, and declared the `tessera` commands and flags with it. Exit codes (a usage error exits 2, not ArgumentParser's 64), the JSON object and the text form, and the command and flag names are unchanged; help and parse-error wording are ArgumentParser's, and a usage error still lists the accepted commands, app names, or the command's usage. The executable prints a completion script for fish, zsh, and bash (`--generate-completion-script SHELL`) that completes the registered app names for `launch`. `mise run dev:launch` with no name prints the command's help and the registered apps instead of mise's missing-argument error (exit 0 at a terminal, 2 in a pipe or a script), and `mise run dev:launch <Tab>` completes the app names from the built `tessera` without building. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built.
- Added two compile-fail fixtures that pin the isolation rules of `Application`, each with a positive control, to `mise run quality:architecture`: an actor host given an app with a main-actor isolated conformance, and a default-main-actor type adopted from another module without `@MainActor` on the conformance.
- Added Windows Ghostty snapshot support with a static library, reproducible Zig build and cache tooling, Frost VM recipes, console mode setup and restoration, live terminal device I/O, focused cross-platform test recipes, and hosted CI coverage.
- Added public issue, Discussion, and pull-request forms, private security and conduct reporting, solo-maintainer ownership metadata, and a fork-safe reviewed contributor vouch gate with tested trust parsing.
- Added read-only DocC pull-request validation plus main-branch and manual GitHub Pages deployment for the combined static documentation site.
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ mise run core:build # build the package
mise run core:test # full Swift test suite (non-parallel, as CI runs it)
mise run core:test-parallel # the same suite in parallel; must stay green
mise run core:example # run an example from the Examples package
mise run dev:apps # list the demos the tessera CLI can launch
mise run dev:launch counter # run one demo in this terminal (q quits)
```

Run the narrowest relevant check first, then widen:
Expand Down
11 changes: 10 additions & 1 deletion Examples/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading