From f18f785515ae562a87c36d6bee4b840ab166270e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 22 Sep 2026 04:07:57 -0400 Subject: [PATCH 01/57] =?UTF-8?q?=F0=9F=A7=AA=20Render=20the=20REPL=20inte?= =?UTF-8?q?raction=20study=20in=20a=20real=20terminal=20(#838)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One documented command opens the Product Owner's approved `XMD REPL Terminal Interface` study in a terminal, rendered from semantic fixtures through `@bomb.sh/tty` 0.9.0: deno task repl:study Six fixtures carry the states #838 names — empty, nested execution with lifecycle rails and collapsed work, generated XMD replacing the expression that produced it, an Elicit drawer with three sessions in flight, a paused head with a historical selection, and a settled entry. Four layout profiles are chosen from the measured terminal size: the study's wide composition, its floor at medium, routed full-screen surfaces at narrow, and an explicit recoverable refusal below 72 × 20. The study composes one screen at 2560 × 1440 and describes no smaller one, so the constrained-terminal policy is this experiment's own design work, as #827 asks. `scripts/repl-study/RESULT.md` records what the renderer gave us, five observed limitations, and the three design states that needed adapting. The committed `.txt` captures under `scripts/tests/fixtures/repl-study/` are both the captures #838 asks for and the goldens the suite checks, so a rendering change arrives in review as the picture it changed. Eight named controls break the harness on purpose — stale frames, an uncoalesced scrubber, a drawer over the footer, a composition below the minimum, an unannounced resize, a lost transcript window, leaked terminal modes and flattened notches — and the same oracles that admit the honest render reject each one. Nothing under `packages/` changes: the harness is Deno-only tooling in `scripts/`, with one exact root dev pin on `@bomb.sh/tty`. --- .gitignore | 4 + deno.json | 1 + deno.lock | 5 + package.json | 1 + pnpm-lock.yaml | 9 + scripts/repl-study/README.md | 82 ++ scripts/repl-study/RESULT.md | 104 ++ scripts/repl-study/capture.ts | 163 +++ scripts/repl-study/fixtures.ts | 707 +++++++++++ scripts/repl-study/host.ts | 415 +++++++ scripts/repl-study/layout.ts | 212 ++++ scripts/repl-study/main.ts | 170 +++ scripts/repl-study/model.ts | 193 +++ scripts/repl-study/mutations.ts | 33 + scripts/repl-study/render.ts | 1036 +++++++++++++++++ scripts/repl-study/screen.ts | 138 +++ scripts/repl-study/view.ts | 78 ++ scripts/runtime-test-exclusions.ts | 6 + .../fixtures/repl-study/drawer.medium.txt | 39 + .../repl-study/drawer.narrow.sessions.txt | 29 + .../fixtures/repl-study/drawer.narrow.txt | 29 + .../fixtures/repl-study/drawer.too-small.txt | 19 + .../tests/fixtures/repl-study/drawer.wide.txt | 51 + .../fixtures/repl-study/empty.medium.txt | 39 + .../fixtures/repl-study/empty.narrow.txt | 29 + .../tests/fixtures/repl-study/empty.wide.txt | 51 + .../fixtures/repl-study/generated.medium.txt | 39 + .../fixtures/repl-study/generated.narrow.txt | 29 + .../fixtures/repl-study/generated.wide.txt | 51 + .../fixtures/repl-study/nested.medium.txt | 39 + .../repl-study/nested.narrow.bindings.txt | 29 + .../fixtures/repl-study/nested.narrow.txt | 29 + .../tests/fixtures/repl-study/nested.wide.txt | 51 + .../fixtures/repl-study/paused.medium.txt | 39 + .../repl-study/paused.narrow.history.txt | 29 + .../fixtures/repl-study/paused.narrow.txt | 29 + .../fixtures/repl-study/paused.too-small.txt | 19 + .../tests/fixtures/repl-study/paused.wide.txt | 51 + .../fixtures/repl-study/settled.medium.txt | 39 + .../fixtures/repl-study/settled.narrow.txt | 29 + .../fixtures/repl-study/settled.wide.txt | 51 + scripts/tests/repl-study.test.ts | 619 ++++++++++ 42 files changed, 4815 insertions(+) create mode 100644 scripts/repl-study/README.md create mode 100644 scripts/repl-study/RESULT.md create mode 100644 scripts/repl-study/capture.ts create mode 100644 scripts/repl-study/fixtures.ts create mode 100644 scripts/repl-study/host.ts create mode 100644 scripts/repl-study/layout.ts create mode 100644 scripts/repl-study/main.ts create mode 100644 scripts/repl-study/model.ts create mode 100644 scripts/repl-study/mutations.ts create mode 100644 scripts/repl-study/render.ts create mode 100644 scripts/repl-study/screen.ts create mode 100644 scripts/repl-study/view.ts create mode 100644 scripts/tests/fixtures/repl-study/drawer.medium.txt create mode 100644 scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt create mode 100644 scripts/tests/fixtures/repl-study/drawer.narrow.txt create mode 100644 scripts/tests/fixtures/repl-study/drawer.too-small.txt create mode 100644 scripts/tests/fixtures/repl-study/drawer.wide.txt create mode 100644 scripts/tests/fixtures/repl-study/empty.medium.txt create mode 100644 scripts/tests/fixtures/repl-study/empty.narrow.txt create mode 100644 scripts/tests/fixtures/repl-study/empty.wide.txt create mode 100644 scripts/tests/fixtures/repl-study/generated.medium.txt create mode 100644 scripts/tests/fixtures/repl-study/generated.narrow.txt create mode 100644 scripts/tests/fixtures/repl-study/generated.wide.txt create mode 100644 scripts/tests/fixtures/repl-study/nested.medium.txt create mode 100644 scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt create mode 100644 scripts/tests/fixtures/repl-study/nested.narrow.txt create mode 100644 scripts/tests/fixtures/repl-study/nested.wide.txt create mode 100644 scripts/tests/fixtures/repl-study/paused.medium.txt create mode 100644 scripts/tests/fixtures/repl-study/paused.narrow.history.txt create mode 100644 scripts/tests/fixtures/repl-study/paused.narrow.txt create mode 100644 scripts/tests/fixtures/repl-study/paused.too-small.txt create mode 100644 scripts/tests/fixtures/repl-study/paused.wide.txt create mode 100644 scripts/tests/fixtures/repl-study/settled.medium.txt create mode 100644 scripts/tests/fixtures/repl-study/settled.narrow.txt create mode 100644 scripts/tests/fixtures/repl-study/settled.wide.txt create mode 100644 scripts/tests/repl-study.test.ts diff --git a/.gitignore b/.gitignore index 9ae85558d..a02ca7bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ packages/web/generated/ # it. The concurrent verifier runs site:build and site:check together, so a lint # that walked one would report on a file nobody wrote. site/*.timestamp-*.mjs + +# The harness in scripts/repl-study writes raw ANSI beside its committed +# captures; only the readable .txt frames are reviewed and kept. +scripts/tests/fixtures/repl-study/*.ansi diff --git a/deno.json b/deno.json index 292fd4016..5ef03d6ec 100644 --- a/deno.json +++ b/deno.json @@ -64,6 +64,7 @@ "gen:publish-workflow": "deno run --allow-all packages/cli/src/deno.ts run scripts/gen-publish-workflow.md", "bump": "deno run -A scripts/bump-version.ts", "weights:measure": "deno run --allow-all scripts/measure-test-weights.ts", + "repl:study": "deno run --allow-all --frozen scripts/repl-study/main.ts", "test": "deno test --allow-all --frozen", "verify": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/preflight.ts scripts/verify.ts", "vendor:verify": "deno run --allow-read --allow-write=/tmp --allow-env --allow-run --cached-only --frozen scripts/verify-cloudflare-dofs.ts", diff --git a/deno.lock b/deno.lock index ec96c9990..6796b5338 100644 --- a/deno.lock +++ b/deno.lock @@ -41,6 +41,7 @@ "npm:@agentclientprotocol/sdk@1.3.0": "1.3.0_zod@4.4.3", "npm:@babel/core@^7.28.0": "7.29.7", "npm:@babel/preset-react@^7.27.1": "7.29.7_@babel+core@7.29.7", + "npm:@bomb.sh/tty@0.9.0": "0.9.0", "npm:@durable-streams/client@~0.2.2": "0.2.6", "npm:@durable-streams/server@~0.3.8": "0.3.8", "npm:@effectionx/context-api@0.6.0": "0.6.0_effection@4.1.0", @@ -467,6 +468,9 @@ "@babel/helper-validator-identifier" ] }, + "@bomb.sh/tty@0.9.0": { + "integrity": "sha512-1fX9lgwdc+kGRQeVEYwv7cJb5i855ysvB/TMCC3/wnnjMiKLZjWew6LLnsAMq4pLzkweivYw+zwYNZRUVhjoBA==" + }, "@clack/core@1.4.3": { "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", "dependencies": [ @@ -3996,6 +4000,7 @@ ], "packageJson": { "dependencies": [ + "npm:@bomb.sh/tty@0.9.0", "npm:@durable-streams/client@~0.2.2", "npm:@durable-streams/server@~0.3.8", "npm:@effectionx/context-api@0.6.0", diff --git a/package.json b/package.json index 7941dd3d4..9fb541619 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "mdast-util-to-string": "^4" }, "devDependencies": { + "@bomb.sh/tty": "0.9.0", "@durable-streams/server": "^0.3.8", "@executablemd/acp": "workspace:*", "@executablemd/cli": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc5691b9..d851f4061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@bomb.sh/tty': + specifier: 0.9.0 + version: 0.9.0 '@durable-streams/server': specifier: ^0.3.8 version: 0.3.8 @@ -552,6 +555,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@bomb.sh/tty@0.9.0': + resolution: {integrity: sha512-1fX9lgwdc+kGRQeVEYwv7cJb5i855ysvB/TMCC3/wnnjMiKLZjWew6LLnsAMq4pLzkweivYw+zwYNZRUVhjoBA==} + engines: {node: '>= 22'} + '@clack/core@1.4.3': resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} @@ -2723,6 +2730,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@bomb.sh/tty@0.9.0': {} + '@clack/core@1.4.3': dependencies: fast-wrap-ansi: 0.2.2 diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md new file mode 100644 index 000000000..d02742fb9 --- /dev/null +++ b/scripts/repl-study/README.md @@ -0,0 +1,82 @@ +# REPL interaction study, in a real terminal + +A bounded experiment for [#838](https://github.com/taras/executable.md/issues/838), +under the REPL quest [#827](https://github.com/taras/executable.md/issues/827). +It renders the Product Owner's approved `XMD REPL Terminal Interface` study from +fixture data through `@bomb.sh/tty` 0.9.0, to answer whether that renderer can +carry the design in terminal cells. + +It executes no XMD, opens no Agent session, reads no journal and writes nothing +but its captures. The implementation may be discarded; `RESULT.md` records what +it found. + +## Run it + +```bash +deno task repl:study # in this terminal +deno task repl:study --fixture drawer # opening on one moment +deno task repl:study --capture captures/ # every fixture at every profile +deno task repl:study --print nested wide # one frame, as text +``` + +Keys, while it is running: + +| Key | What it does | +| --- | --- | +| `1`–`6` | show a fixture: empty, nested, generated, drawer, paused, settled | +| `↑` `↓` `PgUp` `PgDn` | move the transcript window | +| `←` `→` | move the selected checkpoint, one at a time | +| `Esc` | return to the head | +| `Tab` / `Shift+Tab` | move between surfaces, which matters in the narrow profile | +| `d` | open or close the drawer | +| `q` or `Ctrl+C` | leave, restoring the terminal | + +These are the harness's own controls. The accepted focus model — the five-region +ring, the drawer's focus trap, and where focus returns after a suspension — is +[#839](https://github.com/taras/executable.md/issues/839), not this experiment. + +## What it shows + +Six fixtures, each a moment from the study: an empty REPL; a `Plan` running +inside the document scope with three sections settled; the Plan's returned +program replacing the expression that produced it; a project `Elicit` drawer +with three Agent sessions in flight; a paused head with an earlier checkpoint +under inspection; and a settled Entry 1. + +Four layout profiles, chosen from the measured terminal size alone: + +| Profile | From | Composition | +| --- | --- | --- | +| `wide` | 160 × 36 | Sessions, transcript and bindings side by side, above one full-width Execution History footer | +| `medium` | 120 × 30 | the same composition at its floor, with secondary detail dropped | +| `narrow` | 72 × 20 | one surface at a time, full screen, under a bar naming it | +| `too-small` | below 72 × 20 | an explicit refusal that recovers on the next resize | + +## The captures + +`--capture ` writes every fixture at every profile as two files: a `.txt` +frame, which is the interface as a person reads it, and a `.ansi` file, which is +the exact byte stream. The `.txt` frames under +`scripts/tests/fixtures/repl-study/` are committed and are also the goldens +`scripts/tests/repl-study.test.ts` checks, so a rendering change shows up in a +diff as the picture it changed. The `.ansi` files are not committed. + +## How it is put together + +| File | What it owns | +| --- | --- | +| `model.ts` | the semantic vocabulary — scopes, phases, sections, sessions, bindings, checkpoints, drawers. No cells. | +| `fixtures.ts` | the six moments, from the study's own content | +| `view.ts` | what the person chose: the transcript window, the selected checkpoint, the current surface | +| `layout.ts` | the profile, and every region's rectangle in cells | +| `render.ts` | those rectangles and that fixture, as `@bomb.sh/tty` operations | +| `screen.ts` | a terminal's cells, reconstructed from the bytes, so a frame can be read back | +| `host.ts` | the only module that touches the terminal: modes, raw input, signals, restoration | +| `capture.ts` | one frame, away from a terminal, in bytes and in cells | +| `mutations.ts` | the eight ways the evidence breaks this on purpose | +| `main.ts` | the documented command | + +`--replay` runs the same lifecycle with no terminal attached, writing its byte +stream to an ordinary pipe. That is how the evidence checks that the modes the +harness turned on are turned back off — on an ordinary exit, on a signal, and +when a frame throws. diff --git a/scripts/repl-study/RESULT.md b/scripts/repl-study/RESULT.md new file mode 100644 index 000000000..76a4965be --- /dev/null +++ b/scripts/repl-study/RESULT.md @@ -0,0 +1,104 @@ +# What this experiment found + +Issue [#838](https://github.com/taras/executable.md/issues/838) asked whether +`@bomb.sh/tty` can render the approved `XMD REPL Terminal Interface` study in a +real terminal, and what the design owes a terminal that is not 2560 × 1440. + +**Decision: retain the harness, and revise three design states.** The renderer +carried every fixture at every profile without a single renderer error, the +terminal survived exit, interruption and failure, and the seam between semantic +fixtures, layout, rendering and the terminal host held. Three states needed an +adaptation the study does not describe, each named below, and those belong to +whoever takes the design further. + +## Dimensions tested + +| Where | Size | Profile | +| --- | --- | --- | +| rendered and captured | 200 × 50 | wide | +| rendered and captured | 140 × 38 | medium | +| rendered and captured | 90 × 28 | narrow | +| rendered and captured | 64 × 18 | too-small | +| a real pseudo-terminal, interactively, macOS `script` | 80 × 24 | narrow | + +The interactive run opened, showed the `nested` fixture, accepted `4`, `Tab` and +`q` as keystrokes, and left the terminal in the modes it found. Every other +dimension was exercised through the captures and the suite. + +## What the renderer gave us + +- **Layout arrives back in cells.** `render().info.get(id).bounds` reports + `{x, y, width, height}` in terminal cells, so "the Execution History footer is + never covered" is a question that can be asked of the renderer directly + instead of inferred from bytes. This is the single most useful thing the + library does for evidence. +- **The byte vocabulary is tiny.** Frames are made of `ESC[0m`, + `ESC[48;2;r;g;bm`, `ESC[row;colH` and text. Sixty lines reconstruct a terminal + from them, which is how the stale-cell check is possible at all. +- **Diffs really are minimal.** One changed character emits `ESC[0m ESC[1;23H2`. +- **Clay's layout is enough.** Floating regions at exact cell rectangles, fitted + and grown axes, padding, clipping and borders composed the whole study — + three panes, a bottom-anchored contextual band, a full-width footer, and a + drawer above it — with no arithmetic beyond `layout.ts`. +- **Glyphs render.** `▶ ● ◆ ✓ ◀ × ≈ · ↳ ▸ │ ├ ╭ ─` all appear, so the study's + "glyph + word, never colour alone" rule survives into the terminal. + +## Limitations, as observed + +1. **The renderer clips; it does not scroll.** `clip` truncates a region's + overflow and there is no scroll offset, so a window over a long transcript is + the application's to own. That is not a defect — it is a boundary, and it + means every consumer of this renderer will write the same windowing code. +2. **Resize is not in the input stream.** `Input.scan()` decodes keys and mouse + reports; feeding it `ESC[8;24;80t` produced nine ordinary keydowns. Size + changes must come from `SIGWINCH` plus `Deno.consoleSize()` and be handed to + `term.update()`. A renderer never told is not merely stale: it goes on + addressing cells the terminal no longer has. +3. **A pseudo-terminal may report `0 × 0`.** macOS `script` does, and a renderer + handed those dimensions draws nothing at all — which looks exactly like a + crash. The harness now assumes 80 × 24 when the terminal will not say. +4. **Ambiguous-width glyphs are assumed to be one cell.** `●` and `◆` are East + Asian Ambiguous; a terminal configured to render them double-width would + misalign every rail and notch that uses them. Nothing here detects that, and + no such terminal was tested. +5. **The output view expires.** `render().output` must be copied immediately — + `Uint8Array.from(...)` — or the next frame invalidates it. + +## The three design states that required adaptation + +1. **Nesting depth has no height left to use.** The band is four rows, and the + study's four extents already spend them: a minor checkpoint takes the track + row, an entry boundary rises one row above it, the head takes three rows and + carries its label, and a historical selection takes the whole band. Depth is + therefore a glyph tier — `●` for the entry's own scope, `◇` one level in, `·` + deeper — and past three tiers the band stops distinguishing. The scope path + is exact in the journal list beside it, so nothing is lost, but the band + alone cannot answer "how deep is this". +2. **A narrow track is mostly transport.** The study's rule that the track + yields room to the visible controls is faithful and expensive: at 90 columns + while inspecting history, `INSPECTING [ Continue ] [ Return ] [ Fork ]` + leaves the track about nine columns for fourteen checkpoints. Markers that + would collide are gathered into one notch carrying their count, `←`/`→` still + steps through every checkpoint behind it, and the full-screen history surface + lists them all. A design that wants the track legible at narrow widths has to + decide what the transport gives up first. +3. **The band's own labels do not fit narrow.** `EXECUTION HISTORY` and + `recorded · 00:53` cost eighteen columns that the track needs more, so the + narrow band says `HISTORY` and `00:53` and lets the surface bar above it + carry the name. The same pressure drops the transcript's eight-column phase + word below 56 columns, and drops session notes, binding notes and the + drawer's schema column at medium. + +## What was not answered + +- Focus, the ring, the drawer's trap and where focus returns after a suspension + are #839's, and nothing here establishes them. +- No reusable component boundary is proposed; #840 owns that, and the region + functions in `render.ts` are deliberately private. +- Restoration is proved at the byte boundary and by construction — cleanup + registered before the modes are applied — rather than by inspecting a + pseudo-terminal's mode flags. Allocating a PTY and emulating a child terminal + is the territory #801 withdrew. +- Nothing here executes XMD, journals anything, or opens an Agent session, so + every fixture is a statement about rendering and none is a statement about + execution. diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts new file mode 100644 index 000000000..28b1ef1e2 --- /dev/null +++ b/scripts/repl-study/capture.ts @@ -0,0 +1,163 @@ +/** + * One frame, rendered away from a terminal, in bytes and in cells. + * + * `@bomb.sh/tty` does no I/O, so the same frame the interactive harness writes + * to a real terminal can be produced here and read back as text. That is what + * makes the captures reviewable: the `.txt` files are the interface as a person + * would see it, and they are also what the evidence compares against. + */ + +import { createTerm } from "@bomb.sh/tty"; +import type { Term } from "@bomb.sh/tty"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { ensureDir, writeTextFile } from "@effectionx/fs"; +import { join } from "node:path"; + +import { fixture, fixtures } from "./fixtures.ts"; +import type { Fixture } from "./model.ts"; +import type { Profile, SurfaceName } from "./layout.ts"; +import { layoutFor } from "./layout.ts"; +import { renderScreen } from "./render.ts"; +import { applyAnsi, createGrid, gridText } from "./screen.ts"; +import { initialView } from "./view.ts"; +import type { View } from "./view.ts"; +import type { Mutation } from "./mutations.ts"; + +export interface Size { + readonly cols: number; + readonly rows: number; +} + +/** + * The dimensions each profile is captured at. + * + * These are representative terminals, not thresholds: `layout.ts` owns where one + * profile ends and the next begins, and these sit inside each range. + */ +export const PROFILE_SIZES: Record = { + wide: { cols: 200, rows: 50 }, + medium: { cols: 140, rows: 38 }, + narrow: { cols: 90, rows: 28 }, + "too-small": { cols: 64, rows: 18 }, +}; + +export interface Frame { + readonly ansi: Uint8Array; + readonly text: string; +} + +export interface FrameRequest { + readonly fixture: Fixture; + readonly view: View; + readonly size: Size; + readonly mutation?: Mutation; + readonly surface?: SurfaceName; +} + +export function* useTerm(size: Size): Operation { + return yield* until(createTerm({ width: size.cols, height: size.rows })); +} + +/** Render one frame into a fresh terminal, which is always a complete repaint. */ +export function* renderFrame(request: FrameRequest): Operation { + const term = yield* useTerm(request.size); + return renderInto(term, request); +} + +export function renderInto(term: Term, request: FrameRequest): Frame { + const { view, size, mutation } = request; + // A frame drawn from state the harness has already left behind. The renderer + // cannot tell the difference — only a reader, or a golden, can. + const subject = mutation === "stale-frame" ? fixture("empty") : request.fixture; + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: subject.drawer !== undefined && view.drawerOpen, + surface: request.surface ?? view.surface, + mutation, + }); + const result = term.render(renderScreen({ fixture: subject, view, layout, mutation })); + if (result.errors.length > 0) { + throw new Error(`the renderer reported ${JSON.stringify(result.errors)}`); + } + const ansi = Uint8Array.from(result.output); + const grid = applyAnsi(createGrid(size.cols, size.rows), ansi); + return { ansi, text: gridText(grid) }; +} + +export function captureName(fixtureName: string, profile: Profile): string { + return `${fixtureName}.${profile}`; +} + +export interface Capture { + readonly name: string; + readonly profile: Profile; + readonly size: Size; + readonly frame: Frame; +} + +/** + * Every fixture at every profile. + * + * `too-small` is captured for two fixtures only: the refusal does not vary with + * what is behind it, and capturing six identical screens would say six times + * less than capturing two and saying so. + */ +export function* captureAll(): Operation { + const captures: Capture[] = []; + const profiles: Profile[] = ["wide", "medium", "narrow"]; + for (const subject of fixtures()) { + for (const profile of profiles) { + const size = PROFILE_SIZES[profile]; + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + captures.push({ name: captureName(subject.name, profile), profile, size, frame }); + } + } + for (const name of ["drawer", "paused"]) { + const subject = fixture(name); + const size = PROFILE_SIZES["too-small"]; + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + captures.push({ name: captureName(name, "too-small"), profile: "too-small", size, frame }); + } + + // The promoted surfaces only exist in narrow, so the wide captures never show + // them. These are the routed views themselves. + const routed: readonly { readonly fixture: string; readonly surface: SurfaceName }[] = [ + { fixture: "paused", surface: "history" }, + { fixture: "drawer", surface: "sessions" }, + { fixture: "nested", surface: "bindings" }, + ]; + for (const route of routed) { + const subject = fixture(route.fixture); + const size = PROFILE_SIZES.narrow; + const frame = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), surface: route.surface, drawerOpen: false }, + size, + surface: route.surface, + }); + captures.push({ + name: `${route.fixture}.narrow.${route.surface}`, + profile: "narrow", + size, + frame, + }); + } + return captures; +} + +export function* writeCaptures(directory: string, captures: readonly Capture[]): Operation { + yield* ensureDir(directory); + for (const capture of captures) { + const header = `${capture.name} · ${capture.size.cols} × ${capture.size.rows}\n`; + yield* writeTextFile( + join(directory, `${capture.name}.txt`), + `${header}${capture.frame.text}\n`, + ); + yield* writeTextFile( + join(directory, `${capture.name}.ansi`), + new TextDecoder().decode(capture.frame.ansi), + ); + } +} diff --git a/scripts/repl-study/fixtures.ts b/scripts/repl-study/fixtures.ts new file mode 100644 index 000000000..74500cca3 --- /dev/null +++ b/scripts/repl-study/fixtures.ts @@ -0,0 +1,707 @@ +/** + * The six moments the harness can show, taken from the approved study. + * + * Between them they carry every transcript and history trait #838 asks for: + * nested visible scopes with lifecycle rails, collapsed completed work, prose + * long enough to wrap, generated XMD replacing the expression that produced it, + * concurrent session activity, an Elicit drawer, a paused head with a historical + * selection, marker runs dense enough to collide, nesting deeper than the band's + * rows, and a settled entry. + * + * The content is the study's own: the `Create a project README` program, its + * `Plan` component, the three Agent sessions, and the twelve recorded + * checkpoints of the Journal Time Travel animation. + */ + +import type { Checkpoint, Fixture, Session, TranscriptRow } from "./model.ts"; +import { FIXTURE_NAMES } from "./model.ts"; + +const RETURNED_PROGRAM = [ + "# Create a project README", + "", + "Provide the project name and a one-sentence description.", + "", + '', + " Enter the project details.", + "", + "", + "This is the README that will be created:", + "", + '', +]; + +const README = ["# Northstar", "", "A lightweight workspace for coordinating coding agents."]; + +const SESSIONS: readonly Session[] = [ + { + id: "plan-a91f7c", + agent: "planner", + state: "completed", + label: "✓ completed", + turn: "turn 1 · returned 59 lines", + selected: true, + }, + { + id: "review-b72e1d", + agent: "reviewer", + state: "active", + label: "● responding", + turn: "turn 1 · streaming", + note: "streaming · background update · selection unchanged", + }, + { + id: "implement-c31d2e", + agent: "implementer", + state: "queued", + label: "· queued", + turn: "no turn yet", + }, +]; + +/** + * The recorded timeline of the study's animation. + * + * Two of these share a second with a neighbour, and three sit deeper than the + * band has rows for. Both are deliberate: a band that cannot show them has to + * summarize rather than overprint, and the scrubber still has to reach them. + */ +const CHECKPOINTS: readonly Checkpoint[] = [ + { + at: 2, + kind: "entry", + label: "Entry 1 submitted", + scope: "REPL", + depth: 0, + records: ["repl.entry.submitted", "source.frozen 8 lines"], + }, + { + at: 5, + kind: "event", + label: "document scope entered", + scope: "Entry 1 › document", + depth: 1, + records: ["scope.enter document", "capability.granted write"], + }, + { + at: 12, + kind: "event", + label: "Plan entered", + scope: "Entry 1 › document › Plan", + depth: 2, + records: ["scope.enter Plan", "inputs.bound content", "component.resolved Plan.md"], + }, + { + at: 18, + kind: "event", + label: "planning inputs prepared", + scope: "… › Plan › PlanInputs", + depth: 3, + records: ["binding.published syntax", "binding.published inputs"], + }, + { + at: 29, + kind: "event", + label: "planning Agent response admitted", + scope: "… › Plan › Prompt", + depth: 3, + records: ["agent.turn.admitted plan-a91f7c", "binding.published draft"], + }, + { + at: 30, + kind: "event", + label: "draft checked", + scope: "… › Plan › Check", + depth: 4, + records: ["binding.published responseKind", "binding.published check"], + }, + { + at: 41, + kind: "event", + label: "review returned Approve", + scope: "… › Plan › Elicit", + depth: 3, + records: ["elicit.answered review"], + }, + { + at: 47, + kind: "event", + label: "Plan replaced by returned program", + scope: "Entry 1 › document", + depth: 1, + records: ["node.replaced Plan → 59 lines", "scope.exit Plan"], + }, + { + at: 49, + kind: "event", + label: "project Elicit requested", + scope: "Entry 1 › document", + depth: 1, + records: ["elicit.requested project", "drawer.opened"], + }, + { + at: 52, + kind: "event", + label: "project Elicit answered", + scope: "Entry 1 › document", + depth: 1, + records: ["elicit.answered project", "binding.published project"], + }, + { + at: 53, + kind: "event", + label: "confirmation Elicit requested", + scope: "Entry 1 › document", + depth: 1, + records: ["elicit.requested confirmation"], + }, + { + at: 57, + kind: "event", + label: "README.md written", + scope: "Entry 1 › document", + depth: 1, + records: ["effect.file.write README.md", "63 bytes · +3 lines"], + }, + { + at: 60, + kind: "event", + label: "Evaluate exited", + scope: "Entry 1 › document", + depth: 1, + records: ["scope.exit Evaluate", "teardown.complete"], + }, + { + at: 61, + kind: "entry", + label: "Entry 1 completed", + scope: "REPL", + depth: 0, + records: ["repl.entry.completed", "bindings.published 0"], + }, +]; + +function upTo(seconds: number): readonly Checkpoint[] { + return CHECKPOINTS.filter((checkpoint) => checkpoint.at <= seconds); +} + +const NESTED_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "← opened from Entry 1 · live execution projection, not an editor", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "document", depth: 0, emphasis: "title" }, + { + kind: "prose", + text: "repl:entry-1 · submitted source is immutable while running", + depth: 0, + emphasis: "dim", + }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "enter", + pair: "evaluate", + }, + { kind: "prose", text: "Create a project README", depth: 1, emphasis: "title" }, + { + kind: "prose", + depth: 1, + text: "Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, reviews its own draft, and returns it for admission into this document scope.", + }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "active", + pair: "plan", + }, + { kind: "section", name: "Read the Prompt", published: "prompt", state: "collapsed", depth: 2 }, + { + kind: "section", + name: "Prepare the planning inputs", + published: "syntax, inputs", + state: "collapsed", + depth: 2, + }, + { + kind: "section", + name: "Create the first draft", + published: "draft", + state: "collapsed", + depth: 2, + }, + { + kind: "section", + name: "Check the draft", + published: "responseKind, check", + state: "expanded", + depth: 2, + }, + { kind: "lifecycle", source: '', depth: 3, phase: "active", pair: "check" }, + { + kind: "lifecycle", + source: " ", + depth: 4, + phase: "settled", + }, + { + kind: "lifecycle", + source: '', + depth: 3, + phase: "waiting", + pair: "ask", + }, + { + kind: "prose", + text: "Review the generated Plan and choose Approve, Request changes or Stop.", + depth: 4, + }, + { + kind: "prose", + depth: 4, + text: "The reviewer has the draft, the schema it was checked against, and the capabilities the document would be granted if the Plan is admitted. Nothing it returns runs until this scope admits it.", + }, + { kind: "lifecycle", source: '', depth: 4, phase: "settled" }, + { + kind: "lifecycle", + source: '', + depth: 4, + phase: "settled", + }, + { + kind: "lifecycle", + source: "", + depth: 3, + phase: "waiting", + pair: "ask", + close: true, + }, + { kind: "lifecycle", source: "", depth: 3, phase: "active", pair: "check", close: true }, + { kind: "section", name: "Review the draft", published: "review", state: "collapsed", depth: 2 }, + { + kind: "section", + name: "Admit the approved Plan", + published: "admitted", + state: "collapsed", + depth: 2, + }, +]; + +const GENERATED_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "← opened from Entry 1 · live execution projection, not an editor", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "document", depth: 0, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "enter", + pair: "evaluate", + }, + { kind: "prose", text: "Create a project README", depth: 1, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "exit", + pair: "plan", + }, + { + kind: "section", + name: "Admit the approved Plan", + published: "admitted", + state: "collapsed", + depth: 2, + }, + { kind: "lifecycle", source: "", depth: 1, phase: "exit", pair: "plan", close: true }, + { + kind: "fence", + label: "XMD", + lines: RETURNED_PROGRAM, + caption: "returned program · 59 lines · replaces the Plan expression, then evaluates here", + depth: 1, + }, + { kind: "prose", text: "Create a project README", depth: 1, emphasis: "title" }, + { kind: "prose", text: "Provide the project name and a one-sentence description.", depth: 1 }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "enter", + pair: "elicit", + }, + { kind: "prose", text: "Enter the project details.", depth: 2 }, + { kind: "lifecycle", source: "", depth: 1, phase: "enter", pair: "elicit", close: true }, +]; + +const DRAWER_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "← opened from Entry 1 · live execution projection, not an editor", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "document", depth: 0, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "enter", + pair: "evaluate", + }, + { + kind: "section", + name: "Ask for the project details", + published: "project", + state: "expanded", + depth: 1, + }, + { + kind: "lifecycle", + source: '', + depth: 1, + phase: "waiting", + pair: "elicit", + }, + { kind: "prose", text: "Enter the project details.", depth: 2 }, + { + kind: "lifecycle", + source: "", + depth: 1, + phase: "waiting", + pair: "elicit", + close: true, + }, + { kind: "prose", text: "▲ suspended · answer in the drawer below", depth: 1, emphasis: "strong" }, +]; + +const INSPECTED_ROWS: readonly TranscriptRow[] = [ + { + kind: "prose", + text: "reconstructed from the journal · no live action is possible here", + depth: 0, + emphasis: "dim", + }, + { kind: "prose", text: "Plan", depth: 0, emphasis: "title" }, + { + kind: "lifecycle", + source: '', + depth: 0, + phase: "active", + pair: "plan", + }, + { kind: "section", name: "Read the Prompt", published: "prompt", state: "collapsed", depth: 1 }, + { + kind: "section", + name: "Prepare the planning inputs", + published: "syntax, inputs", + state: "expanded", + depth: 1, + }, + { kind: "lifecycle", source: '', depth: 2, phase: "settled" }, + { + kind: "lifecycle", + source: '', + depth: 2, + phase: "active", + }, + { + kind: "prose", + text: "XMD catalog · 47 symbols · component, control, agent, io", + depth: 2, + emphasis: "dim", + }, +]; + +const SETTLED_ROWS: readonly TranscriptRow[] = [ + { kind: "prose", text: "Create a project README", depth: 0, emphasis: "title" }, + { kind: "prose", text: "Provide the project name and a one-sentence description.", depth: 0 }, + { + kind: "fence", + label: "MARKDOWN", + lines: README, + depth: 0, + caption: "README.md · 63 bytes · +3 lines", + }, + { kind: "prose", text: "README.md was created for Northstar.", depth: 0, emphasis: "strong" }, + { kind: "prose", text: "no REPL bindings published · 1 file written", depth: 0, emphasis: "dim" }, +]; + +const FIXTURES: Record = { + empty: { + name: "empty", + moment: "a fresh REPL, before anything has run", + crumb: "REPL", + sidebar: { + tab: "sessions", + heading: "No sessions yet", + placeholder: [ + "Agent sessions appear here as executions open them.", + "They persist after an entry settles.", + ], + }, + sessions: [], + bindings: { + scopeName: "REPL scope", + bindings: [], + placeholder: [ + "No REPL bindings yet", + "Values named with as appear here for the active scope.", + ], + }, + input: { + label: "REPL INPUT", + hint: "⇧⏎ newline", + placeholder: "Enter XMD or invoke a document…", + runEnabled: true, + }, + history: { elapsed: "00:00", headAt: 0, checkpoints: [], transport: "idle" }, + }, + + nested: { + name: "nested", + moment: "Plan running inside the document scope, three sections settled", + crumb: "REPL › Entry 1 › document › Plan · active", + sidebar: { + tab: "sessions", + heading: "SESSIONS · 1", + subheading: "chronological · selection follows you, not activity", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "31.4", + sourceLines: 8, + scopeNote: "↳ Plan scope open", + rows: NESTED_ROWS, + }, + sessions: SESSIONS.slice(0, 1), + bindings: { + scopeName: "Plan scope", + bindings: [ + { + name: "prompt", + lines: ['"Create an XMD program that asks me for a', 'project name and a description…"'], + }, + { + name: "syntax", + note: "prose", + lines: ["XMD catalog · 47 symbols", "component, control, agent, io"], + }, + { + name: "inputs", + note: "json", + lines: ["{", ' surface: "component",', ' session: "plan-a91f7c",', " budget: 3", "}"], + }, + { + name: "draft", + note: "XMD source · 59 lines", + lines: ["# Create a project README", ''], + }, + ], + }, + input: { + label: "DRAFT · ENTRY 2", + hint: "Run unavailable while Entry 1 is active", + runEnabled: false, + }, + history: { + elapsed: "00:31", + headAt: 31, + checkpoints: upTo(31), + transport: "live", + }, + }, + + generated: { + name: "generated", + moment: "the Plan's returned program replacing the expression that produced it", + crumb: "REPL › Entry 1 › document · active", + sidebar: { + tab: "sessions", + heading: "SESSIONS · 2", + subheading: "chronological · selection follows you, not activity", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "48.1", + sourceLines: 8, + scopeNote: "↳ document scope open", + rows: GENERATED_ROWS, + }, + sessions: SESSIONS.slice(0, 2), + bindings: { + scopeName: "document scope", + bindings: [ + { + name: "admitted", + note: "XMD source · 59 lines · sealed", + lines: ["# Create a project README"], + }, + ], + }, + input: { + label: "DRAFT · ENTRY 2", + hint: "Run unavailable while Entry 1 is active", + runEnabled: false, + }, + history: { + elapsed: "00:48", + headAt: 48, + checkpoints: upTo(48), + transport: "live", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, + + drawer: { + name: "drawer", + moment: "suspended at the project Elicit while three sessions are in flight", + crumb: "REPL › Entry 1 › document · suspended", + sidebar: { + tab: "sessions", + heading: "SESSIONS · 3", + subheading: "chronological · selection follows you, not activity", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "48.9", + sourceLines: 8, + scopeNote: "↳ document scope suspended", + rows: DRAWER_ROWS, + }, + sessions: SESSIONS, + bindings: { + scopeName: "document scope", + bindings: [{ name: "readme", note: "markdown · 3 lines", lines: ["# Northstar"] }], + }, + drawer: { + kind: "project", + heading: "INPUT REQUIRED", + origin: + 'suspended at · document scope · validated against the Elicit schema', + prompt: "Enter the project details.", + fields: [ + { label: "Project name", value: "Northstar" }, + { label: "Description", value: "A lightweight workspace for coordinating coding agents." }, + ], + schema: ["{", " name: string (required),", " description: string (required)", "}"], + validation: "both fields valid", + submit: "Submit ⌘↵", + }, + input: { + label: "DRAFT · ENTRY 2", + hint: "Run unavailable while Entry 1 is active", + runEnabled: false, + }, + history: { + elapsed: "00:49", + headAt: 49, + checkpoints: upTo(49), + transport: "live", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, + + paused: { + name: "paused", + moment: "paused at the head, inspecting the recorded Plan scope", + crumb: "REPL › Entry 1 › document › Plan", + badge: "RECONSTRUCTED AT 00:12 · READ-ONLY", + readOnly: true, + sidebar: { + tab: "journal", + heading: "ENTRY 1 · CREATE PROJECT README", + subheading: "inspecting recorded history · read-only", + }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "running", + elapsed: "53.0", + sourceLines: 8, + scopeNote: "↳ reconstructed · read-only", + rows: INSPECTED_ROWS, + }, + sessions: SESSIONS, + bindings: { + scopeName: "Plan scope · as recorded", + bindings: [{ name: "prompt", lines: ['"Create an XMD program that asks…"'] }], + }, + input: { + label: "DRAFT · ENTRY 2", + hint: "suspended · inspecting recorded history", + runEnabled: false, + }, + history: { + elapsed: "00:53", + headAt: 53, + selectedAt: 12, + checkpoints: upTo(53), + transport: "inspecting", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, + + settled: { + name: "settled", + moment: "Entry 1 settled, the input ready for Entry 2", + crumb: "REPL · Entry 1 settled", + sidebar: { tab: "sessions", heading: "SESSIONS · 3", subheading: "persist after settling" }, + entry: { + id: "Entry 1", + title: "Create a project README", + state: "completed", + elapsed: "41.2", + sourceLines: 8, + scopeNote: "▸ source · 8 lines", + rows: SETTLED_ROWS, + }, + sessions: SESSIONS, + bindings: { + scopeName: "REPL scope", + bindings: [], + placeholder: [ + "Entry 1 published none", + "Values named with as appear here for the active scope.", + ], + }, + input: { + label: "REPL INPUT", + hint: "ready for Entry 2", + placeholder: "Enter XMD or invoke a document…", + runEnabled: true, + }, + history: { + elapsed: "01:01", + headAt: 61, + checkpoints: CHECKPOINTS, + transport: "idle", + compressed: { at: 35, note: "4.9s agent wait · compressed" }, + }, + }, +}; + +export function fixture(name: string): Fixture { + const found = FIXTURES[name]; + if (!found) { + throw new Error(`no such fixture: ${name}`); + } + return found; +} + +export function fixtures(): readonly Fixture[] { + return FIXTURE_NAMES.map((name) => fixture(name)); +} diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts new file mode 100644 index 000000000..f7517f054 --- /dev/null +++ b/scripts/repl-study/host.ts @@ -0,0 +1,415 @@ +/** + * The only module that touches the terminal. + * + * Everything it turns on, it turns back off — on an ordinary exit, on a signal, + * and when a frame throws. The ordering is the point: each `ensure()` is + * registered *before* the thing it undoes exists, because a run halted while it + * is still acquiring has nothing registered to unwind, and a terminal left in + * the alternate buffer with its cursor hidden is a shell the person has to fix + * by hand. + * + * Mouse reporting is deliberately never enabled. Nothing here needs a pointer, + * and a terminal left reporting mouse movement is the loudest way this + * experiment could damage the thing it is borrowing. + */ + +import { alternateBuffer, createInput, cursor, settings } from "@bomb.sh/tty"; +import type { Input, InputEvent, Setting, Term } from "@bomb.sh/tty"; +import { createSignal, ensure, resource, spawn, until } from "effection"; +import type { Operation } from "effection"; + +import { fixture, fixtures } from "./fixtures.ts"; +import { FIXTURE_NAMES } from "./model.ts"; +import type { Fixture, FixtureName } from "./model.ts"; +import { layoutFor, SURFACES } from "./layout.ts"; +import { renderScreen } from "./render.ts"; +import { transcriptLines } from "./render.ts"; +import { initialView, moveSurface, returnToHead, scrollBy, scrubBy, toggleDrawer } from "./view.ts"; +import type { View } from "./view.ts"; +import { useTerm } from "./capture.ts"; +import type { Mutation } from "./mutations.ts"; + +/** The modes the harness changes, as one reversible pair. */ +export function terminalModes(): Setting { + return settings(alternateBuffer({ clear: true }), cursor(false)); +} + +/** + * Apply the terminal modes, and restore them however this ends. + * + * The cleanup is registered before the first byte is written, and it only + * reverts what it actually applied. + */ +export function useTerminalModes( + write: (bytes: Uint8Array) => void, + mutation?: Mutation, +): Operation { + return resource(function* (provide) { + const modes = terminalModes(); + let applied = false; + yield* ensure(() => { + if (applied && mutation !== "leak-terminal-modes") { + write(modes.revert); + } + }); + write(modes.apply); + applied = true; + yield* provide(); + }); +} + +export function useRawMode(mutation?: Mutation): Operation { + return resource(function* (provide) { + let raw = false; + yield* ensure(() => { + if (raw && mutation !== "leak-terminal-modes") { + Deno.stdin.setRaw(false); + } + }); + Deno.stdin.setRaw(true); + raw = true; + yield* provide(); + }); +} + +/** + * One operating-system signal, for as long as the enclosing scope lives. + * + * The handler is a stable reference, added after its own removal is registered + * and removed in the same scope's teardown. + */ +export function useSignalListener(signal: Deno.Signal, handler: () => void): Operation { + return resource(function* (provide) { + let added = false; + yield* ensure(() => { + if (added) { + Deno.removeSignalListener(signal, handler); + } + }); + Deno.addSignalListener(signal, handler); + added = true; + yield* provide(); + }); +} + +/** + * Raw keystrokes. + * + * The reader is cancelled in teardown, which is what releases a read that is + * still waiting for a key that will never come. + */ +export function useStdinReader(): Operation> { + return resource(function* (provide) { + let reader: ReadableStreamDefaultReader | undefined; + yield* ensure(function* () { + if (reader) { + yield* until(reader.cancel()); + } + }); + reader = Deno.stdin.readable.getReader(); + yield* provide(reader); + }); +} + +/** + * What to assume when the terminal will not say how big it is. + * + * Some pseudo-terminals report `0 × 0` — macOS `script` does — and a renderer + * handed those dimensions draws nothing at all, which looks exactly like a + * harness that crashed. Eighty by twenty-four is the oldest safe answer to that + * question. + */ +export const ASSUMED_SIZE = { cols: 80, rows: 24 } as const; + +export function measureTerminal(): { cols: number; rows: number } { + try { + const size = Deno.consoleSize(); + if (size.columns > 0 && size.rows > 0) { + return { cols: size.columns, rows: size.rows }; + } + } catch { + // No terminal is attached to this process; the assumed size is the answer. + } + return { cols: ASSUMED_SIZE.cols, rows: ASSUMED_SIZE.rows }; +} + +export type HarnessEvent = + | { readonly kind: "key"; readonly event: InputEvent } + | { readonly kind: "resize" } + | { readonly kind: "quit" }; + +export interface HarnessState { + readonly view: View; + readonly fixture: Fixture; + readonly cols: number; + readonly rows: number; + readonly quit: boolean; +} + +function fixtureAt(index: number): FixtureName { + return FIXTURE_NAMES[Math.max(0, Math.min(FIXTURE_NAMES.length - 1, index))]; +} + +/** + * How one event changes what is shown. + * + * Pure, so the same transitions the interactive harness performs can be + * replayed without a terminal. + */ +export function reduce(state: HarnessState, event: HarnessEvent): HarnessState { + if (event.kind === "quit") { + return { ...state, quit: true }; + } + if (event.kind === "resize") { + const size = measureTerminal(); + return { ...state, cols: size.cols, rows: size.rows }; + } + const key = event.event; + if (key.type !== "keydown") { + return state; + } + if (key.code === "q" || (key.ctrl === true && key.code === "c")) { + return { ...state, quit: true }; + } + const digit = Number(key.code); + if (!Number.isNaN(digit) && digit >= 1 && digit <= FIXTURE_NAMES.length) { + const next = fixture(fixtureAt(digit - 1)); + return { ...state, fixture: next, view: initialView(next) }; + } + const layout = layoutFor({ + cols: state.cols, + rows: state.rows, + drawer: state.fixture.drawer !== undefined && state.view.drawerOpen, + surface: state.view.surface, + }); + const width = Math.max(1, (layout.transcript?.width ?? state.cols) - 2); + const height = layout.transcript?.height ?? state.rows; + const total = state.fixture.entry ? transcriptLines(state.fixture.entry, width).length : 0; + const limit = Math.max(0, total - Math.max(1, height - 3)); + const checkpoints = state.fixture.history.checkpoints.length; + + if (key.code === "ArrowUp") { + return { ...state, view: scrollBy(state.view, -1, limit) }; + } + if (key.code === "ArrowDown") { + return { ...state, view: scrollBy(state.view, 1, limit) }; + } + if (key.code === "PageUp") { + return { ...state, view: scrollBy(state.view, -Math.max(1, height - 4), limit) }; + } + if (key.code === "PageDown") { + return { ...state, view: scrollBy(state.view, Math.max(1, height - 4), limit) }; + } + if (key.code === "ArrowLeft") { + return { ...state, view: scrubBy(state.view, -1, checkpoints) }; + } + if (key.code === "ArrowRight") { + return { ...state, view: scrubBy(state.view, 1, checkpoints) }; + } + if (key.code === "Escape") { + return { ...state, view: returnToHead(state.view) }; + } + if (key.code === "Tab") { + return { ...state, view: moveSurface(state.view, key.shift === true ? -1 : 1) }; + } + if (key.code === "d") { + return { ...state, view: toggleDrawer(state.view) }; + } + return state; +} + +function draw( + term: Term, + state: HarnessState, + write: (bytes: Uint8Array) => void, + mutation?: Mutation, +): void { + const layout = layoutFor({ + cols: state.cols, + rows: state.rows, + drawer: state.fixture.drawer !== undefined && state.view.drawerOpen, + surface: state.view.surface, + mutation, + }); + const result = term.render( + renderScreen({ fixture: state.fixture, view: state.view, layout, mutation }), + ); + write(Uint8Array.from(result.output)); +} + +export interface InteractiveOptions { + readonly fixture: FixtureName; + readonly mutation?: Mutation; +} + +/** + * The harness, in a real terminal. + * + * A resize is read from the operating system rather than from the input stream: + * `Input.scan()` decodes keys and mouse reports, and a terminal's size change is + * neither. + */ +export function* runInteractive(options: InteractiveOptions): Operation { + const write = (bytes: Uint8Array) => { + // Every byte this harness sends the terminal goes through one synchronous + // write, because the last of them is sent from teardown: an asynchronous + // write there can be cut short by the very shutdown that scheduled it, and + // a terminal left in the alternate buffer with a hidden cursor is a shell + // the person has to repair by hand. + // oxlint-disable-next-line local/no-sync-filesystem + Deno.stdout.writeSync(bytes); + }; + const size = measureTerminal(); + let state: HarnessState = { + view: initialView(fixture(options.fixture)), + fixture: fixture(options.fixture), + cols: size.cols, + rows: size.rows, + quit: false, + }; + + const term = yield* useTerm({ cols: state.cols, rows: state.rows }); + const input: Input = yield* until(createInput({})); + + yield* useTerminalModes(write, options.mutation); + yield* useRawMode(options.mutation); + + const events = createSignal(); + const subscription = yield* events; + + yield* useSignalListener("SIGWINCH", () => events.send({ kind: "resize" })); + yield* useSignalListener("SIGINT", () => events.send({ kind: "quit" })); + yield* useSignalListener("SIGTERM", () => events.send({ kind: "quit" })); + + const reader = yield* useStdinReader(); + yield* spawn(function* () { + while (true) { + const chunk = yield* until(reader.read()); + if (chunk.done) { + events.send({ kind: "quit" }); + return; + } + const scanned = input.scan(chunk.value); + for (const event of scanned.events) { + events.send({ kind: "key", event }); + } + } + }); + + draw(term, state, write, options.mutation); + + while (true) { + const next = yield* subscription.next(); + if (next.done) { + return; + } + const before = { cols: state.cols, rows: state.rows }; + // Ignoring a resize means ignoring it completely — the renderer keeps the + // dimensions it had, and goes on addressing cells the terminal no longer + // has. + state = + next.value.kind === "resize" && options.mutation === "skip-resize-update" + ? state + : reduce(state, next.value); + if (state.quit) { + return; + } + if (state.cols !== before.cols || state.rows !== before.rows) { + term.update({ width: state.cols, height: state.rows }); + } + draw(term, state, write, options.mutation); + } +} + +export interface ReplayOptions { + readonly mutation?: Mutation; + /** Raise SIGINT at the harness itself once this many frames have been drawn. */ + readonly interruptAfter?: number; + /** Throw from the frame loop, to prove restoration survives a failure. */ + readonly failAfter?: number; +} + +/** + * The same lifecycle, with no terminal attached. + * + * A capture cannot show that the modes were restored, because restoration is a + * sequence of bytes rather than a picture. This writes those bytes to an + * ordinary pipe so the evidence can read them, and drives the same setup, + * frames, resize and teardown path the interactive harness uses. + */ +export function* runReplay(options: ReplayOptions): Operation { + const chunks: Uint8Array[] = []; + const write = (bytes: Uint8Array) => { + chunks.push(bytes); + // The same rule as the interactive host: the restoring bytes are written + // from teardown, where an asynchronous write is not guaranteed to finish. + // oxlint-disable-next-line local/no-sync-filesystem + Deno.stdout.writeSync(bytes); + }; + + let state: HarnessState = { + view: initialView(fixture("nested")), + fixture: fixture("nested"), + cols: 200, + rows: 50, + quit: false, + }; + const term = yield* useTerm({ cols: state.cols, rows: state.rows }); + + yield* useTerminalModes(write, options.mutation); + + const events = createSignal(); + const subscription = yield* events; + yield* useSignalListener("SIGINT", () => events.send({ kind: "quit" })); + + const script: readonly { + readonly cols: number; + readonly rows: number; + readonly fixture: FixtureName; + }[] = [ + { cols: 200, rows: 50, fixture: "nested" }, + { cols: 140, rows: 38, fixture: "drawer" }, + { cols: 90, rows: 28, fixture: "paused" }, + { cols: 64, rows: 18, fixture: "paused" }, + { cols: 200, rows: 50, fixture: "settled" }, + ]; + + let drawn = 0; + for (const step of script) { + const next = fixture(step.fixture); + state = { ...state, fixture: next, view: initialView(next), cols: step.cols, rows: step.rows }; + if (options.mutation !== "skip-resize-update") { + term.update({ width: state.cols, height: state.rows }); + } + draw(term, state, write, options.mutation); + drawn += 1; + if (options.failAfter !== undefined && drawn >= options.failAfter) { + throw new Error("the harness failed while drawing a frame"); + } + if (options.interruptAfter !== undefined && drawn >= options.interruptAfter) { + // The signal is delivered by the operating system to the listener + // installed above; waiting for it here is what proves the listener, and + // the restoration behind it, are reached by a real interruption. + Deno.kill(Deno.pid, "SIGINT"); + const interrupted = yield* subscription.next(); + if (!interrupted.done && interrupted.value.kind === "quit") { + return; + } + return; + } + } + yield* chunksDrawn(chunks); +} + +/** Frames written, kept so a caller can assert on them without a terminal. */ +function* chunksDrawn(chunks: readonly Uint8Array[]): Operation { + if (chunks.length === 0) { + throw new Error("the replay drew no frames"); + } +} + +export function fixtureNames(): readonly FixtureName[] { + return fixtures().map((one) => one.name); +} + +export { SURFACES }; diff --git a/scripts/repl-study/layout.ts b/scripts/repl-study/layout.ts new file mode 100644 index 000000000..d77fc56dc --- /dev/null +++ b/scripts/repl-study/layout.ts @@ -0,0 +1,212 @@ +/** + * Where each region goes, in terminal cells. + * + * The study composes one screen at 2560×1440 — a sidebar at `0..620`, the + * transcript centred in `620..2100`, a bindings pane at `2130..2530`, the + * contextual surface bottom-anchored above a full-width 92px Execution History + * footer. Those proportions are kept here and the pixels are not: a terminal is + * measured in cells, and the same composition has to hold at 240 columns and at + * 120. + * + * Below the wide composition's floor the interface is not shrunk further. It is + * routed: one surface at a time, full screen, which is the policy #827 asks for + * instead of scaling an interface until its text is unreadable. + */ + +import type { Mutation } from "./mutations.ts"; + +export type Profile = "wide" | "medium" | "narrow" | "too-small"; + +export interface Rect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** The surfaces narrow routing moves between, in ring order. */ +export const SURFACES = ["sessions", "transcript", "bindings", "history"] as const; + +export type SurfaceName = (typeof SURFACES)[number]; + +/** Below this the interface refuses rather than lies. */ +export const MINIMUM = { cols: 72, rows: 20 } as const; + +/** What a pane must have to be worth composing beside another one. */ +export const PANE_MINIMUMS = { sidebar: 28, bindings: 26, transcript: 40 } as const; + +/** Rows the study's 92px bands become. */ +const FOOTER_ROWS = 4; +const INPUT_ROWS = 4; +const HEADER_ROWS = 2; + +export interface Layout { + readonly profile: Profile; + readonly cols: number; + readonly rows: number; + readonly screen: Rect; + /** True where a pane is at its floor and secondary detail is dropped. */ + readonly dense: boolean; + readonly sidebar?: Rect; + readonly transcript?: Rect; + readonly bindings?: Rect; + readonly header?: Rect; + /** The REPL input or the Elicit drawer, bottom-anchored above the footer. */ + readonly contextual?: Rect; + readonly footer?: Rect; + readonly separators: readonly Rect[]; + /** Narrow only: the one row naming the surface you are on. */ + readonly surfaceBar?: Rect; + readonly surface?: SurfaceName; +} + +export function profileFor(cols: number, rows: number): Profile { + if (cols < MINIMUM.cols || rows < MINIMUM.rows) { + return "too-small"; + } + if (cols >= 160 && rows >= 36) { + return "wide"; + } + if (cols >= 120 && rows >= 30) { + return "medium"; + } + return "narrow"; +} + +function clamp(value: number, low: number, high: number): number { + return Math.max(low, Math.min(high, value)); +} + +export interface LayoutRequest { + readonly cols: number; + readonly rows: number; + /** An open drawer takes the contextual band in wide, the screen in narrow. */ + readonly drawer: boolean; + /** Which surface narrow routing is showing. */ + readonly surface: SurfaceName; + /** A deliberate break, for the evidence that would otherwise check nothing. */ + readonly mutation?: Mutation; +} + +/** + * The profile a request is composed as. + * + * Two mutations live here rather than in the renderer, because both are + * decisions about which composition to use at all: one keeps the wide + * composition where routing was required, and the other composes an interface + * on a terminal that is too small to carry one. + */ +function composedProfile(cols: number, rows: number, mutation?: Mutation): Profile { + const measured = profileFor(cols, rows); + if (mutation === "ignore-minimum" && measured === "too-small") { + return "narrow"; + } + if (mutation === "shrink-wide-at-narrow" && measured === "narrow") { + return "medium"; + } + return measured; +} + +export function layoutFor(request: LayoutRequest): Layout { + const { cols, rows, drawer, surface } = request; + const screen = { x: 0, y: 0, width: cols, height: rows }; + const profile = composedProfile(cols, rows, request.mutation); + + if (profile === "too-small") { + return { profile, cols, rows, screen, dense: false, separators: [] }; + } + + if (profile === "narrow") { + if (drawer) { + return { + profile, + cols, + rows, + screen, + dense: true, + separators: [], + surface, + contextual: screen, + }; + } + const surfaceBar = { x: 0, y: 0, width: cols, height: 1 }; + const body = { x: 0, y: 1, width: cols, height: rows - 1 }; + if (surface === "transcript") { + const input = { x: 0, y: rows - 3, width: cols, height: 3 }; + return { + profile, + cols, + rows, + screen, + dense: true, + separators: [], + surfaceBar, + surface, + transcript: { ...body, height: body.height - input.height }, + contextual: input, + }; + } + const region = { ...body }; + return { + profile, + cols, + rows, + screen, + dense: true, + separators: [], + surfaceBar, + surface, + sidebar: surface === "sessions" ? region : undefined, + bindings: surface === "bindings" ? region : undefined, + footer: surface === "history" ? region : undefined, + }; + } + + const sidebarWidth = clamp(Math.round(cols * 0.24), PANE_MINIMUMS.sidebar, 52); + const bindingsWidth = clamp(Math.round(cols * 0.16), PANE_MINIMUMS.bindings, 40); + const footer = { x: 0, y: rows - FOOTER_ROWS, width: cols, height: FOOTER_ROWS }; + const contextualHeight = drawer ? Math.min(14, rows - FOOTER_ROWS - 8) : INPUT_ROWS; + const contextual = { + x: sidebarWidth + 1, + y: footer.y - contextualHeight, + width: cols - sidebarWidth - 1, + height: contextualHeight, + }; + const header = { x: sidebarWidth + 1, y: 0, width: contextual.width, height: HEADER_ROWS }; + const paneTop = HEADER_ROWS + 1; + const paneHeight = contextual.y - paneTop; + + return { + profile, + cols, + rows, + screen, + dense: profile === "medium", + sidebar: { x: 0, y: 0, width: sidebarWidth, height: footer.y }, + header, + transcript: { + x: sidebarWidth + 1, + y: paneTop, + width: cols - sidebarWidth - bindingsWidth - 2, + height: paneHeight, + }, + bindings: { x: cols - bindingsWidth, y: paneTop, width: bindingsWidth, height: paneHeight }, + contextual, + footer, + separators: [ + { x: sidebarWidth, y: 0, width: 1, height: footer.y }, + { x: cols - bindingsWidth - 1, y: paneTop, width: 1, height: paneHeight }, + { x: sidebarWidth + 1, y: HEADER_ROWS, width: contextual.width, height: 1 }, + ], + }; +} + +/** True when two rectangles share at least one cell. */ +export function intersects(one: Rect, other: Rect): boolean { + return ( + one.x < other.x + other.width && + other.x < one.x + one.width && + one.y < other.y + other.height && + other.y < one.y + one.height + ); +} diff --git a/scripts/repl-study/main.ts b/scripts/repl-study/main.ts new file mode 100644 index 000000000..f1a603409 --- /dev/null +++ b/scripts/repl-study/main.ts @@ -0,0 +1,170 @@ +/** + * The documented command. + * + * deno task repl:study the harness, in this terminal + * deno task repl:study --capture every fixture at every profile + * deno task repl:study --print nested wide one frame, as text + * deno task repl:study --replay the lifecycle, with no terminal + * + * `scripts/repl-study/README.md` explains the keys and what each mode is for. + */ + +import { exit, main } from "effection"; +import type { Operation } from "effection"; + +import { captureAll, PROFILE_SIZES, renderFrame, writeCaptures } from "./capture.ts"; +import { fixture } from "./fixtures.ts"; +import { runInteractive, runReplay } from "./host.ts"; +import { isFixtureName } from "./model.ts"; +import type { FixtureName } from "./model.ts"; +import type { Profile } from "./layout.ts"; +import { isMutation } from "./mutations.ts"; +import type { Mutation } from "./mutations.ts"; +import { initialView } from "./view.ts"; + +const USAGE = [ + "usage:", + " repl-study [--fixture ] [--mutation ]", + " repl-study --capture [--mutation ]", + " repl-study --print [--mutation ]", + " repl-study --replay [--interrupt-after ] [--fail-after ] [--mutation ]", + "", + "fixtures: empty, nested, generated, drawer, paused, settled", + "profiles: wide, medium, narrow, too-small", +].join("\n"); + +type Mode = + | { readonly kind: "interactive"; readonly fixture: FixtureName } + | { readonly kind: "capture"; readonly directory: string } + | { readonly kind: "print"; readonly fixture: FixtureName; readonly profile: Profile } + | { readonly kind: "replay"; readonly interruptAfter?: number; readonly failAfter?: number }; + +interface Invocation { + readonly mode: Mode; + readonly mutation?: Mutation; +} + +function isProfile(value: string): value is Profile { + return value === "wide" || value === "medium" || value === "narrow" || value === "too-small"; +} + +/** + * Read the command line, refusing anything it does not understand. + * + * An unknown option is a refusal rather than a default, because a harness that + * silently ignored `--mutation stale-frme` would report a passing run for a + * control that never ran. + */ +export function parse(argv: readonly string[]): Invocation | string { + let mutation: Mutation | undefined; + let fixtureName: FixtureName = "nested"; + let mode: Mode | undefined; + let at = 0; + + const value = (): string | undefined => { + at += 1; + return argv[at]; + }; + + while (at < argv.length) { + const argument = argv[at]; + if (argument === "--mutation") { + const name = value(); + if (name === undefined || !isMutation(name)) { + return `--mutation needs one of the declared controls, not ${JSON.stringify(name)}`; + } + mutation = name; + } else if (argument === "--fixture") { + const name = value(); + if (name === undefined || !isFixtureName(name)) { + return `--fixture needs a fixture name, not ${JSON.stringify(name)}`; + } + fixtureName = name; + } else if (argument === "--capture") { + const directory = value(); + if (directory === undefined) { + return "--capture needs a directory to write into"; + } + mode = { kind: "capture", directory }; + } else if (argument === "--print") { + const name = value(); + const profile = value(); + if (name === undefined || !isFixtureName(name)) { + return `--print needs a fixture name, not ${JSON.stringify(name)}`; + } + if (profile === undefined || !isProfile(profile)) { + return `--print needs a profile, not ${JSON.stringify(profile)}`; + } + mode = { kind: "print", fixture: name, profile }; + } else if (argument === "--replay") { + mode = { kind: "replay" }; + } else if (argument === "--interrupt-after" || argument === "--fail-after") { + const count = Number(value()); + if (!Number.isInteger(count) || count < 1) { + return `${argument} needs a frame count`; + } + const replay = mode?.kind === "replay" ? mode : { kind: "replay" as const }; + mode = + argument === "--interrupt-after" + ? { ...replay, interruptAfter: count } + : { ...replay, failAfter: count }; + } else if (argument === "--help" || argument === "-h") { + return USAGE; + } else { + return `unknown option ${JSON.stringify(argument)}\n\n${USAGE}`; + } + at += 1; + } + + return { mode: mode ?? { kind: "interactive", fixture: fixtureName }, mutation }; +} + +function* run(invocation: Invocation): Operation { + const { mode, mutation } = invocation; + + if (mode.kind === "capture") { + const captures = yield* captureAll(); + yield* writeCaptures(mode.directory, captures); + console.log(`wrote ${captures.length} captures to ${mode.directory}`); + return; + } + + if (mode.kind === "print") { + const subject = fixture(mode.fixture); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES[mode.profile], + mutation, + }); + console.log(frame.text); + return; + } + + if (mode.kind === "replay") { + yield* runReplay({ mutation, interruptAfter: mode.interruptAfter, failAfter: mode.failAfter }); + return; + } + + if (!Deno.stdout.isTerminal() || !Deno.stdin.isTerminal()) { + yield* exit( + 2, + "repl-study needs a real terminal. Use --capture to write every frame to files, --print for one, or --replay for the lifecycle.", + ); + return; + } + + yield* runInteractive({ fixture: mode.fixture, mutation }); +} + +if (import.meta.main) { + await main(function* () { + const invocation = parse(Deno.args); + if (typeof invocation === "string") { + console.log(invocation); + yield* exit(invocation === USAGE ? 0 : 2); + return; + } + yield* run(invocation); + }); +} diff --git a/scripts/repl-study/model.ts b/scripts/repl-study/model.ts new file mode 100644 index 000000000..16323eb98 --- /dev/null +++ b/scripts/repl-study/model.ts @@ -0,0 +1,193 @@ +/** + * What the REPL is showing, said semantically. + * + * A fixture describes execution — which scopes are open, what phase each one is + * in, which sections have settled, what the sessions are doing, which values a + * scope published, where the recorded head is — and nothing about a terminal. No + * row, column, width or byte appears in this file or in any value built from it. + * `layout.ts` decides where things go and `render.ts` decides what cells they + * become, so a rendering change cannot quietly become application state. + */ + +/** The lifecycle phase of one component, as the study's study names it. */ +export type Phase = "enter" | "active" | "waiting" | "exit" | "settled" | "failed" | "pending"; + +/** One line of the execution projection. */ +export type TranscriptRow = + /** A component boundary or expression, carrying its lifecycle phase. */ + | { + readonly kind: "lifecycle"; + readonly source: string; + readonly depth: number; + readonly phase: Phase; + /** Rows sharing a pair name are one scope's opening and closing boundary. */ + readonly pair?: string; + readonly close?: boolean; + } + /** Rendered prose the execution produced. Long text wraps. */ + | { + readonly kind: "prose"; + readonly text: string; + readonly depth: number; + readonly emphasis?: "title" | "strong" | "dim"; + } + /** Generated Markdown or XMD, shown before and after it is admitted. */ + | { + readonly kind: "fence"; + readonly label: string; + readonly lines: readonly string[]; + readonly caption?: string; + readonly depth: number; + } + /** Completed work, collapsed to the bindings it published. */ + | { + readonly kind: "section"; + readonly name: string; + readonly published: string; + readonly state: "collapsed" | "expanded"; + readonly depth: number; + }; + +/** One transcript entry: immutable source, and the execution it opened. */ +export interface Entry { + readonly id: string; + readonly title: string; + readonly state: "running" | "completed"; + readonly elapsed: string; + readonly sourceLines: number; + readonly scopeNote: string; + readonly rows: readonly TranscriptRow[]; +} + +export interface Session { + readonly id: string; + readonly agent: string; + readonly state: "queued" | "active" | "completed"; + readonly label: string; + readonly turn: string; + readonly selected?: boolean; + readonly note?: string; +} + +export interface Binding { + readonly name: string; + readonly note?: string; + readonly lines: readonly string[]; +} + +/** + * One semantic checkpoint on the recorded timeline. + * + * `kind` is the study's major/minor distinction: an entry boundary is major, + * every other recorded moment is minor. `depth` is how deeply nested the scope + * that produced it was, which is what the band runs out of room for first. + */ +export interface Checkpoint { + readonly at: number; + readonly kind: "entry" | "event"; + readonly label: string; + readonly scope: string; + readonly depth: number; + readonly records: readonly string[]; +} + +export type TransportMode = "idle" | "live" | "paused" | "inspecting"; + +export interface History { + readonly elapsed: string; + /** Recorded seconds at the head. The head is the newest recorded moment. */ + readonly headAt: number; + /** Where a historical selection sits, when the fixture has one. */ + readonly selectedAt?: number; + readonly checkpoints: readonly Checkpoint[]; + readonly transport: TransportMode; + /** A long wait the band compresses rather than drawing to scale. */ + readonly compressed?: { readonly at: number; readonly note: string }; +} + +export type Drawer = + | { + readonly kind: "project"; + readonly heading: string; + readonly origin: string; + readonly prompt: string; + readonly fields: readonly { readonly label: string; readonly value: string }[]; + readonly schema: readonly string[]; + readonly validation: string; + readonly submit: string; + } + | { + readonly kind: "review"; + readonly heading: string; + readonly origin: string; + readonly plan: readonly string[]; + readonly more: string; + readonly decisions: readonly { + readonly label: string; + readonly chosen: boolean; + readonly note?: string; + }[]; + readonly submit: string; + } + | { + readonly kind: "confirm"; + readonly heading: string; + readonly origin: string; + readonly prompt: string; + readonly preview: readonly string[]; + readonly actions: readonly { readonly label: string; readonly primary: boolean }[]; + readonly hint: string; + }; + +export interface SidebarState { + readonly tab: "sessions" | "journal" | "state"; + /** Shown instead of a list when there is nothing to list. */ + readonly placeholder?: readonly string[]; + readonly heading?: string; + readonly subheading?: string; +} + +export interface BindingsPane { + readonly scopeName: string; + readonly bindings: readonly Binding[]; + readonly placeholder?: readonly string[]; +} + +export interface InputBand { + readonly label: string; + readonly hint: string; + readonly placeholder?: string; + readonly runEnabled: boolean; +} + +export const FIXTURE_NAMES = [ + "empty", + "nested", + "generated", + "drawer", + "paused", + "settled", +] as const; + +export type FixtureName = (typeof FIXTURE_NAMES)[number]; + +export interface Fixture { + readonly name: FixtureName; + /** One line naming the moment, shown by the harness itself, not the REPL. */ + readonly moment: string; + readonly crumb: string; + /** The study's `RECONSTRUCTED AT … · READ-ONLY` or `PAUSED AT HEAD`. */ + readonly badge?: string; + readonly readOnly?: boolean; + readonly sidebar: SidebarState; + readonly entry?: Entry; + readonly sessions: readonly Session[]; + readonly bindings: BindingsPane; + readonly drawer?: Drawer; + readonly input: InputBand; + readonly history: History; +} + +export function isFixtureName(value: string): value is FixtureName { + return (FIXTURE_NAMES as readonly string[]).includes(value); +} diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts new file mode 100644 index 000000000..0c162e3cd --- /dev/null +++ b/scripts/repl-study/mutations.ts @@ -0,0 +1,33 @@ +/** + * The ways this harness can be broken on purpose. + * + * Every claim the evidence makes has one of these behind it, because a claim + * nobody can break is a claim nobody is checking. Each value is passed to one + * run, changes exactly one behavior, and has to be rejected by the same oracle + * that admits the honest run — not merely crash it. + */ + +export const MUTATIONS = [ + /** Draw the previous frame's operations after the state changed. */ + "stale-frame", + /** Keep the three-pane composition at narrow dimensions instead of routing. */ + "shrink-wide-at-narrow", + /** Let the drawer take the rows the Execution History footer owns. */ + "drawer-covers-footer", + /** Compose the interface below the supported minimum instead of refusing. */ + "ignore-minimum", + /** Resize the terminal without telling the renderer. */ + "skip-resize-update", + /** Drop the transcript window and the scrubber's coalescing. */ + "clip-long-transcript", + /** Leave the terminal in the modes the harness turned on. */ + "leak-terminal-modes", + /** Render one notch height for every kind of marker. */ + "flatten-notches", +] as const; + +export type Mutation = (typeof MUTATIONS)[number]; + +export function isMutation(value: string): value is Mutation { + return (MUTATIONS as readonly string[]).includes(value); +} diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts new file mode 100644 index 000000000..5d98584e8 --- /dev/null +++ b/scripts/repl-study/render.ts @@ -0,0 +1,1036 @@ +/** + * The approved interface, in terminal cells. + * + * Every region is a floating box placed at the rectangle `layout.ts` computed, + * so what the renderer reports through `render().info` can be checked against + * what the layout intended. Lines are pre-wrapped and padded here rather than + * left to wrap themselves, because a window over a long transcript has to know + * how many rows each line will take before it can decide which lines to show. + * + * Colours, glyphs and wording come from the Product Owner's study. The study is + * explicit that a lifecycle phase is "glyph + word, never colour alone", so a + * phase is always legible on a monochrome terminal too. + */ + +import { close, grow, fixed, open, rgba, text } from "@bomb.sh/tty"; +import type { Op } from "@bomb.sh/tty"; + +import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow } from "./model.ts"; +import type { Layout, Rect } from "./layout.ts"; +import { MINIMUM } from "./layout.ts"; +import type { View } from "./view.ts"; +import type { Mutation } from "./mutations.ts"; + +const C = { + src: rgba(0xc8, 0xd2, 0xd9), + active: rgba(0x7f, 0xd3, 0xe8), + tick: rgba(0x5a, 0xa8, 0x7c), + settledText: rgba(0x7c, 0x86, 0x8d), + hold: rgba(0xc9, 0x9a, 0x3f), + intro: rgba(0xcf, 0xe0, 0xea), + out: rgba(0xe6, 0xec, 0xf1), + label: rgba(0x8b, 0x95, 0x9c), + dim: rgba(0x7b, 0x85, 0x8d), + name: rgba(0xb8, 0xc4, 0xcc), + exit: rgba(0xc2, 0x76, 0x6e), + gold: rgba(0xc9, 0xa8, 0x6a), + fail: rgba(0xd2, 0x4b, 0x3f), + rule: rgba(0x16, 0x1c, 0x21), +}; + +const BG = { + app: rgba(0x0b, 0x0d, 0x0f), + side: rgba(0x09, 0x0b, 0x0c), + center: rgba(0x0c, 0x0e, 0x11), + bind: rgba(0x0a, 0x0c, 0x0e), + drawer: rgba(0x0e, 0x13, 0x16), + input: rgba(0x0a, 0x0d, 0x0f), + footer: rgba(0x08, 0x09, 0x0b), + rule: rgba(0x16, 0x1c, 0x21), +}; + +interface PhaseStyle { + readonly glyph: string; + readonly word: string; + readonly color: number; +} + +const PHASE: Record = { + enter: { glyph: "▶", word: "ENTER", color: C.tick }, + active: { glyph: "●", word: "ACTIVE", color: C.active }, + waiting: { glyph: "●", word: "WAITING", color: C.hold }, + exit: { glyph: "◀", word: "EXIT", color: C.exit }, + settled: { glyph: "✓", word: "SETTLED", color: C.tick }, + failed: { glyph: "×", word: "FAILED", color: C.fail }, + pending: { glyph: " ", word: "", color: C.dim }, +}; + +/** One piece of a line, with the width it is padded or truncated to. */ +export interface Segment { + readonly text: string; + readonly color?: number; + /** Omit on exactly one segment to let it take the remaining width. */ + readonly width?: number; +} + +export interface VisualLine { + readonly segments: readonly Segment[]; +} + +function fit(value: string, width: number): string { + if (width <= 0) { + return ""; + } + const glyphs = [...value]; + if (glyphs.length > width) { + return width === 1 ? "…" : glyphs.slice(0, width - 1).join("") + "…"; + } + return value + " ".repeat(width - glyphs.length); +} + +export function wrapText(value: string, width: number): string[] { + if (width <= 0) { + return []; + } + const lines: string[] = []; + let current = ""; + for (const word of value.split(" ")) { + if (current === "") { + current = word; + continue; + } + if ([...current].length + 1 + [...word].length <= width) { + current = `${current} ${word}`; + continue; + } + lines.push(current); + current = word; + } + if (current !== "") { + lines.push(current); + } + return lines.length === 0 ? [""] : lines; +} + +/** Lay one row of segments out across `width` columns. */ +function lineOps(id: string, width: number, line: VisualLine): Op[] { + const fixedWidth = line.segments.reduce((total, segment) => total + (segment.width ?? 0), 0); + const flexible = line.segments.filter((segment) => segment.width === undefined).length; + const remaining = Math.max(0, width - fixedWidth); + const share = flexible === 0 ? 0 : Math.floor(remaining / flexible); + const ops: Op[] = [ + open(id, { layout: { width: fixed(width), height: fixed(1), direction: "ltr" } }), + ]; + let used = 0; + line.segments.forEach((segment, index) => { + const isLastFlexible = + segment.width === undefined && + line.segments.slice(index + 1).every((later) => later.width !== undefined); + const segmentWidth = segment.width ?? (isLastFlexible ? Math.max(0, remaining - used) : share); + if (segment.width === undefined) { + used += segmentWidth; + } + ops.push( + open(`${id}.${index}`, { layout: { width: fixed(segmentWidth), height: fixed(1) } }), + text(fit(segment.text, segmentWidth), { color: segment.color ?? C.src }), + close(), + ); + }); + ops.push(close()); + return ops; +} + +interface RegionOptions { + readonly bg?: number; + readonly padding?: { readonly left?: number; readonly right?: number; readonly top?: number }; +} + +function region( + id: string, + rect: Rect, + lines: readonly VisualLine[], + options: RegionOptions = {}, +): Op[] { + const padLeft = options.padding?.left ?? 1; + const padRight = options.padding?.right ?? 1; + const padTop = options.padding?.top ?? 0; + const innerWidth = Math.max(0, rect.width - padLeft - padRight); + const capacity = Math.max(0, rect.height - padTop); + const ops: Op[] = [ + open(id, { + layout: { + width: fixed(rect.width), + height: fixed(rect.height), + direction: "ttb", + padding: { left: padLeft, right: padRight, top: padTop }, + }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + bg: options.bg ?? BG.app, + clip: { horizontal: true, vertical: true }, + }), + ]; + lines.slice(0, capacity).forEach((line, index) => { + ops.push(...lineOps(`${id}.line.${index}`, innerWidth, line)); + }); + ops.push(close()); + return ops; +} + +function rule(id: string, rect: Rect, glyph: string): Op[] { + const ops: Op[] = [ + open(id, { + layout: { width: fixed(rect.width), height: fixed(rect.height), direction: "ttb" }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + bg: BG.rule, + clip: { horizontal: true, vertical: true }, + }), + ]; + for (let row = 0; row < rect.height; row += 1) { + ops.push( + open(`${id}.${row}`, { layout: { width: fixed(rect.width), height: fixed(1) } }), + text(glyph.repeat(Math.max(0, rect.width)), { color: C.rule }), + close(), + ); + } + ops.push(close()); + return ops; +} + +function blank(): VisualLine { + return { segments: [{ text: "" }] }; +} + +function plain(value: string, color = C.src): VisualLine { + return { segments: [{ text: value, color }] }; +} + +function label(value: string): VisualLine { + return plain(value, C.label); +} + +/** + * The transcript's rows, already wrapped and railed. + * + * A rail runs from a component's opening boundary to its matching close, so a + * reader can see which nested scope a line belongs to. That is why this returns + * visual lines rather than the semantic rows: the window that scrolls them has + * to count what the terminal will actually show. + */ +export function transcriptLines(entry: Entry, width: number, collapse = true): VisualLine[] { + const lines: VisualLine[] = []; + const openDepths: number[] = []; + const wordColumn = width >= 56 ? 8 : 0; + + const prefixFor = (depth: number, exclude?: number): string => { + let prefix = ""; + for (let level = 0; level < depth; level += 1) { + const railed = openDepths.includes(level) && level !== exclude; + prefix += railed ? "│ " : " "; + } + return prefix; + }; + + const push = (row: TranscriptRow) => { + if (row.kind === "lifecycle") { + const style = PHASE[row.phase]; + const prefix = prefixFor(row.depth, row.close ? row.depth : undefined); + const head = `${prefix}${style.glyph} `; + const body = width - head.length - wordColumn; + lines.push({ + segments: [ + { text: head, color: style.color, width: head.length }, + // Depth already indents the line, so the source's own leading spaces + // would indent it twice. + { text: fit(row.source.trimStart(), Math.max(0, body)), color: C.src }, + ...(wordColumn > 0 ? [{ text: style.word, color: style.color, width: wordColumn }] : []), + ], + }); + if (row.pair !== undefined && !row.close) { + openDepths.push(row.depth); + } + if (row.pair !== undefined && row.close) { + const at = openDepths.lastIndexOf(row.depth); + if (at >= 0) { + openDepths.splice(at, 1); + } + } + return; + } + if (row.kind === "section") { + const prefix = prefixFor(row.depth); + const glyph = row.state === "collapsed" ? "✓" : "▾"; + const color = row.state === "collapsed" ? C.settledText : C.intro; + const summary = + collapse && row.state === "collapsed" ? `${row.name} · ${row.published}` : row.name; + lines.push({ + segments: [ + { + text: `${prefix}${glyph} `, + color: row.state === "collapsed" ? C.tick : C.intro, + width: prefix.length + 2, + }, + { text: summary, color }, + ], + }); + return; + } + if (row.kind === "fence") { + const prefix = prefixFor(row.depth); + const inner = width - prefix.length - 2; + const border = (content: string, color: number) => { + lines.push({ + segments: [ + { text: `${prefix}│ `, color: C.rule, width: prefix.length + 2 }, + { text: fit(content, Math.max(0, inner)), color }, + ], + }); + }; + border(row.label, C.label); + for (const fenced of row.lines) { + border(fenced, fenced.startsWith("#") ? C.intro : C.src); + } + if (row.caption !== undefined) { + border(row.caption, C.dim); + } + return; + } + const prefix = prefixFor(row.depth); + const color = + row.emphasis === "dim" + ? C.dim + : row.emphasis === "title" + ? C.out + : row.emphasis === "strong" + ? C.hold + : C.src; + for (const wrapped of wrapText(row.text, Math.max(1, width - prefix.length))) { + lines.push({ + segments: [ + { text: prefix, width: prefix.length }, + { text: wrapped, color }, + ], + }); + } + }; + + for (const row of entry.rows) { + push(row); + } + return lines; +} + +function entryHeader(entry: Entry): VisualLine { + const running = entry.state === "running"; + return { + segments: [ + { text: entry.id, color: C.out, width: entry.id.length + 2 }, + { + text: running ? `● running · ${entry.elapsed}s` : `✓ completed · ${entry.elapsed}s`, + color: running ? C.active : C.tick, + width: 24, + }, + { text: entry.scopeNote, color: C.dim }, + ], + }; +} + +function transcriptRegion(fixture: Fixture, view: View, rect: Rect, mutation?: Mutation): Op[] { + const width = Math.max(0, rect.width - 2); + const lines: VisualLine[] = []; + if (!fixture.entry) { + lines.push(label("TRANSCRIPT"), blank(), plain("No executions yet.", C.dim), blank()); + for (const wrapped of wrapText( + "Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the bindings it published.", + width, + )) { + lines.push(plain(wrapped, C.dim)); + } + return region("transcript", rect, lines, { bg: BG.center }); + } + + lines.push(entryHeader(fixture.entry), blank()); + const body = transcriptLines(fixture.entry, width); + const capacity = Math.max(0, rect.height - lines.length); + const windowed = + mutation === "clip-long-transcript" + ? body + : body.slice(view.anchor, view.anchor + Math.max(0, capacity - 1)); + lines.push(...windowed); + if (mutation !== "clip-long-transcript") { + const remaining = body.length - view.anchor - windowed.length; + if (remaining > 0) { + lines.push(plain(`▸ ${remaining} more lines · ↑↓ PgUp PgDn`, C.dim)); + } else if (view.anchor > 0) { + lines.push(plain(`▴ ${view.anchor} earlier lines · ↑ scrolls back`, C.dim)); + } + } + return region("transcript", rect, lines, { bg: BG.center }); +} + +function sidebarRegion(fixture: Fixture, view: View, layout: Layout, rect: Rect): Op[] { + const lines: VisualLine[] = []; + const tabs = fixture.sidebar.tab; + if (!layout.dense && layout.profile === "wide") { + lines.push(plain("XMD REPL", C.out), blank()); + } + lines.push( + { + segments: [ + { text: "SESSION", color: tabs === "sessions" ? C.intro : C.label, width: 10 }, + { text: "JOURNAL", color: tabs === "journal" ? C.intro : C.label, width: 10 }, + { text: "STATE", color: tabs === "state" ? C.intro : C.label, width: 8 }, + ], + }, + blank(), + ); + + if (fixture.sidebar.heading !== undefined) { + lines.push(label(fixture.sidebar.heading)); + } + if (fixture.sidebar.subheading !== undefined && !layout.dense) { + lines.push(plain(fixture.sidebar.subheading, C.dim)); + } + lines.push(blank()); + + if (tabs === "journal") { + const selected = view.checkpoint; + fixture.history.checkpoints.forEach((point, index) => { + const on = index === selected; + const later = selected >= 0 && index > selected; + lines.push({ + segments: [ + { text: clock(point.at), color: on ? C.gold : C.dim, width: 6 }, + { + text: point.kind === "entry" ? "◆" : "●", + color: on ? C.gold : later ? C.dim : C.active, + width: 2, + }, + { text: point.label, color: on ? C.out : later ? C.dim : C.src }, + ], + }); + }); + lines.push(blank(), plain("▸ 26 internal records", C.dim)); + const chosen = fixture.history.checkpoints[selected]; + if (chosen) { + lines.push( + blank(), + label("SELECTED CHECKPOINT"), + plain(chosen.label, C.out), + plain(`${clock(chosen.at)} elapsed · ${chosen.scope}`, C.dim), + ); + for (const record of chosen.records) { + lines.push(plain(`· ${record}`, C.dim)); + } + } + return region("sidebar", rect, lines, { bg: BG.side }); + } + + if (fixture.sessions.length === 0) { + for (const placeholder of fixture.sidebar.placeholder ?? []) { + for (const wrapped of wrapText(placeholder, Math.max(1, rect.width - 2))) { + lines.push(plain(wrapped, C.dim)); + } + } + return region("sidebar", rect, lines, { bg: BG.side }); + } + + for (const session of fixture.sessions) { + lines.push({ + segments: [ + { text: session.selected ? "│ " : " ", color: C.active, width: 2 }, + { text: session.id, color: session.selected ? C.out : C.name }, + ], + }); + lines.push({ + segments: [ + { text: " ", width: 2 }, + { + text: session.label, + color: + session.state === "active" ? C.active : session.state === "completed" ? C.tick : C.dim, + width: 14, + }, + { text: layout.dense ? session.agent : `${session.agent} · ${session.turn}`, color: C.dim }, + ], + }); + if (session.note !== undefined && !layout.dense) { + lines.push(plain(` ${session.note}`, C.dim)); + } + lines.push(blank()); + } + return region("sidebar", rect, lines, { bg: BG.side }); +} + +function bindingsRegion(fixture: Fixture, layout: Layout, rect: Rect): Op[] { + const lines: VisualLine[] = [ + label("BINDINGS"), + plain(fixture.bindings.scopeName, C.dim), + blank(), + ]; + if (fixture.bindings.bindings.length === 0) { + for (const placeholder of fixture.bindings.placeholder ?? []) { + for (const wrapped of wrapText(placeholder, Math.max(1, rect.width - 2))) { + lines.push(plain(wrapped, C.dim)); + } + } + return region("bindings", rect, lines, { bg: BG.bind }); + } + for (const binding of fixture.bindings.bindings) { + lines.push(plain(binding.name, C.name)); + if (binding.note !== undefined && !layout.dense) { + lines.push(plain(binding.note, C.dim)); + } + for (const value of binding.lines) { + lines.push(plain(value, C.settledText)); + } + lines.push(blank()); + } + return region("bindings", rect, lines, { bg: BG.bind }); +} + +function contextualRegion(fixture: Fixture, view: View, layout: Layout, rect: Rect): Op[] { + const width = Math.max(0, rect.width - 2); + if (fixture.drawer && view.drawerOpen) { + const drawer = fixture.drawer; + const lines: VisualLine[] = [plain(drawer.heading, C.hold)]; + // Which suspended request this is answering is never dropped: a drawer + // without its origin is a form with no idea what it belongs to. + for (const wrapped of wrapText(drawer.origin, width)) { + lines.push(plain(wrapped, C.dim)); + } + lines.push(blank()); + if (drawer.kind === "project") { + for (const wrapped of wrapText(drawer.prompt, width)) { + lines.push(plain(wrapped, C.src)); + } + lines.push(blank()); + for (const field of drawer.fields) { + lines.push(label(field.label)); + lines.push({ + segments: [ + { text: "┃ ", color: C.rule, width: 2 }, + { text: field.value, color: C.out }, + ], + }); + } + lines.push(blank(), { + segments: [ + { text: drawer.validation, color: C.dim, width: Math.min(width, 20) }, + { text: drawer.submit, color: C.tick }, + ], + }); + if (!layout.dense) { + lines.push(blank(), label("schema")); + for (const schema of drawer.schema) { + lines.push(plain(schema, C.settledText)); + } + } + } + if (drawer.kind === "review") { + for (const planLine of drawer.plan) { + lines.push(plain(planLine, C.src)); + } + lines.push(plain(drawer.more, C.dim), blank()); + for (const decision of drawer.decisions) { + lines.push({ + segments: [ + { + text: decision.chosen ? "(•) " : "( ) ", + color: decision.chosen ? C.tick : C.label, + width: 4, + }, + { text: decision.label, color: decision.chosen ? C.out : C.src }, + ], + }); + } + lines.push(blank(), plain(drawer.submit, C.tick)); + } + if (drawer.kind === "confirm") { + for (const wrapped of wrapText(drawer.prompt, width)) { + lines.push(plain(wrapped, C.src)); + } + for (const preview of drawer.preview) { + lines.push({ + segments: [ + { text: "│ ", color: C.rule, width: 2 }, + { text: preview, color: C.src }, + ], + }); + } + lines.push(blank(), { + segments: drawer.actions.map((action) => ({ + text: `[ ${action.label} ]`, + color: action.primary ? C.tick : C.label, + width: action.label.length + 6, + })), + }); + lines.push(plain(drawer.hint, C.dim)); + } + return region("contextual", rect, lines, { bg: BG.drawer }); + } + + const input = fixture.input; + const lines: VisualLine[] = [ + { + segments: [ + { text: input.label, color: C.label, width: Math.min(width, 18) }, + { text: input.hint, color: input.runEnabled ? C.dim : C.hold }, + { + text: input.runEnabled ? "[ Run ⌘⏎ ]" : "[ Run ]", + color: input.runEnabled ? C.tick : C.dim, + width: 12, + }, + ], + }, + plain(input.placeholder ?? "", C.settledText), + ]; + return region("contextual", rect, lines, { bg: BG.input }); +} + +function clock(seconds: number): string { + const minutes = Math.floor(Math.max(0, seconds) / 60); + const rest = Math.floor(Math.max(0, seconds) % 60); + return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; +} + +export interface Transport { + readonly word: string; + readonly color: number; + readonly controls: readonly string[]; +} + +function transportFor(fixture: Fixture, dense: boolean): Transport { + const mode = fixture.history.transport; + if (mode === "live") { + return { word: "LIVE", color: C.active, controls: ["Pause"] }; + } + if (mode === "paused") { + return { + word: "PAUSED", + color: C.dim, + controls: ["Continue", dense ? "Return" : "Return to paused head"], + }; + } + if (mode === "inspecting") { + return { + word: dense ? "INSPECTING" : "INSPECTING HISTORY", + color: C.gold, + controls: [ + "Continue", + dense ? "Return" : "Return to paused head", + dense ? "Fork" : "Fork from here", + ], + }; + } + return { word: "IDLE", color: C.dim, controls: ["Pause"] }; +} + +/** What one column of the band is carrying. */ +export interface Notch { + readonly column: number; + readonly checkpoints: readonly Checkpoint[]; +} + +/** + * Which checkpoints share which column. + * + * Markers that would land on the same column are gathered rather than drawn + * over one another, because a marker drawn over its neighbour is a checkpoint + * the band is silently not showing. The gathered count is what the band draws, + * and the scrubber still steps through every checkpoint behind it. + */ +export function notchLayout( + history: Fixture["history"], + trackLeft: number, + trackWidth: number, + mutation?: Mutation, +): Notch[] { + if (mutation === "clip-long-transcript") { + return history.checkpoints.map((checkpoint) => ({ + column: columnFor(checkpoint.at, history, trackLeft, trackWidth), + checkpoints: [checkpoint], + })); + } + const columns = new Map(); + for (const checkpoint of history.checkpoints) { + const column = columnFor(checkpoint.at, history, trackLeft, trackWidth); + const bucket = columns.get(column) ?? []; + bucket.push(checkpoint); + columns.set(column, bucket); + } + return [...columns.entries()] + .map(([column, checkpoints]) => ({ column, checkpoints })) + .toSorted((one, other) => one.column - other.column); +} + +export function columnFor( + at: number, + history: Fixture["history"], + trackLeft: number, + trackWidth: number, +): number { + const span = 72; + const end = Math.max(history.headAt, 6); + const start = Math.max(0, end - span); + const ratio = (at - start) / Math.max(1, end - start); + return trackLeft + Math.min(trackWidth - 1, Math.max(0, Math.round(ratio * (trackWidth - 1)))); +} + +export interface BandGeometry { + readonly transport: Transport; + readonly right: string; + readonly inner: number; + readonly labelWidth: number; + readonly trackLeft: number; + readonly trackWidth: number; +} + +/** + * How much of the band the track actually gets. + * + * The study's own rule is that the track yields room to exactly the transport + * controls that are visible in that mode, so inspecting history — which shows + * three controls — leaves a much shorter track than running does. The head's + * label sits just right of the head, so it is reserved too. + */ +export function bandGeometry(fixture: Fixture, layout: Layout, rect: Rect): BandGeometry { + const transport = transportFor(fixture, layout.dense || layout.profile === "narrow"); + const controls = transport.controls.map((control) => `[ ${control} ]`).join(" "); + const right = `${transport.word} ${controls}`; + const inner = Math.max(0, rect.width - 2); + // A narrow band spends its columns on the track instead of on a label the + // surface bar above it already carries. + const labelWidth = + layout.profile === "narrow" ? 10 : Math.min(Math.max(18, Math.round(rect.width * 0.12)), 26); + const headLabelRoom = fixture.history.transport === "live" ? 8 : 15; + const rightReserve = Math.min(inner - labelWidth - 4, [...right].length + 2 + headLabelRoom); + return { + transport, + right, + inner, + labelWidth, + trackLeft: labelWidth, + trackWidth: Math.max(1, inner - labelWidth - rightReserve), + }; +} + +/** + * The Execution History band. + * + * Four extents distinguish what sits on the track, which is the study's own + * vocabulary read into cells: a minor checkpoint takes the track row, an entry + * boundary rises one row above it, the head takes three rows and carries its + * label, and a historical selection takes the whole band. Depth is a glyph tier + * rather than a fifth height — the band has four rows and cannot spend one per + * nesting level. + */ +function footerRegion( + fixture: Fixture, + view: View, + layout: Layout, + rect: Rect, + mutation?: Mutation, +): Op[] { + const history = fixture.history; + const flat = mutation === "flatten-notches"; + const { transport, right, inner, labelWidth, trackLeft, trackWidth } = bandGeometry( + fixture, + layout, + rect, + ); + + const grid: string[][] = [0, 1, 2, 3].map(() => Array.from({ length: inner }, () => " ")); + const colors: number[][] = [0, 1, 2, 3].map(() => Array.from({ length: inner }, () => C.dim)); + + const put = (row: number, column: number, glyph: string, color: number) => { + if (row < 0 || row > 3 || column < 0 || column >= inner) { + return; + } + grid[row][column] = glyph; + colors[row][column] = color; + }; + + const hasHistory = history.checkpoints.length > 0; + if (hasHistory) { + const headColumn = columnFor(history.headAt, history, trackLeft, trackWidth); + for (let column = trackLeft; column <= headColumn && column < inner; column += 1) { + put(2, column, "─", C.rule); + } + + const selected = history.checkpoints[view.checkpoint]; + const notches = notchLayout(history, trackLeft, trackWidth, mutation); + + for (const notch of notches) { + const boundary = notch.checkpoints.some((checkpoint) => checkpoint.kind === "entry"); + const deepest = Math.max(...notch.checkpoints.map((checkpoint) => checkpoint.depth)); + const later = + selected !== undefined && + notch.checkpoints.every((checkpoint) => checkpoint.at > selected.at); + const color = later ? C.dim : boundary ? C.out : C.active; + const coalesced = notch.checkpoints.length > 1; + const glyph = coalesced + ? notch.checkpoints.length < 10 + ? String(notch.checkpoints.length) + : "+" + : boundary + ? "◆" + : deepest <= 1 + ? "●" + : deepest === 2 + ? "◇" + : "·"; + put(2, notch.column, glyph, color); + if (boundary && !flat) { + put(1, notch.column, "│", color); + } + } + + if (history.compressed) { + put(2, columnFor(history.compressed.at, history, trackLeft, trackWidth), "≈", C.hold); + } + + const headColor = history.transport === "live" ? C.active : C.dim; + put(2, headColumn, "┃", headColor); + if (!flat) { + put(1, headColumn, "│", headColor); + put(0, headColumn, "│", headColor); + } + + if (selected !== undefined) { + const column = columnFor(selected.at, history, trackLeft, trackWidth); + for (const row of flat ? [2] : [0, 1, 2, 3]) { + put(row, column, "┃", C.gold); + } + } + } + + const putText = (row: number, column: number, value: string, color: number) => { + [...value].forEach((glyph, offset) => { + put(row, column + offset, glyph, color); + }); + }; + + const selected = history.checkpoints[view.checkpoint]; + + // A narrow band has no room for the study's full left labels, and truncating + // them to "EXECUTION HI…" says less than a shorter word that fits. The + // surface bar above already names the surface there. + const compact = labelWidth < 18; + putText(0, 0, fit(compact ? "HISTORY" : "EXECUTION HISTORY", labelWidth - 1), C.label); + putText( + 1, + 0, + fit( + hasHistory + ? compact + ? history.elapsed + : `recorded · ${history.elapsed}` + : "No recorded execution yet", + labelWidth - 1, + ), + C.dim, + ); + putText(2, 0, fit(fixture.entry ? fixture.entry.id : "", labelWidth - 1), C.dim); + putText(0, Math.max(0, inner - [...right].length), right, transport.color); + + if (hasHistory) { + const headColumn = columnFor(history.headAt, history, trackLeft, trackWidth); + const headLabel = + history.transport === "live" + ? "LIVE" + : history.transport === "idle" + ? "SETTLED" + : "PAUSED HEAD"; + const headColor = history.transport === "live" ? C.active : C.dim; + if (headColumn + 2 + headLabel.length < inner - [...right].length) { + putText(0, headColumn + 2, headLabel, headColor); + putText(1, headColumn + 2, clock(history.headAt), C.dim); + } + const note = + selected !== undefined + ? `${clock(selected.at)} · snapped · ${(history.headAt - selected.at).toFixed(1)}s before head` + : history.compressed + ? history.compressed.note + : "digits mark coalesced checkpoints · ←/→ visits each"; + const anchor = + selected !== undefined + ? columnFor(selected.at, history, trackLeft, trackWidth) + : history.compressed + ? columnFor(history.compressed.at, history, trackLeft, trackWidth) + : trackLeft; + // Two columns clear of the marker it describes, so the note never writes + // over the notch and shortens it. + const noteColumn = Math.max(0, Math.min(anchor + 2, inner - [...note].length)); + putText(3, noteColumn, note, selected !== undefined ? C.gold : C.dim); + } + + const lines: VisualLine[] = [0, 1, 2, 3].map((row) => ({ + segments: runsOf(grid[row], colors[row]), + })); + + // Narrow routing gives the band a whole screen. The extra rows carry the + // checkpoint list, so a marker the band had to coalesce is still readable — + // summarizing the track is only honest if the detail is somewhere. + if (rect.height > 6) { + lines.push(blank(), label("CHECKPOINTS")); + const room = rect.height - lines.length; + const listed = history.checkpoints.slice(0, Math.max(0, room - 1)); + listed.forEach((point, index) => { + const on = index === view.checkpoint; + lines.push({ + segments: [ + { text: clock(point.at), color: on ? C.gold : C.dim, width: 6 }, + { + text: + point.kind === "entry" ? "◆" : point.depth <= 1 ? "●" : point.depth === 2 ? "◇" : "·", + color: on ? C.gold : C.active, + width: 2, + }, + { text: point.label, color: on ? C.out : C.src }, + { text: point.scope, color: C.dim, width: Math.min(28, Math.max(0, rect.width - 40)) }, + ], + }); + }); + const hidden = history.checkpoints.length - listed.length; + if (hidden > 0) { + lines.push(plain(`▸ ${hidden} more checkpoints · ←/→ moves through every one`, C.dim)); + } + } + + return region("footer", rect, lines, { bg: BG.footer, padding: { left: 1, right: 1 } }); +} + +/** Keep each cell's colour when a grid row becomes segments. */ +function runsOf(glyphs: readonly string[], colors: readonly number[]): Segment[] { + const segments: Segment[] = []; + let run = ""; + let color = colors[0] ?? C.dim; + glyphs.forEach((glyph, index) => { + const at = colors[index] ?? C.dim; + if (at !== color && run !== "") { + segments.push({ text: run, color, width: [...run].length }); + run = ""; + } + color = at; + run += glyph; + }); + if (run !== "") { + segments.push({ text: run, color, width: [...run].length }); + } + return segments; +} + +function headerRegion(fixture: Fixture, rect: Rect): Op[] { + const lines: VisualLine[] = [ + { + segments: [ + { text: fixture.crumb, color: C.label }, + ...(fixture.badge === undefined + ? [] + : [{ text: fixture.badge, color: C.gold, width: [...fixture.badge].length + 2 }]), + ], + }, + blank(), + ]; + return region("header", rect, lines, { bg: BG.center }); +} + +function surfaceBarRegion(fixture: Fixture, view: View, rect: Rect): Op[] { + const names = { + sessions: "SESSIONS", + transcript: "TRANSCRIPT", + bindings: "BINDINGS", + history: "EXECUTION HISTORY", + }; + const at = ["sessions", "transcript", "bindings", "history"].indexOf(view.surface) + 1; + return region( + "surface-bar", + rect, + [ + { + segments: [ + { text: `${names[view.surface]} · ${at} / 4`, color: C.intro, width: 27 }, + { + text: fixture.badge ?? fixture.crumb, + color: fixture.badge === undefined ? C.dim : C.gold, + }, + { text: "Tab ▸", color: C.label, width: 7 }, + ], + }, + ], + { bg: BG.side }, + ); +} + +function tooSmallRegion(layout: Layout): Op[] { + const lines: VisualLine[] = [ + plain("Terminal too small", C.out), + plain( + `${MINIMUM.cols} × ${MINIMUM.rows} required · ${layout.cols} × ${layout.rows} now`, + C.hold, + ), + plain("resize to continue", C.dim), + ]; + return region("too-small", layout.screen, lines, { + bg: BG.app, + padding: { left: 1, right: 1, top: 1 }, + }); +} + +export interface ScreenRequest { + readonly fixture: Fixture; + readonly view: View; + readonly layout: Layout; + readonly mutation?: Mutation; +} + +export function renderScreen(request: ScreenRequest): Op[] { + const { fixture, view, layout, mutation } = request; + const ops: Op[] = [ + open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: BG.app }), + ]; + + if (layout.profile === "too-small") { + ops.push(...tooSmallRegion(layout), close()); + return ops; + } + + if (layout.surfaceBar) { + ops.push(...surfaceBarRegion(fixture, view, layout.surfaceBar)); + } + if (layout.header) { + ops.push(...headerRegion(fixture, layout.header)); + } + if (layout.sidebar) { + ops.push(...sidebarRegion(fixture, view, layout, layout.sidebar)); + } + if (layout.transcript) { + ops.push(...transcriptRegion(fixture, view, layout.transcript, mutation)); + } + if (layout.bindings) { + ops.push(...bindingsRegion(fixture, layout, layout.bindings)); + } + const covering = + mutation === "drawer-covers-footer" && layout.footer !== undefined && view.drawerOpen; + if (layout.contextual && !covering) { + ops.push(...contextualRegion(fixture, view, layout, layout.contextual)); + } + if (layout.footer) { + ops.push(...footerRegion(fixture, view, layout, layout.footer, mutation)); + } + if (layout.contextual && covering) { + // Drawn last, so it lands on top of the band the study says is never + // covered — which is the point of this control. + ops.push( + ...contextualRegion(fixture, view, layout, { + ...layout.contextual, + height: layout.contextual.height + layout.footer!.height, + }), + ); + } + for (const [index, separator] of layout.separators.entries()) { + ops.push(...rule(`rule.${index}`, separator, separator.width === 1 ? "│" : "─")); + } + ops.push(close()); + return ops; +} diff --git a/scripts/repl-study/screen.ts b/scripts/repl-study/screen.ts new file mode 100644 index 000000000..b1a0bd241 --- /dev/null +++ b/scripts/repl-study/screen.ts @@ -0,0 +1,138 @@ +/** + * The terminal's own memory, so a frame can be read back. + * + * `@bomb.sh/tty` emits only the cells that changed since the previous frame, + * which is what makes it cheap and also what makes staleness invisible: a + * renderer that forgot to redraw something emits nothing for it, and nothing is + * indistinguishable from correct until you look at the screen. Applying the + * bytes to a grid here is how the evidence looks at the screen. + * + * The vocabulary is deliberately tiny — cursor addressing, colour, and text are + * everything 0.9.0 emits — and anything else is refused rather than ignored, so + * a future version that starts scrolling or erasing cannot slip past unnoticed. + */ + +export class UnsupportedSequenceError extends Error { + readonly sequence: string; + + constructor(sequence: string) { + super(`the harness does not model the terminal sequence ${JSON.stringify(sequence)}`); + this.name = "UnsupportedSequenceError"; + this.sequence = sequence; + } +} + +export interface Grid { + readonly cols: number; + readonly rows: number; + readonly cells: string[][]; + row: number; + column: number; + /** + * Glyphs addressed at cells this terminal does not have. + * + * A real terminal does not discard them: it clamps or wraps them, and what + * the person sees is corruption. Counting them is how a renderer still + * drawing at the previous size is caught. + */ + overflow: number; +} + +export function createGrid(cols: number, rows: number): Grid { + return { + cols, + rows, + cells: Array.from({ length: rows }, () => Array.from({ length: cols }, () => " ")), + row: 0, + column: 0, + overflow: 0, + }; +} + +// The escape byte is the thing being parsed here, so matching a control +// character is the point rather than an accident. +// oxlint-disable-next-line no-control-regex +const CSI = /^\u001b\[([0-9;]*)([A-Za-z])/; + +export function applyAnsi(grid: Grid, bytes: Uint8Array): Grid { + const stream = new TextDecoder().decode(bytes); + let at = 0; + while (at < stream.length) { + const glyph = stream[at]; + if (glyph === "\u001b") { + const match = CSI.exec(stream.slice(at)); + if (!match) { + throw new UnsupportedSequenceError(stream.slice(at, at + 8)); + } + const [sequence, parameters, final] = match; + if (final === "H") { + const [row, column] = parameters.split(";"); + grid.row = (Number(row) || 1) - 1; + grid.column = (Number(column) || 1) - 1; + if (grid.row >= grid.rows || grid.column >= grid.cols) { + grid.overflow += 1; + } + } else if (final !== "m") { + throw new UnsupportedSequenceError(sequence); + } + at += sequence.length; + continue; + } + if (glyph === "\n") { + grid.row += 1; + grid.column = 0; + at += 1; + continue; + } + if (grid.row >= 0 && grid.row < grid.rows && grid.column >= 0 && grid.column < grid.cols) { + grid.cells[grid.row][grid.column] = glyph; + } else if (glyph !== " ") { + grid.overflow += 1; + } + grid.column += 1; + at += 1; + } + return grid; +} + +/** + * The part of a larger grid a smaller terminal can still see. + * + * After a terminal shrinks, whatever sits beyond its new edges is out of view + * and no longer anyone's business. What is inside those edges is, which is the + * region a stale cell would be found in. + */ +export function viewport(grid: Grid, cols: number, rows: number): Grid { + return { + cols, + rows, + cells: Array.from({ length: rows }, (_unused, row) => + Array.from({ length: cols }, (_also, column) => grid.cells[row]?.[column] ?? " "), + ), + row: 0, + column: 0, + overflow: grid.overflow, + }; +} + +/** The grid as a person reads it: one line per row, trailing blanks removed. */ +export function gridText(grid: Grid): string { + return grid.cells.map((row) => row.join("").replace(/ +$/, "")).join("\n"); +} + +/** Where two grids differ, named by cell, for a failure that has to be readable. */ +export function gridDifferences(one: Grid, other: Grid): string[] { + const differences: string[] = []; + const rows = Math.max(one.rows, other.rows); + const cols = Math.max(one.cols, other.cols); + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < cols; column += 1) { + const left = one.cells[row]?.[column] ?? ""; + const right = other.cells[row]?.[column] ?? ""; + if (left !== right) { + differences.push(`${row},${column}: ${JSON.stringify(left)} ≠ ${JSON.stringify(right)}`); + } + } + } + return differences; +} diff --git a/scripts/repl-study/view.ts b/scripts/repl-study/view.ts new file mode 100644 index 000000000..65aabb900 --- /dev/null +++ b/scripts/repl-study/view.ts @@ -0,0 +1,78 @@ +/** + * What the person looking at the harness has chosen. + * + * The renderer clips; it does not scroll. So the window over a long transcript, + * the selected checkpoint, and which surface narrow routing is showing are the + * application's to own — and they survive every resize, which is the property + * #838 asks a profile transition to preserve. + */ + +import type { FixtureName } from "./model.ts"; +import type { Fixture } from "./model.ts"; +import type { SurfaceName } from "./layout.ts"; +import { SURFACES } from "./layout.ts"; + +export interface View { + readonly fixture: FixtureName; + /** Index of the first visible transcript line. */ + readonly anchor: number; + /** Index into the fixture's checkpoints, or -1 for "following the head". */ + readonly checkpoint: number; + readonly surface: SurfaceName; + readonly drawerOpen: boolean; +} + +export function initialView(fixture: Fixture): View { + const selected = fixture.history.selectedAt; + const checkpoint = + selected === undefined + ? -1 + : fixture.history.checkpoints.findIndex((point) => point.at === selected); + return { + fixture: fixture.name, + anchor: 0, + checkpoint, + surface: "transcript", + drawerOpen: fixture.drawer !== undefined, + }; +} + +export function scrollBy(view: View, delta: number, limit: number): View { + const anchor = Math.max(0, Math.min(limit, view.anchor + delta)); + return anchor === view.anchor ? view : { ...view, anchor }; +} + +/** + * Move the selection one checkpoint at a time. + * + * Navigation runs over the checkpoint list rather than over the columns the band + * drew, so a marker that had to share a column with its neighbour is still + * reachable — which is the whole reason the band is allowed to summarize. + */ +export function scrubBy(view: View, delta: number, count: number): View { + if (count === 0) { + return view; + } + const from = view.checkpoint === -1 ? count : view.checkpoint; + const checkpoint = Math.max(0, Math.min(count - 1, from + delta)); + return checkpoint === view.checkpoint ? view : { ...view, checkpoint }; +} + +/** Return to the head, abandoning a historical selection. */ +export function returnToHead(view: View): View { + return view.checkpoint === -1 ? view : { ...view, checkpoint: -1 }; +} + +export function moveSurface(view: View, delta: number): View { + const at = SURFACES.indexOf(view.surface); + const next = SURFACES[(at + delta + SURFACES.length) % SURFACES.length]; + return { ...view, surface: next }; +} + +export function showSurface(view: View, surface: SurfaceName): View { + return view.surface === surface ? view : { ...view, surface }; +} + +export function toggleDrawer(view: View): View { + return { ...view, drawerOpen: !view.drawerOpen }; +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 9607be6d0..da21d0e9d 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -35,6 +35,12 @@ export interface RuntimeExclusion { const DERIVED_SCOPE = "https://github.com/taras/executable.md/issues/144"; const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ + { + path: "scripts/tests/repl-study.test.ts", + reason: + "its subject is the Deno terminal harness in scripts/repl-study: the host reads Deno.consoleSize(), sets Deno.stdin raw mode, installs Deno signal listeners, and the restoration cases run `deno run` as a child. A Node or Bun shard has no `deno` on PATH and no equivalent of the host it is testing", + issue: "https://github.com/taras/executable.md/issues/838", + }, { path: "scripts/tests/build-npm.test.ts", reason: diff --git a/scripts/tests/fixtures/repl-study/drawer.medium.txt b/scripts/tests/fixtures/repl-study/drawer.medium.txt new file mode 100644 index 000000000..fbe75793b --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.medium.txt @@ -0,0 +1,39 @@ +drawer.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL › Entry 1 › document · suspended + │ + SESSIONS · 3 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + │ plan-a91f7c │ │ document scope + ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + review-b72e1d │ ▶ ENTER │ # Northstar + ● responding reviewer │ │ ▾ Ask for the project details │ + │ │ ● WAITING │ + implement-c31d2e │ │ │ Enter the project details. │ + · queued implementer │ │ ● WAITING │ + │ │ ▲ suspended · answer in the drawer below │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ + │ + EXECUTION HISTORY │ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ 00:49 + Entry 1 ────◆─────●────────────◇───────────·────────────────────·─·────────≈───────────·──────────●───┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt new file mode 100644 index 000000000..1be6a32de --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt @@ -0,0 +1,29 @@ +drawer.narrow.sessions · 90 × 28 + SESSIONS · 1 / 4 REPL › Entry 1 › document · suspended Tab ▸ + SESSION JOURNAL STATE + + SESSIONS · 3 + + │ plan-a91f7c + ✓ completed planner + + review-b72e1d + ● responding reviewer + + implement-c31d2e + · queued implementer + + + + + + + + + + + + + + + diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.txt new file mode 100644 index 000000000..1a9e02d7b --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.txt @@ -0,0 +1,29 @@ +drawer.narrow · 90 × 28 + INPUT REQUIRED + suspended at · document scope · validated against the Elicit + schema + + Enter the project details. + + Project name + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. + + both fields valid Submit ⌘↵ + + + + + + + + + + + + + + + + diff --git a/scripts/tests/fixtures/repl-study/drawer.too-small.txt b/scripts/tests/fixtures/repl-study/drawer.too-small.txt new file mode 100644 index 000000000..46a6ed0c3 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.too-small.txt @@ -0,0 +1,19 @@ +drawer.too-small · 64 × 18 + + Terminal too small + 72 × 20 required · 64 × 18 now + resize to continue + + + + + + + + + + + + + + diff --git a/scripts/tests/fixtures/repl-study/drawer.wide.txt b/scripts/tests/fixtures/repl-study/drawer.wide.txt new file mode 100644 index 000000000..187319fa8 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/drawer.wide.txt @@ -0,0 +1,51 @@ +drawer.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ 00:49 + Entry 1 ──────◆────────●────────────────────◇─────────────────·─────────────────────────────────·──·──────────────≈─────────────────·─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/empty.medium.txt b/scripts/tests/fixtures/repl-study/empty.medium.txt new file mode 100644 index 000000000..1b0ad45de --- /dev/null +++ b/scripts/tests/fixtures/repl-study/empty.medium.txt @@ -0,0 +1,39 @@ +empty.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL + │ + No sessions yet │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ TRANSCRIPT │ BINDINGS + Agent sessions appear here as │ │ REPL scope + executions open them. │ No executions yet. │ + They persist after an entry │ │ No REPL bindings yet + settles. │ Submitted blocks append here as immutable entries. Each entry keeps its │ Values named with as + │ source, its rendered output, and the bindings it published. │ appear here for the + │ │ active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded exec… + + diff --git a/scripts/tests/fixtures/repl-study/empty.narrow.txt b/scripts/tests/fixtures/repl-study/empty.narrow.txt new file mode 100644 index 000000000..e596ebf1d --- /dev/null +++ b/scripts/tests/fixtures/repl-study/empty.narrow.txt @@ -0,0 +1,29 @@ +empty.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL Tab ▸ + TRANSCRIPT + + No executions yet. + + Submitted blocks append here as immutable entries. Each entry keeps its source, its + rendered output, and the bindings it published. + + + + + + + + + + + + + + + + + + + REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + Enter XMD or invoke a document… + diff --git a/scripts/tests/fixtures/repl-study/empty.wide.txt b/scripts/tests/fixtures/repl-study/empty.wide.txt new file mode 100644 index 000000000..68a62878b --- /dev/null +++ b/scripts/tests/fixtures/repl-study/empty.wide.txt @@ -0,0 +1,51 @@ +empty.wide · 200 × 50 + XMD REPL │ REPL + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ TRANSCRIPT │ BINDINGS + No sessions yet │ │ REPL scope + │ No executions yet. │ + Agent sessions appear here as executions open │ │ No REPL bindings yet + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the │ Values named with as appear + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … + + diff --git a/scripts/tests/fixtures/repl-study/generated.medium.txt b/scripts/tests/fixtures/repl-study/generated.medium.txt new file mode 100644 index 000000000..dddb3e7c4 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/generated.medium.txt @@ -0,0 +1,39 @@ +generated.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL › Entry 1 › document · active + │ + SESSIONS · 2 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS + │ plan-a91f7c │ │ document scope + ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ admitted + review-b72e1d │ ▶ ENTER │ # Create a project READ… + ● responding reviewer │ │ Create a project README │ + │ │ ◀ EXIT │ + │ │ │ ✓ Admit the approved Plan · admitted │ + │ │ ◀ EXIT │ + │ │ │ XMD │ + │ │ │ # Create a project README │ + │ │ │ │ + │ │ │ Provide the project name and a one-sentence description. │ + │ │ │ │ + │ │ │ │ + │ │ │ Enter the project details. │ + │ │ │ │ + │ │ │ │ + │ │ │ This is the README that will be created: │ + │ │ │ │ + │ │ │ │ + │ │ │ returned program · 59 lines · replaces the Plan expression, then evalua… │ + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. │ + │ │ ▶ ENTER │ + │ │ │ Enter the project details. │ + │ ▸ 1 more lines · ↑↓ PgUp PgDn │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ LIVE LIVE [ Pause ] + recorded · 00:48 │ │ 00:48 + Entry 1 ────◆─────●─────────────◇──────────·─────────────────────·─·─────────≈──────────·───────────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/generated.narrow.txt b/scripts/tests/fixtures/repl-study/generated.narrow.txt new file mode 100644 index 000000000..f00fae617 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/generated.narrow.txt @@ -0,0 +1,29 @@ +generated.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL › Entry 1 › document · active Tab ▸ + Entry 1 ● running · 48.1s ↳ document scope open + + ← opened from Entry 1 · live execution projection, not an editor + document + ▶ ENTER + │ Create a project README + │ ◀ EXIT + │ │ ✓ Admit the approved Plan · admitted + │ ◀ EXIT + │ │ XMD + │ │ # Create a project README + │ │ + │ │ Provide the project name and a one-sentence description. + │ │ + │ │ + │ │ Enter the project details. + │ │ + │ │ + │ │ This is the README that will be created: + │ │ + │ │ + │ │ returned program · 59 lines · replaces the Plan expression, then evaluates here + │ Create a project README + ▸ 4 more lines · ↑↓ PgUp PgDn + DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + + diff --git a/scripts/tests/fixtures/repl-study/generated.wide.txt b/scripts/tests/fixtures/repl-study/generated.wide.txt new file mode 100644 index 000000000..e4789eafe --- /dev/null +++ b/scripts/tests/fixtures/repl-study/generated.wide.txt @@ -0,0 +1,51 @@ +generated.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · active + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS + SESSIONS · 2 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ admitted + │ plan-a91f7c │ ▶ ENTER │ XMD source · 59 lines · sealed + ✓ completed planner · turn 1 · returned 5… │ │ Create a project README │ # Create a project README + │ │ ◀ EXIT │ + review-b72e1d │ │ │ ✓ Admit the approved Plan · admitted │ + ● responding reviewer · turn 1 · streaming │ │ ◀ EXIT │ + streaming · background update · selection u… │ │ │ XMD │ + │ │ │ # Create a project README │ + │ │ │ │ + │ │ │ Provide the project name and a one-sentence description. │ + │ │ │ │ + │ │ │ │ + │ │ │ Enter the project details. │ + │ │ │ │ + │ │ │ │ + │ │ │ This is the README that will be created: │ + │ │ │ │ + │ │ │ │ + │ │ │ returned program · 59 lines · replaces the Plan expression, then evaluates here │ + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. │ + │ │ ▶ ENTER │ + │ │ │ Enter the project details. │ + │ │ ▶ ENTER │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ LIVE LIVE [ Pause ] + recorded · 00:48 │ │ 00:48 + Entry 1 ──────◆────────●─────────────────────◇──────────────────·────────────────────────────────·───·──────────────≈─────────────────·──────────────────●──┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/nested.medium.txt b/scripts/tests/fixtures/repl-study/nested.medium.txt new file mode 100644 index 000000000..c01e552f5 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.medium.txt @@ -0,0 +1,39 @@ +nested.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL › Entry 1 › document › Plan · active + │ + SESSIONS · 1 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + │ plan-a91f7c │ │ Plan scope + ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ prompt + │ repl:entry-1 · submitted source is immutable while running │ "Create an XMD program … + │ ▶ ENTER │ project name and a desc… + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan │ syntax + │ │ component drafts the program that asks for them, reviews its own draft, │ XMD catalog · 47 symbols + │ │ and returns it for admission into this document scope. │ component, control, age… + │ │ ● ACTIVE │ + │ │ │ ✓ Read the Prompt · prompt │ inputs + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ { + │ │ │ ✓ Create the first draft · draft │ surface: "component", + │ │ │ ▾ Check the draft │ session: "plan-a91f7c… + │ │ │ ● ACTIVE │ budget: 3 + │ │ │ │ ✓ SETTLED │ } + │ │ │ ● WAITING │ + │ │ │ │ Review the generated Plan and choose Approve, Request changes or │ draft + │ │ │ │ Stop. │ # Create a project READ… + │ │ │ │ The reviewer has the draft, the schema it was checked against, and │ SETTLED │ + │ │ │ │ ✓ SETTLED │ + │ │ │ ● WAITING │ + │ ▸ 3 more lines · ↑↓ PgUp PgDn │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ 00:31 + Entry 1 ──────◆────────●────────────────────◇──────────────────·────────────────────────────────·──·──┃ + digits mark coalesced checkpoints · ←/→ visits each diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt new file mode 100644 index 000000000..2425e9ea7 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt @@ -0,0 +1,29 @@ +nested.narrow.bindings · 90 × 28 + BINDINGS · 3 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ + BINDINGS + Plan scope + + prompt + "Create an XMD program that asks me for a + project name and a description…" + + syntax + XMD catalog · 47 symbols + component, control, agent, io + + inputs + { + surface: "component", + session: "plan-a91f7c", + budget: 3 + } + + draft + # Create a project README + + + + + + + diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.txt b/scripts/tests/fixtures/repl-study/nested.narrow.txt new file mode 100644 index 000000000..b5d9e1ec9 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.narrow.txt @@ -0,0 +1,29 @@ +nested.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ + Entry 1 ● running · 31.4s ↳ Plan scope open + + ← opened from Entry 1 · live execution projection, not an editor + document + repl:entry-1 · submitted source is immutable while running + ▶ ENTER + │ Create a project README + │ Provide the project name and a one-sentence description. The Plan component drafts the + │ program that asks for them, reviews its own draft, and returns it for admission into + │ this document scope. + │ ● ACTIVE + │ │ ✓ Read the Prompt · prompt + │ │ ✓ Prepare the planning inputs · syntax, inputs + │ │ ✓ Create the first draft · draft + │ │ ▾ Check the draft + │ │ ● ACTIVE + │ │ │ ✓ SETTLED + │ │ ● WAITING + │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. + │ │ │ The reviewer has the draft, the schema it was checked against, and the + │ │ │ capabilities the document would be granted if the Plan is admitted. Nothing it + │ │ │ returns runs until this scope admits it. + │ │ │ ✓ SETTLED + ▸ 5 more lines · ↑↓ PgUp PgDn + DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + + diff --git a/scripts/tests/fixtures/repl-study/nested.wide.txt b/scripts/tests/fixtures/repl-study/nested.wide.txt new file mode 100644 index 000000000..0a4cabfea --- /dev/null +++ b/scripts/tests/fixtures/repl-study/nested.wide.txt @@ -0,0 +1,51 @@ +nested.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan · active + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + SESSIONS · 1 │ │ Plan scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ prompt + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running │ "Create an XMD program that a… + ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER │ project name and a descriptio… + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ ACTIVE │ "Create an XMD program … + 00:29 ● planning Agent response… │ │ ✓ Read the Prompt · prompt │ + 00:30 ● draft checked │ │ ▾ Prepare the planning inputs │ + 00:41 ● review returned Approve │ │ ✓ SETTLED │ + 00:47 ● Plan replaced by return… │ │ ● ACTIVE │ + 00:49 ● project Elicit requested │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit req… │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › docum… │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY ┃ │ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] + recorded · 00:53 │ ┃ │ 00:53 + Entry 1 ──◆──●───────┃──────·───────────··────≈──────·─────●──●──●┃ + ┃ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.history.txt b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt new file mode 100644 index 000000000..8aefe524c --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt @@ -0,0 +1,29 @@ +paused.narrow.history · 90 × 28 + EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ + HISTORY ┃ │ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] + 00:53 │ ┃ │ 00:53 + Entry 1 ─◆●─┃·───2─≈·─●●┃ + ┃ 00:12 · snapped · 41.0s before head + + CHECKPOINTS + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document + 00:12 ◇ Plan entered Entry 1 › document › Plan + 00:18 · planning inputs prepared … › Plan › PlanInputs + 00:29 · planning Agent response admitted … › Plan › Prompt + 00:30 · draft checked … › Plan › Check + 00:41 · review returned Approve … › Plan › Elicit + 00:47 ● Plan replaced by returned program Entry 1 › document + 00:49 ● project Elicit requested Entry 1 › document + 00:52 ● project Elicit answered Entry 1 › document + 00:53 ● confirmation Elicit requested Entry 1 › document + + + + + + + + + + diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.txt b/scripts/tests/fixtures/repl-study/paused.narrow.txt new file mode 100644 index 000000000..5cc7cdace --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.narrow.txt @@ -0,0 +1,29 @@ +paused.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ + Entry 1 ● running · 53.0s ↳ reconstructed · read-only + + reconstructed from the journal · no live action is possible here + Plan + ● ACTIVE + │ ✓ Read the Prompt · prompt + │ ▾ Prepare the planning inputs + │ ✓ SETTLED + │ ● ACTIVE + │ XMD catalog · 47 symbols · component, control, agent, io + + + + + + + + + + + + + + + DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + + diff --git a/scripts/tests/fixtures/repl-study/paused.too-small.txt b/scripts/tests/fixtures/repl-study/paused.too-small.txt new file mode 100644 index 000000000..89987e9b3 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.too-small.txt @@ -0,0 +1,19 @@ +paused.too-small · 64 × 18 + + Terminal too small + 72 × 20 required · 64 × 18 now + resize to continue + + + + + + + + + + + + + + diff --git a/scripts/tests/fixtures/repl-study/paused.wide.txt b/scripts/tests/fixtures/repl-study/paused.wide.txt new file mode 100644 index 000000000..6ff020659 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/paused.wide.txt @@ -0,0 +1,51 @@ +paused.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS + ENTRY 1 · CREATE PROJECT README │ │ Plan scope · as recorded + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here │ + │ Plan │ prompt + 00:02 ◆ Entry 1 submitted │ ● ACTIVE │ "Create an XMD program that a… + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt │ + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs │ + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › document › Plan │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY ┃ │ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] + recorded · 00:53 │ ┃ │ 00:53 + Entry 1 ───◆───●──────────┃────────·───────────────·─·──────≈────────·────────●──●────●┃ + ┃ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-study/settled.medium.txt b/scripts/tests/fixtures/repl-study/settled.medium.txt new file mode 100644 index 000000000..383d04c81 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/settled.medium.txt @@ -0,0 +1,39 @@ +settled.medium · 140 × 38 + SESSION JOURNAL STATE │ REPL · Entry 1 settled + │ + SESSIONS · 3 │───────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS + │ plan-a91f7c │ │ REPL scope + ✓ completed planner │ Create a project README │ + │ Provide the project name and a one-sentence description. │ Entry 1 published none + review-b72e1d │ │ MARKDOWN │ Values named with as + ● responding reviewer │ │ # Northstar │ appear here for the + │ │ │ active scope. + implement-c31d2e │ │ A lightweight workspace for coordinating coding agents. │ + · queued implementer │ │ README.md · 63 bytes · +3 lines │ + │ README.md was created for Northstar. │ + │ no REPL bindings published · 1 file written │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY │ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ 01:01 + Entry 1 ───◆───●─────────◇────────·──────────────·─·──────≈───────·────────●──●───●─●────●────●┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/settled.narrow.txt b/scripts/tests/fixtures/repl-study/settled.narrow.txt new file mode 100644 index 000000000..8e487cc20 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/settled.narrow.txt @@ -0,0 +1,29 @@ +settled.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL · Entry 1 settled Tab ▸ + Entry 1 ✓ completed · 41.2s ▸ source · 8 lines + + Create a project README + Provide the project name and a one-sentence description. + │ MARKDOWN + │ # Northstar + │ + │ A lightweight workspace for coordinating coding agents. + │ README.md · 63 bytes · +3 lines + README.md was created for Northstar. + no REPL bindings published · 1 file written + + + + + + + + + + + + + + REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + Enter XMD or invoke a document… + diff --git a/scripts/tests/fixtures/repl-study/settled.wide.txt b/scripts/tests/fixtures/repl-study/settled.wide.txt new file mode 100644 index 000000000..7c724b0e4 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/settled.wide.txt @@ -0,0 +1,51 @@ +settled.wide · 200 × 50 + XMD REPL │ REPL · Entry 1 settled + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS + SESSIONS · 3 │ │ REPL scope + persist after settling │ Create a project README │ + │ Provide the project name and a one-sentence description. │ Entry 1 published none + │ plan-a91f7c │ │ MARKDOWN │ Values named with as appear + ✓ completed planner · turn 1 · returned 5… │ │ # Northstar │ here for the active scope. + │ │ │ + review-b72e1d │ │ A lightweight workspace for coordinating coding agents. │ + ● responding reviewer · turn 1 · streaming │ │ README.md · 63 bytes · +3 lines │ + streaming · background update · selection u… │ README.md was created for Northstar. │ + │ no REPL bindings published · 1 file written │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY │ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ 01:01 + Entry 1 ─────◆──────●───────────────◇─────────────·────────────────────────·─·───────────≈─────────────·─────────────●───●──────●──●────────●──────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts new file mode 100644 index 000000000..af5b5b63f --- /dev/null +++ b/scripts/tests/repl-study.test.ts @@ -0,0 +1,619 @@ +/** + * The REPL interaction study, rendered and checked. + * + * A terminal interface is the kind of thing that only ever failed in front of a + * person: a pane that lost a row, a footer a drawer sat on top of, cells left + * behind by a resize nobody told the renderer about. `@bomb.sh/tty` does layout, + * input decoding and ANSI in pure computation with no terminal attached, so all + * of that can be asked here instead — of the same frames the interactive harness + * writes to a real terminal. + * + * Every claim has a control that breaks it. Each control is one value of the + * closed enum in `mutations.ts`, and the same oracle that admits the honest + * render has to reject it by name rather than merely crash. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { exec } from "@effectionx/process"; +import { exists, readdir, readTextFile } from "@effectionx/fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + captureAll, + PROFILE_SIZES, + renderFrame, + renderInto, + useTerm, + writeCaptures, +} from "../repl-study/capture.ts"; +import type { Size } from "../repl-study/capture.ts"; +import { fixture, fixtures } from "../repl-study/fixtures.ts"; +import { terminalModes } from "../repl-study/host.ts"; +import type { HarnessState } from "../repl-study/host.ts"; +import { intersects, layoutFor, MINIMUM, PANE_MINIMUMS, profileFor } from "../repl-study/layout.ts"; +import type { Profile } from "../repl-study/layout.ts"; +import { MUTATIONS } from "../repl-study/mutations.ts"; +import { bandGeometry, columnFor, notchLayout, transcriptLines } from "../repl-study/render.ts"; +import { + applyAnsi, + createGrid, + gridDifferences, + gridText, + viewport, +} from "../repl-study/screen.ts"; +import { initialView, scrollBy } from "../repl-study/view.ts"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const GOLDENS = fileURLToPath(new URL("./fixtures/repl-study/", import.meta.url)); +const MAIN = "scripts/repl-study/main.ts"; + +/** The band rows of a rendered frame, as a grid of glyphs. */ +function bandRows(text: string, size: Size): string[] { + const rows = text.split("\n"); + return [0, 1, 2, 3].map((offset) => rows[size.rows - 4 + offset] ?? ""); +} + +function glyphAt(row: string, column: number): string { + return [...row][column] ?? " "; +} + +/** How many band rows carry something at this column. */ +function notchHeight(rows: readonly string[], column: number): number { + return rows.filter((row) => { + const glyph = glyphAt(row, column); + return glyph !== " " && glyph !== "─"; + }).length; +} + +describe("fixture rendering", () => { + it("renders every committed capture exactly", function* () { + const captures = yield* captureAll(); + expect(captures.length).toBeGreaterThan(0); + for (const capture of captures) { + const golden = yield* readTextFile(join(GOLDENS, `${capture.name}.txt`)); + const header = `${capture.name} · ${capture.size.cols} × ${capture.size.rows}\n`; + expect(`${header}${capture.frame.text}\n`).toBe(golden); + } + }); + + it("commits a capture for every fixture at every composed profile", function* () { + const names = (yield* readdir(GOLDENS)).filter((name) => name.endsWith(".txt")); + for (const subject of fixtures()) { + for (const profile of ["wide", "medium", "narrow"]) { + expect(names).toContain(`${subject.name}.${profile}.txt`); + } + } + expect(names).toContain("drawer.too-small.txt"); + expect(names).toContain("paused.narrow.history.txt"); + }); + + it("reports no renderer errors for any fixture or profile", function* () { + for (const subject of fixtures()) { + for (const profile of ["wide", "medium", "narrow", "too-small"] as Profile[]) { + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES[profile], + }); + expect(frame.text.length).toBeGreaterThan(0); + } + } + }); + + it("rejects a frame drawn from stale state", function* () { + const subject = fixture("settled"); + const stale = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES.wide, + mutation: "stale-frame", + }); + const golden = yield* readTextFile(join(GOLDENS, "settled.wide.txt")); + const header = `settled.wide · ${PROFILE_SIZES.wide.cols} × ${PROFILE_SIZES.wide.rows}\n`; + expect(`${header}${stale.text}\n`).not.toBe(golden); + expect(stale.text).not.toContain("Entry 1 ✓ completed"); + }); +}); + +describe("layout profiles", () => { + it("chooses a profile from the measured size alone", function* () { + expect(profileFor(200, 50)).toBe("wide"); + expect(profileFor(160, 36)).toBe("wide"); + expect(profileFor(159, 36)).toBe("medium"); + expect(profileFor(160, 35)).toBe("medium"); + expect(profileFor(120, 30)).toBe("medium"); + expect(profileFor(119, 30)).toBe("narrow"); + expect(profileFor(72, 20)).toBe("narrow"); + expect(profileFor(71, 20)).toBe("too-small"); + expect(profileFor(72, 19)).toBe("too-small"); + }); + + it("keeps every composed pane wide enough to read", function* () { + for (const [cols, rows] of [ + [200, 50], + [160, 36], + [140, 38], + [120, 30], + ]) { + const layout = layoutFor({ cols, rows, drawer: false, surface: "transcript" }); + expect(layout.sidebar?.width ?? 0).toBeGreaterThanOrEqual(PANE_MINIMUMS.sidebar); + expect(layout.bindings?.width ?? 0).toBeGreaterThanOrEqual(PANE_MINIMUMS.bindings); + expect(layout.transcript?.width ?? 0).toBeGreaterThanOrEqual(PANE_MINIMUMS.transcript); + } + }); + + it("rejects the wide composition kept at narrow dimensions", function* () { + const layout = layoutFor({ + cols: 90, + rows: 28, + drawer: false, + surface: "transcript", + mutation: "shrink-wide-at-narrow", + }); + expect(layout.transcript?.width ?? 0).toBeLessThan(PANE_MINIMUMS.transcript); + }); + + it("preserves the fixture, the window and the selection across every transition", function* () { + const subject = fixture("paused"); + let state: HarnessState = { + fixture: subject, + view: { ...initialView(subject), anchor: 3, surface: "bindings" }, + cols: 200, + rows: 50, + quit: false, + }; + const before = state.view; + for (const size of [ + PROFILE_SIZES.medium, + PROFILE_SIZES.narrow, + PROFILE_SIZES["too-small"], + PROFILE_SIZES.wide, + ]) { + // `reduce` reads the size from the terminal, so the resize is applied the + // way the interactive harness applies it: as new dimensions, not as a new + // view. + state = { ...state, cols: size.cols, rows: size.rows }; + expect(state.view).toEqual(before); + const frame = yield* renderFrame({ fixture: state.fixture, view: state.view, size }); + expect(frame.text.length).toBeGreaterThan(0); + } + expect(state.view.anchor).toBe(3); + expect(state.view.checkpoint).toBe(before.checkpoint); + expect(state.view.surface).toBe("bindings"); + }); +}); + +describe("the history footer", () => { + it("stays at the bottom, full width, with a drawer open", function* () { + for (const profile of ["wide", "medium"] as Profile[]) { + const size = PROFILE_SIZES[profile]; + const subject = fixture("drawer"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: true, + surface: "transcript", + }); + expect(layout.footer).toBeDefined(); + expect(layout.footer?.y).toBe(size.rows - 4); + expect(layout.footer?.width).toBe(size.cols); + expect(layout.contextual).toBeDefined(); + expect(intersects(layout.contextual!, layout.footer!)).toBe(false); + + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + const rows = bandRows(frame.text, size); + // Visible means all of it: the label the study puts at the left, the + // transport at the right, and the track with the head on it between them. + expect(rows[0]).toContain("EXECUTION HISTORY"); + expect(rows[0]).toContain("[ Pause ]"); + expect(rows[2]).toContain("┃"); + } + }); + + it("rejects a drawer that takes the footer's rows", function* () { + const size = PROFILE_SIZES.wide; + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: true, + surface: "transcript", + }); + const covering = { + ...layout.contextual!, + height: layout.contextual!.height + layout.footer!.height, + }; + expect(intersects(covering, layout.footer!)).toBe(true); + + const subject = fixture("drawer"); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size, + mutation: "drawer-covers-footer", + }); + const rows = bandRows(frame.text, size); + expect(rows[0]).not.toContain("[ Pause ]"); + expect(rows[2]).not.toContain("┃"); + }); + + it("gives four distinguishable notch heights", function* () { + const size = PROFILE_SIZES.wide; + const subject = fixture("paused"); + const view = initialView(subject); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const frame = yield* renderFrame({ fixture: subject, view, size }); + const rows = bandRows(frame.text, size); + const offset = 1; + + const history = subject.history; + const selected = history.checkpoints[view.checkpoint]; + const boundary = history.checkpoints.find((point) => point.kind === "entry")!; + const minor = history.checkpoints.find( + (point) => + point.kind === "event" && + notchLayout(history, geometry.trackLeft, geometry.trackWidth).find( + (notch) => + notch.column === columnFor(point.at, history, geometry.trackLeft, geometry.trackWidth), + )!.checkpoints.length === 1, + )!; + + const column = (at: number) => + offset + columnFor(at, history, geometry.trackLeft, geometry.trackWidth); + expect(notchHeight(rows, column(selected.at))).toBe(4); + expect(notchHeight(rows, column(history.headAt))).toBe(3); + expect(notchHeight(rows, column(boundary.at))).toBe(2); + expect(notchHeight(rows, column(minor.at))).toBe(1); + }); + + it("rejects one notch height for every marker", function* () { + const size = PROFILE_SIZES.wide; + const subject = fixture("paused"); + const view = initialView(subject); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const frame = yield* renderFrame({ fixture: subject, view, size, mutation: "flatten-notches" }); + const rows = bandRows(frame.text, size); + const selected = subject.history.checkpoints[view.checkpoint]; + const column = + 1 + columnFor(selected.at, subject.history, geometry.trackLeft, geometry.trackWidth); + expect(notchHeight(rows, column)).toBe(1); + }); +}); + +describe("the supported minimum", () => { + it("refuses a terminal below it, and says what it needs", function* () { + const size = PROFILE_SIZES["too-small"]; + const subject = fixture("drawer"); + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + expect(frame.text).toContain("Terminal too small"); + expect(frame.text).toContain( + `${MINIMUM.cols} × ${MINIMUM.rows} required · ${size.cols} × ${size.rows} now`, + ); + expect(frame.text).not.toContain("EXECUTION HISTORY"); + }); + + it("recovers the same view when the terminal grows again", function* () { + const subject = fixture("paused"); + const view = { ...initialView(subject), anchor: 2 }; + const small = yield* renderFrame({ fixture: subject, view, size: PROFILE_SIZES["too-small"] }); + expect(small.text).toContain("Terminal too small"); + const restored = yield* renderFrame({ fixture: subject, view, size: PROFILE_SIZES.wide }); + const golden = yield* readTextFile(join(GOLDENS, "paused.wide.txt")); + expect(restored.text.length).toBeGreaterThan(0); + expect(golden).toContain("EXECUTION HISTORY"); + }); + + it("rejects an interface composed below the minimum", function* () { + const subject = fixture("drawer"); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES["too-small"], + mutation: "ignore-minimum", + }); + expect(frame.text).not.toContain("Terminal too small"); + }); +}); + +describe("resize", () => { + /** + * One run across every profile, ending smaller than it started — which is + * where a renderer that was not told about the resize leaves its evidence. + */ + const script: readonly { readonly fixture: string; readonly size: Size }[] = [ + { fixture: "nested", size: PROFILE_SIZES.wide }, + { fixture: "drawer", size: PROFILE_SIZES.medium }, + { fixture: "paused", size: PROFILE_SIZES["too-small"] }, + { fixture: "settled", size: PROFILE_SIZES.narrow }, + ]; + + /** + * Play the script through one terminal that really is being resized. + * + * The grid is the terminal, so it changes size at every step whether or not + * the renderer was told. A renderer still drawing at the old size addresses + * cells this terminal no longer has, which is what corrupts a real one. + */ + function* play(told: boolean) { + const term = yield* useTerm(PROFILE_SIZES.wide); + let grid = createGrid(PROFILE_SIZES.wide.cols, PROFILE_SIZES.wide.rows); + for (const step of script) { + const subject = fixture(step.fixture); + if (grid.cols !== step.size.cols || grid.rows !== step.size.rows) { + const resized = createGrid(step.size.cols, step.size.rows); + resized.overflow = grid.overflow; + grid = resized; + } + if (told) { + term.update({ width: step.size.cols, height: step.size.rows }); + } + // Ignoring a resize means ignoring it completely: the renderer keeps both + // the terminal's old dimensions and the layout it computed from them. + const frame = renderInto(term, { + fixture: subject, + view: initialView(subject), + size: told ? step.size : PROFILE_SIZES.wide, + mutation: told ? undefined : "skip-resize-update", + }); + applyAnsi(grid, frame.ansi); + } + const last = script[script.length - 1]; + const subject = fixture(last.fixture); + const fresh = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: last.size, + }); + return { + seen: viewport(grid, last.size.cols, last.size.rows), + fresh: applyAnsi(createGrid(last.size.cols, last.size.rows), fresh.ansi), + }; + } + + it("leaves no stale cells behind", function* () { + const { seen, fresh } = yield* play(true); + expect(seen.overflow).toBe(0); + expect(gridDifferences(seen, fresh)).toEqual([]); + expect(gridText(seen)).toBe(gridText(fresh)); + }); + + it("corrupts the screen when the renderer is not told the size changed", function* () { + const { seen, fresh } = yield* play(false); + expect(seen.overflow).toBeGreaterThan(0); + expect(gridDifferences(seen, fresh).length).toBeGreaterThan(0); + }); + + it("refuses a terminal sequence it does not model", function* () { + const grid = createGrid(10, 2); + expect(() => applyAnsi(grid, new TextEncoder().encode("\u001b[2J"))).toThrow( + "does not model the terminal sequence", + ); + }); +}); + +describe("staying operable", () => { + it("windows a long transcript and marks what is clipped", function* () { + const size = PROFILE_SIZES.narrow; + const subject = fixture("nested"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const width = layout.transcript!.width - 2; + const total = transcriptLines(subject.entry!, width).length; + expect(total).toBeGreaterThan(layout.transcript!.height); + + let view = initialView(subject); + const seen = new Set(); + const limit = total; + for (let step = 0; step <= total; step += 1) { + const frame = yield* renderFrame({ fixture: subject, view, size }); + for (const line of frame.text.split("\n")) { + seen.add(line.trim()); + } + view = scrollBy(view, 1, limit); + } + const last = transcriptLines(subject.entry!, width).at(-1)!; + const lastText = last.segments + .map((segment) => segment.text) + .join("") + .trim(); + expect([...seen].some((line) => line.includes(lastText.slice(0, 20)))).toBe(true); + + const first = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); + expect(first.text).toContain("more lines"); + }); + + it("clips a long transcript with no route out when the window is removed", function* () { + const size = PROFILE_SIZES.narrow; + const subject = fixture("nested"); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size, + mutation: "clip-long-transcript", + }); + expect(frame.text).not.toContain("more lines"); + }); + + it("reaches every checkpoint even where the band had to coalesce", function* () { + const size = PROFILE_SIZES.narrow; + const subject = fixture("paused"); + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "history", + }); + const rect = layout.footer!; + const geometry = bandGeometry(subject, layout, rect); + const notches = notchLayout(subject.history, geometry.trackLeft, geometry.trackWidth); + const gathered = notches.reduce((total, notch) => total + notch.checkpoints.length, 0); + + expect(notches.some((notch) => notch.checkpoints.length > 1)).toBe(true); + expect(gathered).toBe(subject.history.checkpoints.length); + + for (let index = 0; index < subject.history.checkpoints.length; index += 1) { + const point = subject.history.checkpoints[index]; + const frame = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), checkpoint: index, surface: "history", drawerOpen: false }, + size, + surface: "history", + }); + expect(frame.text).toContain( + `${String(Math.floor(point.at / 60)).padStart(2, "0")}:${String(point.at % 60).padStart(2, "0")} · snapped`, + ); + } + }); + + it("loses checkpoints when the band stops coalescing", function* () { + const subject = fixture("paused"); + const size = PROFILE_SIZES.narrow; + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "history", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const notches = notchLayout( + subject.history, + geometry.trackLeft, + geometry.trackWidth, + "clip-long-transcript", + ); + const columns = new Set(notches.map((notch) => notch.column)); + expect(columns.size).toBeLessThan(subject.history.checkpoints.length); + }); +}); + +describe("terminal restoration", () => { + const modes = terminalModes(); + const apply = new TextDecoder().decode(modes.apply); + const revert = new TextDecoder().decode(modes.revert); + + it("restores the modes it changed on an ordinary exit", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN} --replay`, { cwd: ROOT }).join(); + expect(result.code).toBe(0); + expect(result.stdout.startsWith(apply)).toBe(true); + expect(result.stdout.endsWith(revert)).toBe(true); + }); + + it("restores them when the run is interrupted by a signal", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN} --replay --interrupt-after 2`, { + cwd: ROOT, + }).join(); + expect(result.stdout.startsWith(apply)).toBe(true); + expect(result.stdout.endsWith(revert)).toBe(true); + }); + + it("restores them when a frame fails", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN} --replay --fail-after 2`, { + cwd: ROOT, + }).join(); + expect(result.code).not.toBe(0); + expect(result.stdout.endsWith(revert)).toBe(true); + }); + + it("rejects a run that leaves the terminal in the modes it turned on", function* () { + const result = yield* exec( + `deno run --allow-all ${MAIN} --replay --mutation leak-terminal-modes`, + { cwd: ROOT }, + ).join(); + expect(result.stdout.endsWith(revert)).toBe(false); + }); + + it("refuses interactive mode when there is no terminal, and names what to use instead", function* () { + const result = yield* exec(`deno run --allow-all ${MAIN}`, { cwd: ROOT }).join(); + expect(result.code).toBe(2); + expect(`${result.stdout}${result.stderr}`).toContain("--capture"); + }); +}); + +describe("the boundary this experiment keeps", () => { + it("keeps terminal cells out of the fixtures", function* () { + // `rows` is allowed and is not a terminal row: an entry's rows are the + // study's own transcript rows, which stay semantic until `render.ts` turns + // them into lines for a width it was given. + const forbidden = [ + "x", + "y", + "width", + "height", + "cols", + "columns", + "column", + "anchor", + "profile", + ]; + const walk = (value: unknown, path: string) => { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`)); + return; + } + if (typeof value !== "object" || value === null) { + return; + } + for (const [key, nested] of Object.entries(value)) { + expect({ path: `${path}.${key}`, forbidden: forbidden.includes(key) }).toEqual({ + path: `${path}.${key}`, + forbidden: false, + }); + walk(nested, `${path}.${key}`); + } + }; + for (const subject of fixtures()) { + walk(subject, subject.name); + } + }); + + it("depends on the renderer from the root manifest alone", function* () { + const root = JSON.parse(yield* readTextFile(join(ROOT, "package.json"))); + expect(Object.keys(root.dependencies)).not.toContain("@bomb.sh/tty"); + expect(root.devDependencies["@bomb.sh/tty"]).toBe("0.9.0"); + + for (const member of yield* readdir(join(ROOT, "packages"))) { + for (const manifest of ["package.json", "deno.json"]) { + const path = join(ROOT, "packages", member, manifest); + if (!(yield* exists(path))) { + continue; + } + const text = yield* readTextFile(path); + expect(text).not.toContain("@bomb.sh/tty"); + expect(text).not.toContain("repl-study"); + } + } + }); + + it("declares every control the evidence uses", function* () { + for (const mutation of MUTATIONS) { + expect(typeof mutation).toBe("string"); + } + expect(MUTATIONS.length).toBe(8); + }); + + it("writes its captures where the goldens live", function* () { + const directory = yield* useTempDirectory("repl-study-captures"); + const captures = yield* captureAll(); + yield* writeCaptures(directory, captures); + const written = yield* readdir(directory); + expect(written.filter((name) => name.endsWith(".txt")).length).toBe(captures.length); + }); +}); From 3d79cb36d4fc3dfc978a7634f8e807c6932b5a6e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 22 Sep 2026 05:12:50 -0400 Subject: [PATCH 02/57] =?UTF-8?q?=F0=9F=8E=AC=20Animate=20the=20REPL=20stu?= =?UTF-8?q?dy,=20and=20make=20notch=20height=20mean=20scope=20depth=20(#83?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two things missing from f18f7855, and both are now proved. **Animation.** The frame loop discarded `RenderResult.animating`, supplied no elapsed time, and redrew only after input — so a declared transition would have rendered its first frame and stopped. It now runs a frame clock as a child of the terminal session: `sleep(16)` in a spawned task, started only while the renderer reports it is interpolating or the application's own transition has not finished, and halted the moment both settle. An idle REPL schedules nothing, and cancelling the session takes the clock with it. Two kinds of motion run during a playback between two fixtures. The renderer owns one — the contextual band declares a transition, so a drawer opening has its height and top edge interpolated, and seventeen of forty-one frames in a measured run were still animating. The harness owns the other: the recorded head travels along the track and the target's transcript arrives a few rows at a time, both computed from elapsed milliseconds alone. The six fixtures stay exactly what they were — stable entry points and goldens. A playback is the path between two of them and holds no state that outlives it: its phase and elapsed time live in the frame loop, and reconstruction lands on a fixture rather than halfway through a transition. Evidence: a deterministic playback rendered with explicit `deltaTime`, with start, midpoint and settled captures committed; a pseudo-terminal run that keeps drawing with nothing typed at it; and an interruption mid-transition that leaves the trace at the frame the signal arrived on and the terminal's modes restored. Three new controls back them — `never-tick`, `restore-mid-animation`, and the existing `skip-resize-update`. **Notch height is scope depth.** It encoded selected/head/entry status, which contradicts the settled decision. Height now carries depth alone — depth 0 fills the band, depth 3 takes the track row, deeper shares the shortest notch — and everything else is said another way: the playhead is its own heavier stem with its own label, a selection is gold with a caret and a label, and an entry boundary is `◆` where an event is `●`. Selecting a checkpoint no longer changes its notch height, and a test proves it. That needs one more row than the study's 92-pixel band divides into: four depths plus a label row the notches do not reach. `RESULT.md` records it as the adaptation it is. --- scripts/repl-study/README.md | 39 +- scripts/repl-study/RESULT.md | 80 +++- scripts/repl-study/capture.ts | 128 ++++++- scripts/repl-study/host.ts | 178 ++++++++- scripts/repl-study/layout.ts | 10 +- scripts/repl-study/main.ts | 89 ++++- scripts/repl-study/mutations.ts | 4 + scripts/repl-study/playback.ts | 83 ++++ scripts/repl-study/render.ts | 271 ++++++++----- .../fixtures/repl-study/drawer.medium.txt | 10 +- .../repl-study/drawer.narrow.sessions.txt | 15 - .../fixtures/repl-study/drawer.narrow.txt | 16 - .../fixtures/repl-study/drawer.too-small.txt | 14 - .../tests/fixtures/repl-study/drawer.wide.txt | 10 +- .../fixtures/repl-study/empty.medium.txt | 3 - .../fixtures/repl-study/empty.narrow.txt | 1 - .../tests/fixtures/repl-study/empty.wide.txt | 3 - .../fixtures/repl-study/generated.medium.txt | 12 +- .../fixtures/repl-study/generated.narrow.txt | 2 - .../fixtures/repl-study/generated.wide.txt | 10 +- .../fixtures/repl-study/nested.medium.txt | 12 +- .../repl-study/nested.narrow.bindings.txt | 6 - .../fixtures/repl-study/nested.narrow.txt | 2 - .../tests/fixtures/repl-study/nested.wide.txt | 10 +- .../fixtures/repl-study/paused.medium.txt | 10 +- .../repl-study/paused.narrow.history.txt | 27 +- .../fixtures/repl-study/paused.narrow.txt | 2 - .../fixtures/repl-study/paused.too-small.txt | 14 - .../tests/fixtures/repl-study/paused.wide.txt | 10 +- .../play.generated-drawer.midpoint.txt | 51 +++ .../play.generated-drawer.settled.txt | 51 +++ .../play.generated-drawer.start.txt | 51 +++ .../fixtures/repl-study/settled.medium.txt | 10 +- .../fixtures/repl-study/settled.narrow.txt | 1 - .../fixtures/repl-study/settled.wide.txt | 10 +- scripts/tests/repl-study.test.ts | 359 ++++++++++++++++-- 36 files changed, 1269 insertions(+), 335 deletions(-) create mode 100644 scripts/repl-study/playback.ts create mode 100644 scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt create mode 100644 scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt create mode 100644 scripts/tests/fixtures/repl-study/play.generated-drawer.start.txt diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index d02742fb9..a0be8f430 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -13,10 +13,11 @@ it found. ## Run it ```bash -deno task repl:study # in this terminal -deno task repl:study --fixture drawer # opening on one moment -deno task repl:study --capture captures/ # every fixture at every profile -deno task repl:study --print nested wide # one frame, as text +deno task repl:study # in this terminal +deno task repl:study --fixture drawer # opening on one moment +deno task repl:study --play generated drawer # playing one transition +deno task repl:study --capture captures/ # every fixture at every profile +deno task repl:study --print nested wide # one frame, as text ``` Keys, while it is running: @@ -29,12 +30,41 @@ Keys, while it is running: | `Esc` | return to the head | | `Tab` / `Shift+Tab` | move between surfaces, which matters in the narrow profile | | `d` | open or close the drawer | +| `p` | play the transition out of this moment into the next | | `q` or `Ctrl+C` | leave, restoring the terminal | These are the harness's own controls. The accepted focus model — the five-region ring, the drawer's focus trap, and where focus returns after a suspension — is [#839](https://github.com/taras/executable.md/issues/839), not this experiment. +## Moving between moments + +The six fixtures are stable states — reconstructable, capturable, and what a +journal would restore. A playback is the path between two of them, and it exists +only while it runs: its phase and elapsed time live in the frame loop, never in a +fixture. Two kinds of motion run during one: + +- **the renderer's own.** The contextual band declares a transition, so when a + suspension opens the drawer `@bomb.sh/tty` interpolates its height and top edge + and reports `animating` until it arrives. The harness supplies time and nothing + else. +- **the application's own.** The recorded head travels along the track and the + target's transcript arrives a few rows at a time, both interpolated here from + elapsed milliseconds. + +A frame clock — `sleep(16)` in a child of the terminal session — is spawned only +while one of those two is still moving, and halted the moment both have settled, +so an idle REPL schedules nothing. Cancelling the session halts the clock with +it, which is why an interruption cannot leave a frame being drawn into a terminal +that has already been restored. + +Three flags exist for running a playback without a person watching: +`--frames ` leaves once the playback settles or that many frames have been +drawn, `--interrupt-after-frames ` raises a real `SIGINT` at the harness mid +transition, and `--trace ` records what every frame did — elapsed time, the +delta it was given, whether the renderer was animating, and how many bytes it +emitted. + ## What it shows Six fixtures, each a moment from the study: an empty REPL; a `Plan` running @@ -66,6 +96,7 @@ diff as the picture it changed. The `.ansi` files are not committed. | File | What it owns | | --- | --- | | `model.ts` | the semantic vocabulary — scopes, phases, sections, sessions, bindings, checkpoints, drawers. No cells. | +| `playback.ts` | the path between two fixtures, and the motion at one instant of it | | `fixtures.ts` | the six moments, from the study's own content | | `view.ts` | what the person chose: the transcript window, the selected checkpoint, the current surface | | `layout.ts` | the profile, and every region's rectangle in cells | diff --git a/scripts/repl-study/RESULT.md b/scripts/repl-study/RESULT.md index 76a4965be..49d70d892 100644 --- a/scripts/repl-study/RESULT.md +++ b/scripts/repl-study/RESULT.md @@ -4,12 +4,12 @@ Issue [#838](https://github.com/taras/executable.md/issues/838) asked whether `@bomb.sh/tty` can render the approved `XMD REPL Terminal Interface` study in a real terminal, and what the design owes a terminal that is not 2560 × 1440. -**Decision: retain the harness, and revise three design states.** The renderer -carried every fixture at every profile without a single renderer error, the -terminal survived exit, interruption and failure, and the seam between semantic -fixtures, layout, rendering and the terminal host held. Three states needed an -adaptation the study does not describe, each named below, and those belong to -whoever takes the design further. +**Decision: retain the harness, and revise two design states.** The renderer +carried every fixture at every profile without a single renderer error, animated +both its own transitions and the application's, and gave the terminal back after +an ordinary exit, an interruption mid-animation and a failure. The seam between +semantic fixtures, layout, rendering and the terminal host held. Two states +needed an adaptation the study does not describe, named below. ## Dimensions tested @@ -20,11 +20,44 @@ whoever takes the design further. | rendered and captured | 90 × 28 | narrow | | rendered and captured | 64 × 18 | too-small | | a real pseudo-terminal, interactively, macOS `script` | 80 × 24 | narrow | +| a real pseudo-terminal, animating with no input | 80 × 24 | narrow | The interactive run opened, showed the `nested` fixture, accepted `4`, `Tab` and `q` as keystrokes, and left the terminal in the modes it found. Every other dimension was exercised through the captures and the suite. +## Animation + +The renderer animates, and the frame loop that drives it is small. + +- **A declared transition is interpolated by the renderer.** Giving the + contextual band `transition: { duration: 260, easing: "easeInOut", properties: + ["height", "y"] }` is the whole of what the harness does about the drawer's + movement: `render()` then reports `animating: true` and reports interpolated + cell bounds until it arrives. In one measured playback, seventeen of forty-one + frames were still interpolating. +- **`deltaTime` is milliseconds, and the renderer never measures time itself.** + A frame given `deltaTime: 0` — which is what a keystroke or a resize gets — + advances no transition, so typing during a transition does not skip it forward. +- **Some interpolated frames emit nothing.** Sub-cell movement changes no cell, + so a loop that stopped when a frame produced zero bytes would freeze halfway. + `animating` is the condition to schedule on, never the byte count. +- **The clock belongs to the session.** It is a child task running `sleep(16)`, + spawned when either the renderer or the application's own transition is moving, + and halted as soon as both settle — an idle REPL schedules nothing at all. An + interruption mid-transition halted it with the session: the trace ends at the + frame the signal arrived on, and the terminal's modes were restored after it. +- **Application-timed motion stays a pure function of elapsed time.** The head's + travel along the track and the transcript's arrival are computed from + milliseconds, so the same instant renders identically from a test, a capture + and the live loop — which is what makes a midpoint capture possible at all. + +What a playback never becomes is state. The six fixtures remain the +reconstructable moments; a playback's phase and elapsed time live in the frame +loop and are gone when it settles. Reconstruction lands on a fixture, and a +control that makes it land halfway through a transition is rejected by the +goldens. + ## What the renderer gave us - **Layout arrives back in cells.** `render().info.get(id).bounds` reports @@ -64,16 +97,17 @@ dimension was exercised through the captures and the suite. 5. **The output view expires.** `render().output` must be copied immediately — `Uint8Array.from(...)` — or the next frame invalidates it. -## The three design states that required adaptation +## The two design states that required adaptation -1. **Nesting depth has no height left to use.** The band is four rows, and the - study's four extents already spend them: a minor checkpoint takes the track - row, an entry boundary rises one row above it, the head takes three rows and - carries its label, and a historical selection takes the whole band. Depth is - therefore a glyph tier — `●` for the entry's own scope, `◇` one level in, `·` - deeper — and past three tiers the band stops distinguishing. The scope path - is exact in the journal list beside it, so nothing is lost, but the band - alone cannot answer "how deep is this". +1. **The band is five rows, not the study's four.** Notch height carries scope + depth, which is the settled meaning, and four depths need four rows of their + own: depth 0 fills them, depth 3 takes the track row alone, and anything + deeper shares the shortest notch and says so with `·`. The selection's label + then has nowhere to go — a depth-0 notch and the label want the same cell — so + the band takes one more row than the study's 92 pixels divide into. Everything + else about a marker is said some way that is not height: the playhead is its + own heavier stem with its own label, a selection is gold with `▲` and a label, + and an entry boundary is `◆` where an ordinary event is `●`. 2. **A narrow track is mostly transport.** The study's rule that the track yields room to the visible controls is faithful and expensive: at 90 columns while inspecting history, `INSPECTING [ Continue ] [ Return ] [ Fork ]` @@ -82,17 +116,21 @@ dimension was exercised through the captures and the suite. steps through every checkpoint behind it, and the full-screen history surface lists them all. A design that wants the track legible at narrow widths has to decide what the transport gives up first. -3. **The band's own labels do not fit narrow.** `EXECUTION HISTORY` and - `recorded · 00:53` cost eighteen columns that the track needs more, so the - narrow band says `HISTORY` and `00:53` and lets the surface bar above it - carry the name. The same pressure drops the transcript's eight-column phase - word below 56 columns, and drops session notes, binding notes and the - drawer's schema column at medium. + The band's own labels feel the same squeeze: `EXECUTION HISTORY` and + `recorded · 00:53` cost eighteen columns the track needs more, so the narrow + band says `HISTORY` and `00:53` and lets the surface bar above it carry the + name. The same pressure drops the transcript's eight-column phase word below + 56 columns, and drops session notes, binding notes and the drawer's schema + column at medium. ## What was not answered - Focus, the ring, the drawer's trap and where focus returns after a suspension are #839's, and nothing here establishes them. +- Only one native transition is exercised — the drawer's opening. A drawer + *closing* would need the moment being left to stay renderable through the + transition, which is a question about what a playback holds, and #842's + journal reconstruction is the place to answer it. - No reusable component boundary is proposed; #840 owns that, and the region functions in `render.ts` are deliberately private. - Restoration is proved at the byte boundary and by construction — cleanup diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 28b1ef1e2..b55a028de 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -8,13 +8,15 @@ */ import { createTerm } from "@bomb.sh/tty"; -import type { Term } from "@bomb.sh/tty"; +import type { BoundingBox, Term } from "@bomb.sh/tty"; import { until } from "effection"; import type { Operation } from "effection"; import { ensureDir, writeTextFile } from "@effectionx/fs"; import { join } from "node:path"; import { fixture, fixtures } from "./fixtures.ts"; +import { motionAt, PLAYBACKS } from "./playback.ts"; +import type { Motion, Playback } from "./playback.ts"; import type { Fixture } from "./model.ts"; import type { Profile, SurfaceName } from "./layout.ts"; import { layoutFor } from "./layout.ts"; @@ -45,14 +47,35 @@ export const PROFILE_SIZES: Record = { export interface Frame { readonly ansi: Uint8Array; readonly text: string; + /** True while the renderer is still interpolating a declared transition. */ + readonly animating: boolean; + /** Where each region landed, in cells, as the renderer reports it. */ + readonly bounds: Readonly>; } +/** The regions whose geometry the evidence asks about. */ +const MEASURED = [ + "root", + "header", + "sidebar", + "transcript", + "bindings", + "contextual", + "footer", + "surface-bar", + "too-small", +]; + export interface FrameRequest { readonly fixture: Fixture; readonly view: View; readonly size: Size; readonly mutation?: Mutation; readonly surface?: SurfaceName; + /** Present only while a playback is running between two fixtures. */ + readonly motion?: Motion; + /** Milliseconds since the previous frame, which native transitions consume. */ + readonly deltaTime?: number; } export function* useTerm(size: Size): Operation { @@ -70,20 +93,80 @@ export function renderInto(term: Term, request: FrameRequest): Frame { // A frame drawn from state the harness has already left behind. The renderer // cannot tell the difference — only a reader, or a golden, can. const subject = mutation === "stale-frame" ? fixture("empty") : request.fixture; + const motion = + mutation === "restore-mid-animation" && request.motion === undefined + ? // Reconstruction must land on a state, never halfway through a transition. + // This control makes it land halfway. + { progress: 0.5, headAt: subject.history.headAt / 2, reveal: 0.5, done: false } + : request.motion; + // A playback's first frame still shows the moment it is leaving, so a drawer + // about to open is not open yet: that is what gives the renderer two + // geometries to interpolate between rather than one it has already arrived at. + const opening = motion === undefined || motion.progress > 0; const layout = layoutFor({ cols: size.cols, rows: size.rows, - drawer: subject.drawer !== undefined && view.drawerOpen, + drawer: subject.drawer !== undefined && view.drawerOpen && opening, surface: request.surface ?? view.surface, mutation, }); - const result = term.render(renderScreen({ fixture: subject, view, layout, mutation })); + const result = term.render(renderScreen({ fixture: subject, view, layout, mutation, motion }), { + deltaTime: request.deltaTime ?? 0, + }); if (result.errors.length > 0) { throw new Error(`the renderer reported ${JSON.stringify(result.errors)}`); } const ansi = Uint8Array.from(result.output); const grid = applyAnsi(createGrid(size.cols, size.rows), ansi); - return { ansi, text: gridText(grid) }; + const bounds: Record = {}; + for (const id of MEASURED) { + bounds[id] = result.info.get(id)?.bounds; + } + return { ansi, text: gridText(grid), animating: result.animating, bounds }; +} + +/** + * One playback, rendered frame by frame with an explicit delta. + * + * Nothing here waits: time is supplied rather than measured, so the same + * sequence comes out of a test, a capture and a review identically. The run + * ends when the application's motion has finished *and* the renderer has + * stopped interpolating, which is the same condition the frame loop uses to + * stop scheduling. + */ +export function* playFrames( + playback: Playback, + size: Size, + options: { readonly frameMs?: number; readonly limit?: number } = {}, +): Operation { + const frameMs = options.frameMs ?? 16; + const limit = options.limit ?? 200; + const subject = fixture(playback.to); + const view = initialView(subject); + const term = yield* useTerm(size); + const frames: Frame[] = []; + // A frame in the middle of a transition is a handful of changed cells, not a + // screen. The screen is what those changes have added up to, so the grid + // carries across frames exactly as a terminal's does. + const screen = createGrid(size.cols, size.rows); + let elapsed = 0; + for (let index = 0; index < limit; index += 1) { + const motion = motionAt(playback, elapsed); + const frame = renderInto(term, { + fixture: subject, + view, + size, + motion, + deltaTime: index === 0 ? 0 : frameMs, + }); + applyAnsi(screen, frame.ansi); + frames.push({ ...frame, text: gridText(screen) }); + if (motion.done && !frame.animating) { + return frames; + } + elapsed += frameMs; + } + return frames; } export function captureName(fixtureName: string, profile: Profile): string { @@ -144,17 +227,44 @@ export function* captureAll(): Operation { frame, }); } + // Three moments of one playback, so a reader can see a transition without + // running it: where it starts, where it is halfway, and where it settles. + const playback = PLAYBACKS.find((one) => one.from === "generated" && one.to === "drawer")!; + const frames = yield* playFrames(playback, PROFILE_SIZES.wide); + const moments: readonly { readonly label: string; readonly at: number }[] = [ + { label: "start", at: 0 }, + { label: "midpoint", at: Math.floor((frames.length - 1) / 2) }, + { label: "settled", at: frames.length - 1 }, + ]; + for (const moment of moments) { + captures.push({ + name: `play.${playback.from}-${playback.to}.${moment.label}`, + profile: "wide", + size: PROFILE_SIZES.wide, + frame: frames[moment.at], + }); + } + return captures; } +/** + * One capture as a file: what it is, how big the terminal was, and the screen. + * + * Trailing blank rows are dropped, because the header already says how tall the + * terminal was and a file that ends in empty lines is a file git complains + * about. Both the writer and the evidence read the screen through here, so a + * golden cannot disagree with what `--capture` writes. + */ +export function captureText(capture: Capture): string { + const header = `${capture.name} · ${capture.size.cols} × ${capture.size.rows}`; + return `${header}\n${capture.frame.text.replace(/\n+$/, "")}\n`; +} + export function* writeCaptures(directory: string, captures: readonly Capture[]): Operation { yield* ensureDir(directory); for (const capture of captures) { - const header = `${capture.name} · ${capture.size.cols} × ${capture.size.rows}\n`; - yield* writeTextFile( - join(directory, `${capture.name}.txt`), - `${header}${capture.frame.text}\n`, - ); + yield* writeTextFile(join(directory, `${capture.name}.txt`), captureText(capture)); yield* writeTextFile( join(directory, `${capture.name}.ansi`), new TextDecoder().decode(capture.frame.ansi), diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index f7517f054..6dde73538 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -15,8 +15,8 @@ import { alternateBuffer, createInput, cursor, settings } from "@bomb.sh/tty"; import type { Input, InputEvent, Setting, Term } from "@bomb.sh/tty"; -import { createSignal, ensure, resource, spawn, until } from "effection"; -import type { Operation } from "effection"; +import { createSignal, ensure, resource, sleep, spawn, until } from "effection"; +import type { Operation, Signal, Task } from "effection"; import { fixture, fixtures } from "./fixtures.ts"; import { FIXTURE_NAMES } from "./model.ts"; @@ -26,8 +26,10 @@ import { renderScreen } from "./render.ts"; import { transcriptLines } from "./render.ts"; import { initialView, moveSurface, returnToHead, scrollBy, scrubBy, toggleDrawer } from "./view.ts"; import type { View } from "./view.ts"; -import { useTerm } from "./capture.ts"; +import { renderInto, useTerm } from "./capture.ts"; import type { Mutation } from "./mutations.ts"; +import { motionAt, playbackFrom } from "./playback.ts"; +import type { Motion, Playback } from "./playback.ts"; /** The modes the harness changes, as one reversible pair. */ export function terminalModes(): Setting { @@ -136,6 +138,7 @@ export function measureTerminal(): { cols: number; rows: number } { export type HarnessEvent = | { readonly kind: "key"; readonly event: InputEvent } | { readonly kind: "resize" } + | { readonly kind: "tick" } | { readonly kind: "quit" }; export interface HarnessState { @@ -164,6 +167,11 @@ export function reduce(state: HarnessState, event: HarnessEvent): HarnessState { const size = measureTerminal(); return { ...state, cols: size.cols, rows: size.rows }; } + if (event.kind === "tick") { + // A frame passing changes what is drawn, never what is shown: the motion is + // a function of elapsed time, which the frame loop owns. + return state; + } const key = event.event; if (key.type !== "keydown") { return state; @@ -218,28 +226,71 @@ export function reduce(state: HarnessState, event: HarnessEvent): HarnessState { return state; } +/** One frame drawn, and whether the renderer is still moving. */ +interface Painted { + readonly animating: boolean; + readonly bytes: number; +} + function draw( term: Term, state: HarnessState, write: (bytes: Uint8Array) => void, mutation?: Mutation, -): void { - const layout = layoutFor({ - cols: state.cols, - rows: state.rows, - drawer: state.fixture.drawer !== undefined && state.view.drawerOpen, - surface: state.view.surface, + motion?: Motion, + deltaTime = 0, +): Painted { + // One render path for the harness and for the captures, so what a person sees + // in a terminal and what a golden records cannot drift apart. + const frame = renderInto(term, { + fixture: state.fixture, + view: state.view, + size: { cols: state.cols, rows: state.rows }, mutation, + motion, + deltaTime, }); - const result = term.render( - renderScreen({ fixture: state.fixture, view: state.view, layout, mutation }), - ); - write(Uint8Array.from(result.output)); + write(frame.ansi); + return { animating: frame.animating, bytes: frame.ansi.length }; +} + +/** A frame every sixteen milliseconds, which is the rate the study was made at. */ +export const FRAME_MS = 16; + +/** + * The clock that keeps an animation moving when nothing else is happening. + * + * It is spawned as a child of the terminal session and halted the moment + * nothing is moving, so an idle REPL costs nothing and a cancelled session + * cannot leave a timer drawing into a terminal that has already been restored. + */ +function* ticker(events: Signal): Operation { + while (true) { + yield* sleep(FRAME_MS); + events.send({ kind: "tick" }); + } +} + +/** One line of what the frame loop did, for evidence that cannot watch a screen. */ +export interface TraceEntry { + readonly frame: number; + readonly elapsedMs: number; + readonly deltaTime: number; + readonly animating: boolean; + readonly motionDone: boolean | null; + readonly bytes: number; } export interface InteractiveOptions { readonly fixture: FixtureName; readonly mutation?: Mutation; + /** Start this playback immediately, rather than waiting for `p`. */ + readonly play?: Playback; + /** Leave after this many frames, so a run can end without a keystroke. */ + readonly maxFrames?: number; + /** Raise SIGINT at this harness once this many frames have been drawn. */ + readonly interruptAfterFrames?: number; + readonly trace?: TraceEntry[]; } /** @@ -296,13 +347,110 @@ export function* runInteractive(options: InteractiveOptions): Operation { } }); - draw(term, state, write, options.mutation); + let playback = options.play; + let elapsed = 0; + let frames = 0; + let clock: Task | undefined; + let interrupted = false; + let settled = false; + + if (playback !== undefined) { + const target = fixture(playback.to); + state = { ...state, fixture: target, view: initialView(target) }; + } + + /** + * Draw one frame, then decide whether anything is still moving. + * + * The clock is started only when the renderer says it is animating or the + * application's own transition has not finished, and halted as soon as both + * have settled — so an idle REPL schedules nothing at all. + */ + const paint = function* (deltaTime: number): Operation { + const motion = playback === undefined ? undefined : motionAt(playback, elapsed); + const painted = draw(term, state, write, options.mutation, motion, deltaTime); + frames += 1; + options.trace?.push({ + frame: frames, + elapsedMs: elapsed, + deltaTime, + animating: painted.animating, + motionDone: motion === undefined ? null : motion.done, + bytes: painted.bytes, + }); + + const moving = painted.animating || (motion !== undefined && !motion.done); + const active = options.mutation === "never-tick" ? false : moving; + if (active && clock === undefined) { + clock = yield* spawn(() => ticker(events)); + } + if (!active && clock !== undefined) { + const running = clock; + clock = undefined; + yield* running.halt(); + } + if (motion !== undefined && motion.done && !painted.animating) { + // The transition has arrived. What remains is the fixture itself, which + // is what a journal or a URL would restore. + playback = undefined; + settled = true; + } + if (options.maxFrames !== undefined && !active) { + // A run with a frame budget has nobody at the keyboard, so when nothing + // is moving there is nothing left for it to do. A harness that scheduled + // no frame at all ends here too, after exactly one. + settled = true; + } + }; + + yield* paint(0); while (true) { + // `--frames` is a ceiling for a run nobody is watching: it leaves when the + // playback has settled, or when that many frames have been drawn, whichever + // comes first. Without it the harness waits for a keystroke, as it should. + if (options.maxFrames !== undefined && (settled || frames >= options.maxFrames)) { + return; + } + if ( + options.interruptAfterFrames !== undefined && + frames >= options.interruptAfterFrames && + !interrupted + ) { + interrupted = true; + Deno.kill(Deno.pid, "SIGINT"); + } + const next = yield* subscription.next(); if (next.done) { return; } + + if (next.value.kind === "tick") { + elapsed += FRAME_MS; + yield* paint(FRAME_MS); + continue; + } + + // A keystroke or a resize is not time passing, so the renderer is told no + // time has passed: a transition in flight keeps its own pace instead of + // jumping forward because somebody typed. + const pressed = next.value.kind === "key" ? next.value.event : undefined; + if (pressed !== undefined && pressed.type === "keydown") { + const code = pressed.code; + if (code === "p") { + const starting = playbackFrom(state.fixture.name); + if (starting !== undefined) { + playback = starting; + elapsed = 0; + const target = fixture(starting.to); + state = { ...state, fixture: target, view: initialView(target) }; + yield* paint(0); + continue; + } + } + } + const before = { cols: state.cols, rows: state.rows }; // Ignoring a resize means ignoring it completely — the renderer keeps the // dimensions it had, and goes on addressing cells the terminal no longer @@ -317,7 +465,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { if (state.cols !== before.cols || state.rows !== before.rows) { term.update({ width: state.cols, height: state.rows }); } - draw(term, state, write, options.mutation); + yield* paint(0); } } diff --git a/scripts/repl-study/layout.ts b/scripts/repl-study/layout.ts index d77fc56dc..fe2944f56 100644 --- a/scripts/repl-study/layout.ts +++ b/scripts/repl-study/layout.ts @@ -36,7 +36,15 @@ export const MINIMUM = { cols: 72, rows: 20 } as const; export const PANE_MINIMUMS = { sidebar: 28, bindings: 26, transcript: 40 } as const; /** Rows the study's 92px bands become. */ -const FOOTER_ROWS = 4; +/** + * The Execution History band is five rows, not the study's four. + * + * Notch height carries scope depth, and four depths need four rows of their + * own. The selection's label needs a row the notches are not using, or a + * depth-0 notch and the label fight for the same cell — so the band takes one + * more row than the study's 92 pixels divide into. + */ +const FOOTER_ROWS = 5; const INPUT_ROWS = 4; const HEADER_ROWS = 2; diff --git a/scripts/repl-study/main.ts b/scripts/repl-study/main.ts index f1a603409..372d1a2f4 100644 --- a/scripts/repl-study/main.ts +++ b/scripts/repl-study/main.ts @@ -9,12 +9,16 @@ * `scripts/repl-study/README.md` explains the keys and what each mode is for. */ -import { exit, main } from "effection"; +import { ensure, exit, main } from "effection"; import type { Operation } from "effection"; import { captureAll, PROFILE_SIZES, renderFrame, writeCaptures } from "./capture.ts"; import { fixture } from "./fixtures.ts"; import { runInteractive, runReplay } from "./host.ts"; +import type { TraceEntry } from "./host.ts"; +import { playbackBetween } from "./playback.ts"; +import type { Playback } from "./playback.ts"; +import { writeTextFile } from "@effectionx/fs"; import { isFixtureName } from "./model.ts"; import type { FixtureName } from "./model.ts"; import type { Profile } from "./layout.ts"; @@ -25,16 +29,25 @@ import { initialView } from "./view.ts"; const USAGE = [ "usage:", " repl-study [--fixture ] [--mutation ]", + " repl-study --play [--frames ] [--interrupt-after-frames ] [--trace ]", " repl-study --capture [--mutation ]", " repl-study --print [--mutation ]", " repl-study --replay [--interrupt-after ] [--fail-after ] [--mutation ]", "", "fixtures: empty, nested, generated, drawer, paused, settled", "profiles: wide, medium, narrow, too-small", + "playbacks: empty→nested, nested→generated, generated→drawer, drawer→paused, paused→settled", ].join("\n"); type Mode = - | { readonly kind: "interactive"; readonly fixture: FixtureName } + | { + readonly kind: "interactive"; + readonly fixture: FixtureName; + readonly play?: Playback; + readonly maxFrames?: number; + readonly interruptAfterFrames?: number; + readonly trace?: string; + } | { readonly kind: "capture"; readonly directory: string } | { readonly kind: "print"; readonly fixture: FixtureName; readonly profile: Profile } | { readonly kind: "replay"; readonly interruptAfter?: number; readonly failAfter?: number }; @@ -44,6 +57,10 @@ interface Invocation { readonly mutation?: Mutation; } +function isFrameCount(value: string | undefined): value is string { + return value !== undefined && Number.isInteger(Number(value)) && Number(value) >= 1; +} + function isProfile(value: string): value is Profile { return value === "wide" || value === "medium" || value === "narrow" || value === "too-small"; } @@ -59,6 +76,10 @@ export function parse(argv: readonly string[]): Invocation | string { let mutation: Mutation | undefined; let fixtureName: FixtureName = "nested"; let mode: Mode | undefined; + let play: Playback | undefined; + let maxFrames: number | undefined; + let interruptAfterFrames: number | undefined; + let trace: string | undefined; let at = 0; const value = (): string | undefined => { @@ -96,6 +117,35 @@ export function parse(argv: readonly string[]): Invocation | string { return `--print needs a profile, not ${JSON.stringify(profile)}`; } mode = { kind: "print", fixture: name, profile }; + } else if (argument === "--play") { + const from = value(); + const to = value(); + if (from === undefined || !isFixtureName(from) || to === undefined || !isFixtureName(to)) { + return `--play needs two fixture names, not ${JSON.stringify([from, to])}`; + } + const found = playbackBetween(from, to); + if (found === undefined) { + return `there is no playback from ${from} to ${to}`; + } + play = found; + } else if (argument === "--frames") { + const count = value(); + if (!isFrameCount(count)) { + return "--frames needs a frame count"; + } + maxFrames = Number(count); + } else if (argument === "--interrupt-after-frames") { + const count = value(); + if (!isFrameCount(count)) { + return "--interrupt-after-frames needs a frame count"; + } + interruptAfterFrames = Number(count); + } else if (argument === "--trace") { + const path = value(); + if (path === undefined) { + return "--trace needs a file to write"; + } + trace = path; } else if (argument === "--replay") { mode = { kind: "replay" }; } else if (argument === "--interrupt-after" || argument === "--fail-after") { @@ -116,7 +166,17 @@ export function parse(argv: readonly string[]): Invocation | string { at += 1; } - return { mode: mode ?? { kind: "interactive", fixture: fixtureName }, mutation }; + return { + mode: mode ?? { + kind: "interactive", + fixture: fixtureName, + play, + maxFrames, + interruptAfterFrames, + trace, + }, + mutation, + }; } function* run(invocation: Invocation): Operation { @@ -154,7 +214,28 @@ function* run(invocation: Invocation): Operation { return; } - yield* runInteractive({ fixture: mode.fixture, mutation }); + const trace: TraceEntry[] = []; + const tracePath = mode.trace; + if (tracePath !== undefined) { + // Written from teardown, not after the loop: an interruption ends this run + // through the same shutdown that restores the terminal, and a trace that + // only survived an ordinary exit could not testify about an interrupted + // one. Registered before the harness starts, so it runs after it stops. + yield* ensure(function* () { + yield* writeTextFile( + tracePath, + trace.map((entry) => JSON.stringify(entry)).join("\n") + "\n", + ); + }); + } + yield* runInteractive({ + fixture: mode.fixture, + mutation, + play: mode.play, + maxFrames: mode.maxFrames, + interruptAfterFrames: mode.interruptAfterFrames, + trace: tracePath === undefined ? undefined : trace, + }); } if (import.meta.main) { diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index 0c162e3cd..1cd5abe0d 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -24,6 +24,10 @@ export const MUTATIONS = [ "leak-terminal-modes", /** Render one notch height for every kind of marker. */ "flatten-notches", + /** Never schedule a frame, so an animation renders once and stops. */ + "never-tick", + /** Reconstruct a moment as a half-finished animation rather than a state. */ + "restore-mid-animation", ] as const; export type Mutation = (typeof MUTATIONS)[number]; diff --git a/scripts/repl-study/playback.ts b/scripts/repl-study/playback.ts new file mode 100644 index 000000000..a0f42d0df --- /dev/null +++ b/scripts/repl-study/playback.ts @@ -0,0 +1,83 @@ +/** + * Moving between two moments, rather than cutting between them. + * + * The six fixtures stay what they are: stable, reconstructable states that the + * captures and the journal both describe. A playback is the path between two of + * them, and it exists only while it is running — its phase and its elapsed time + * live in the frame loop's own local state, never in a fixture and never in + * anything a journal would restore. Reconstruction lands on a fixture; it never + * lands halfway through a transition. + * + * Two kinds of motion run here. The renderer owns one — a drawer that grows out + * of the contextual band, interpolated by `@bomb.sh/tty` from a declared + * transition — and the application owns the other: the recorded head travelling + * along the track, and the target's transcript arriving a few rows at a time. + */ + +import { fixture } from "./fixtures.ts"; +import type { FixtureName } from "./model.ts"; + +export interface Playback { + readonly from: FixtureName; + readonly to: FixtureName; + readonly durationMs: number; +} + +/** The path the harness plays, in the order the study tells its story. */ +export const PLAYBACKS: readonly Playback[] = [ + { from: "empty", to: "nested", durationMs: 640 }, + { from: "nested", to: "generated", durationMs: 640 }, + { from: "generated", to: "drawer", durationMs: 640 }, + { from: "drawer", to: "paused", durationMs: 640 }, + { from: "paused", to: "settled", durationMs: 640 }, +]; + +export function playbackFrom(from: FixtureName): Playback | undefined { + return PLAYBACKS.find((playback) => playback.from === from); +} + +export function playbackBetween(from: FixtureName, to: FixtureName): Playback | undefined { + return PLAYBACKS.find((playback) => playback.from === from && playback.to === to); +} + +export interface Motion { + /** Eased 0…1. */ + readonly progress: number; + /** Where the recorded head sits while it travels between the two moments. */ + readonly headAt: number; + /** How much of the target's transcript has arrived, as a share of its rows. */ + readonly reveal: number; + readonly done: boolean; +} + +function easeInOutCubic(fraction: number): number { + return fraction < 0.5 + ? 4 * fraction * fraction * fraction + : 1 - Math.pow(-2 * fraction + 2, 3) / 2; +} + +/** + * The motion of one playback at one moment. + * + * Pure, and a function of elapsed time alone, so the same instant can be + * rendered from a capture, from a test, or from the frame loop and come out + * identical. + */ +export function motionAt(playback: Playback, elapsedMs: number): Motion { + const fraction = + playback.durationMs <= 0 ? 1 : Math.max(0, Math.min(1, elapsedMs / playback.durationMs)); + const progress = easeInOutCubic(fraction); + const from = fixture(playback.from).history.headAt; + const to = fixture(playback.to).history.headAt; + return { + progress, + headAt: from + (to - from) * progress, + reveal: progress, + done: fraction >= 1, + }; +} + +/** The moment a playback settles on, which is the state anything restores to. */ +export function settledFixture(playback: Playback): FixtureName { + return playback.to; +} diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 5d98584e8..df72ce175 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -20,6 +20,7 @@ import type { Layout, Rect } from "./layout.ts"; import { MINIMUM } from "./layout.ts"; import type { View } from "./view.ts"; import type { Mutation } from "./mutations.ts"; +import type { Motion } from "./playback.ts"; const C = { src: rgba(0xc8, 0xd2, 0xd9), @@ -143,6 +144,18 @@ function lineOps(id: string, width: number, line: VisualLine): Op[] { interface RegionOptions { readonly bg?: number; readonly padding?: { readonly left?: number; readonly right?: number; readonly top?: number }; + /** + * A transition the renderer owns. + * + * Declaring it makes `@bomb.sh/tty` interpolate this region between the + * geometry of one frame and the next, and report `animating` until it + * settles. The harness supplies the time; it does not do the interpolation. + */ + readonly transition?: { + readonly duration: number; + readonly easing?: "linear" | "easeIn" | "easeOut" | "easeInOut"; + readonly properties: readonly ("x" | "y" | "position" | "width" | "height" | "size" | "bg")[]; + }; } function region( @@ -167,6 +180,15 @@ function region( floating: { x: rect.x, y: rect.y, attachTo: "root" }, bg: options.bg ?? BG.app, clip: { horizontal: true, vertical: true }, + ...(options.transition === undefined + ? {} + : { + transition: { + duration: options.transition.duration, + easing: options.transition.easing, + properties: [...options.transition.properties], + }, + }), }), ]; lines.slice(0, capacity).forEach((line, index) => { @@ -196,6 +218,20 @@ function rule(id: string, rect: Rect, glyph: string): Op[] { return ops; } +/** + * The drawer's own movement, which the renderer performs. + * + * The contextual band is four rows as an input and fourteen as a drawer, and it + * is bottom-anchored, so both its height and its top edge change when a + * suspension opens. Declaring the transition is all the harness does; Clay + * interpolates the geometry and reports `animating` until it arrives. + */ +const DRAWER_TRANSITION = { + duration: 260, + easing: "easeInOut", + properties: ["height", "y"], +} as const; + function blank(): VisualLine { return { segments: [{ text: "" }] }; } @@ -334,7 +370,13 @@ function entryHeader(entry: Entry): VisualLine { }; } -function transcriptRegion(fixture: Fixture, view: View, rect: Rect, mutation?: Mutation): Op[] { +function transcriptRegion( + fixture: Fixture, + view: View, + rect: Rect, + mutation?: Mutation, + motion?: Motion, +): Op[] { const width = Math.max(0, rect.width - 2); const lines: VisualLine[] = []; if (!fixture.entry) { @@ -355,6 +397,18 @@ function transcriptRegion(fixture: Fixture, view: View, rect: Rect, mutation?: M mutation === "clip-long-transcript" ? body : body.slice(view.anchor, view.anchor + Math.max(0, capacity - 1)); + + // While a playback runs, the target's transcript arrives a few rows at a + // time. This is the application's own interpolation: the renderer is not + // animating anything here, and when the motion settles every row is present. + const arriving = motion !== undefined && !motion.done; + if (arriving) { + const shown = Math.max(1, Math.ceil(motion.reveal * windowed.length)); + lines.push(...windowed.slice(0, shown)); + lines.push(plain("…", C.dim)); + return region("transcript", rect, lines, { bg: BG.center }); + } + lines.push(...windowed); if (mutation !== "clip-long-transcript") { const remaining = body.length - view.anchor - windowed.length; @@ -566,7 +620,7 @@ function contextualRegion(fixture: Fixture, view: View, layout: Layout, rect: Re }); lines.push(plain(drawer.hint, C.dim)); } - return region("contextual", rect, lines, { bg: BG.drawer }); + return region("contextual", rect, lines, { bg: BG.drawer, transition: DRAWER_TRANSITION }); } const input = fixture.input; @@ -584,7 +638,7 @@ function contextualRegion(fixture: Fixture, view: View, layout: Layout, rect: Re }, plain(input.placeholder ?? "", C.settledText), ]; - return region("contextual", rect, lines, { bg: BG.input }); + return region("contextual", rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION }); } function clock(seconds: number): string { @@ -714,15 +768,40 @@ export function bandGeometry(fixture: Fixture, layout: Layout, rect: Rect): Band }; } +/** + * How tall the notch for one scope depth is. + * + * Height carries depth and nothing else: the shallowest scope gets the whole + * band and each level in takes one row less. Four depths are what four rows can + * spell, so anything deeper shares the shortest notch and says so with a glyph. + */ +export function notchHeightForDepth(depth: number): number { + return Math.max(1, 4 - Math.min(depth, 3)); +} + +/** The band's rows: four a notch can reach, and the label row below them. */ +export const BAND_ROWS = [0, 1, 2, 3, 4] as const; + +/** Where the track runs, and where a notch of depth 3 sits. */ +export const TRACK_ROW = 3; + +/** The row the selection's label owns, which no notch reaches. */ +export const NOTE_ROW = 4; + +/** True where a depth is deeper than the band has heights for. */ +export function isDeeperThanBand(depth: number): boolean { + return depth > 3; +} + /** * The Execution History band. * - * Four extents distinguish what sits on the track, which is the study's own - * vocabulary read into cells: a minor checkpoint takes the track row, an entry - * boundary rises one row above it, the head takes three rows and carries its - * label, and a historical selection takes the whole band. Depth is a glyph tier - * rather than a fifth height — the band has four rows and cannot spend one per - * nesting level. + * A notch's height is its scope depth — depth 0 fills all four rows, depth 3 + * takes the track row alone — because that is the settled meaning of notch + * height. Everything else about a marker is said some other way: the playhead is + * its own full-height stem with a label, a selection is gold with a caret under + * it, an entry boundary is `◆` where an ordinary event is `●`, and a column + * holding several checkpoints shows how many. */ function footerRegion( fixture: Fixture, @@ -730,8 +809,12 @@ function footerRegion( layout: Layout, rect: Rect, mutation?: Mutation, + motion?: Motion, ): Op[] { const history = fixture.history; + // While a playback runs, the head is where the application says it is; the + // recorded head is where it will be when the motion settles. + const headAt = motion !== undefined && !motion.done ? motion.headAt : history.headAt; const flat = mutation === "flatten-notches"; const { transport, right, inner, labelWidth, trackLeft, trackWidth } = bandGeometry( fixture, @@ -739,25 +822,75 @@ function footerRegion( rect, ); - const grid: string[][] = [0, 1, 2, 3].map(() => Array.from({ length: inner }, () => " ")); - const colors: number[][] = [0, 1, 2, 3].map(() => Array.from({ length: inner }, () => C.dim)); + const grid: string[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => " ")); + const colors: number[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => C.dim)); const put = (row: number, column: number, glyph: string, color: number) => { - if (row < 0 || row > 3 || column < 0 || column >= inner) { + if (row < 0 || row >= BAND_ROWS.length || column < 0 || column >= inner) { return; } grid[row][column] = glyph; colors[row][column] = color; }; + const putText = (row: number, column: number, value: string, color: number) => { + [...value].forEach((glyph, offset) => { + put(row, column + offset, glyph, color); + }); + }; + const hasHistory = history.checkpoints.length > 0; + const selectedCheckpoint = history.checkpoints[view.checkpoint]; + + // Text goes down before the markers do, so a notch or a caret always wins the + // column it belongs in rather than being written over by a label. + const compact = labelWidth < 18; + putText(0, 0, fit(compact ? "HISTORY" : "EXECUTION HISTORY", labelWidth - 1), C.label); + putText( + 1, + 0, + fit( + hasHistory + ? compact + ? history.elapsed + : `recorded · ${history.elapsed}` + : "No recorded execution yet", + labelWidth - 1, + ), + C.dim, + ); + putText(2, 0, fit(fixture.entry ? fixture.entry.id : "", labelWidth - 1), C.dim); + putText(0, Math.max(0, inner - [...right].length), right, transport.color); + if (hasHistory) { - const headColumn = columnFor(history.headAt, history, trackLeft, trackWidth); + const headColumn = columnFor(headAt, history, trackLeft, trackWidth); + const note = + selectedCheckpoint !== undefined + ? `▲ ${clock(selectedCheckpoint.at)} · snapped · ${( + history.headAt - selectedCheckpoint.at + ).toFixed(1)}s before head` + : history.compressed + ? history.compressed.note + : "notch height is scope depth · digits mark coalesced checkpoints"; + const anchor = + selectedCheckpoint !== undefined + ? columnFor(selectedCheckpoint.at, history, trackLeft, trackWidth) + : history.compressed + ? columnFor(history.compressed.at, history, trackLeft, trackWidth) + : trackLeft; + // The label row is the band's fifth, which no notch reaches, so the note + // can sit under the marker it describes without shortening it. + const noteColumn = Math.max(0, Math.min(anchor, inner - [...note].length)); + putText(NOTE_ROW, noteColumn, note, selectedCheckpoint !== undefined ? C.gold : C.dim); + } + + if (hasHistory) { + const headColumn = columnFor(headAt, history, trackLeft, trackWidth); for (let column = trackLeft; column <= headColumn && column < inner; column += 1) { - put(2, column, "─", C.rule); + put(TRACK_ROW, column, "─", C.rule); } - const selected = history.checkpoints[view.checkpoint]; + const selected = selectedCheckpoint; const notches = notchLayout(history, trackLeft, trackWidth, mutation); for (const notch of notches) { @@ -768,103 +901,58 @@ function footerRegion( notch.checkpoints.every((checkpoint) => checkpoint.at > selected.at); const color = later ? C.dim : boundary ? C.out : C.active; const coalesced = notch.checkpoints.length > 1; + // The shallowest scope in the column owns the notch's height, so a + // coalesced column never hides the outermost thing that happened there. + const shallowest = Math.min(...notch.checkpoints.map((checkpoint) => checkpoint.depth)); + const chosen = + selected !== undefined && + notch.checkpoints.some((checkpoint) => checkpoint.at === selected.at); const glyph = coalesced ? notch.checkpoints.length < 10 ? String(notch.checkpoints.length) : "+" : boundary ? "◆" - : deepest <= 1 - ? "●" - : deepest === 2 - ? "◇" - : "·"; - put(2, notch.column, glyph, color); - if (boundary && !flat) { - put(1, notch.column, "│", color); + : isDeeperThanBand(deepest) + ? "·" + : "●"; + const notchColor = chosen ? C.gold : color; + put(TRACK_ROW, notch.column, glyph, notchColor); + // The notch rises from the track row, one row per level out, so its + // height is the depth and nothing else about it is. + const height = flat ? 1 : notchHeightForDepth(shallowest); + for (let row = TRACK_ROW - 1; row > TRACK_ROW - height; row -= 1) { + put(row, notch.column, "│", notchColor); } } if (history.compressed) { - put(2, columnFor(history.compressed.at, history, trackLeft, trackWidth), "≈", C.hold); + put(TRACK_ROW, columnFor(history.compressed.at, history, trackLeft, trackWidth), "≈", C.hold); } + // The playhead is not a notch and does not borrow a notch's meaning: it is + // a heavier stem over the notch rows, and it carries its own label. const headColor = history.transport === "live" ? C.active : C.dim; - put(2, headColumn, "┃", headColor); - if (!flat) { - put(1, headColumn, "│", headColor); - put(0, headColumn, "│", headColor); + for (const row of flat ? [TRACK_ROW] : [0, 1, 2, 3]) { + put(row, headColumn, "┃", headColor); } - if (selected !== undefined) { - const column = columnFor(selected.at, history, trackLeft, trackWidth); - for (const row of flat ? [2] : [0, 1, 2, 3]) { - put(row, column, "┃", C.gold); - } - } - } - - const putText = (row: number, column: number, value: string, color: number) => { - [...value].forEach((glyph, offset) => { - put(row, column + offset, glyph, color); - }); - }; - - const selected = history.checkpoints[view.checkpoint]; - - // A narrow band has no room for the study's full left labels, and truncating - // them to "EXECUTION HI…" says less than a shorter word that fits. The - // surface bar above already names the surface there. - const compact = labelWidth < 18; - putText(0, 0, fit(compact ? "HISTORY" : "EXECUTION HISTORY", labelWidth - 1), C.label); - putText( - 1, - 0, - fit( - hasHistory - ? compact - ? history.elapsed - : `recorded · ${history.elapsed}` - : "No recorded execution yet", - labelWidth - 1, - ), - C.dim, - ); - putText(2, 0, fit(fixture.entry ? fixture.entry.id : "", labelWidth - 1), C.dim); - putText(0, Math.max(0, inner - [...right].length), right, transport.color); - - if (hasHistory) { - const headColumn = columnFor(history.headAt, history, trackLeft, trackWidth); + // The head's label goes down last. A moving head passes over notches, and + // what a person needs to read there is where the head is, not the stem of + // a marker it happens to be beside. const headLabel = history.transport === "live" ? "LIVE" : history.transport === "idle" ? "SETTLED" : "PAUSED HEAD"; - const headColor = history.transport === "live" ? C.active : C.dim; if (headColumn + 2 + headLabel.length < inner - [...right].length) { putText(0, headColumn + 2, headLabel, headColor); - putText(1, headColumn + 2, clock(history.headAt), C.dim); + putText(1, headColumn + 2, clock(headAt), C.dim); } - const note = - selected !== undefined - ? `${clock(selected.at)} · snapped · ${(history.headAt - selected.at).toFixed(1)}s before head` - : history.compressed - ? history.compressed.note - : "digits mark coalesced checkpoints · ←/→ visits each"; - const anchor = - selected !== undefined - ? columnFor(selected.at, history, trackLeft, trackWidth) - : history.compressed - ? columnFor(history.compressed.at, history, trackLeft, trackWidth) - : trackLeft; - // Two columns clear of the marker it describes, so the note never writes - // over the notch and shortens it. - const noteColumn = Math.max(0, Math.min(anchor + 2, inner - [...note].length)); - putText(3, noteColumn, note, selected !== undefined ? C.gold : C.dim); } - const lines: VisualLine[] = [0, 1, 2, 3].map((row) => ({ + const lines: VisualLine[] = BAND_ROWS.map((row) => ({ segments: runsOf(grid[row], colors[row]), })); @@ -881,8 +969,7 @@ function footerRegion( segments: [ { text: clock(point.at), color: on ? C.gold : C.dim, width: 6 }, { - text: - point.kind === "entry" ? "◆" : point.depth <= 1 ? "●" : point.depth === 2 ? "◇" : "·", + text: point.kind === "entry" ? "◆" : isDeeperThanBand(point.depth) ? "·" : "●", color: on ? C.gold : C.active, width: 2, }, @@ -982,10 +1069,12 @@ export interface ScreenRequest { readonly view: View; readonly layout: Layout; readonly mutation?: Mutation; + /** Present only while a playback is running between two fixtures. */ + readonly motion?: Motion; } export function renderScreen(request: ScreenRequest): Op[] { - const { fixture, view, layout, mutation } = request; + const { fixture, view, layout, mutation, motion } = request; const ops: Op[] = [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: BG.app }), ]; @@ -1005,7 +1094,7 @@ export function renderScreen(request: ScreenRequest): Op[] { ops.push(...sidebarRegion(fixture, view, layout, layout.sidebar)); } if (layout.transcript) { - ops.push(...transcriptRegion(fixture, view, layout.transcript, mutation)); + ops.push(...transcriptRegion(fixture, view, layout.transcript, mutation, motion)); } if (layout.bindings) { ops.push(...bindingsRegion(fixture, layout, layout.bindings)); @@ -1016,7 +1105,7 @@ export function renderScreen(request: ScreenRequest): Op[] { ops.push(...contextualRegion(fixture, view, layout, layout.contextual)); } if (layout.footer) { - ops.push(...footerRegion(fixture, view, layout, layout.footer, mutation)); + ops.push(...footerRegion(fixture, view, layout, layout.footer, mutation, motion)); } if (layout.contextual && covering) { // Drawn last, so it lands on top of the band the study says is never diff --git a/scripts/tests/fixtures/repl-study/drawer.medium.txt b/scripts/tests/fixtures/repl-study/drawer.medium.txt index fbe75793b..2ae783bd1 100644 --- a/scripts/tests/fixtures/repl-study/drawer.medium.txt +++ b/scripts/tests/fixtures/repl-study/drawer.medium.txt @@ -18,7 +18,6 @@ drawer.medium · 140 × 38 │ │ │ │ │ │ - │ │ │ INPUT REQUIRED │ suspended at · document scope · validated against the Elicit schema │ @@ -33,7 +32,8 @@ drawer.medium · 140 × 38 │ │ │ - EXECUTION HISTORY │ LIVE LIVE [ Pause ] - recorded · 00:49 │ │ 00:49 - Entry 1 ────◆─────●────────────◇───────────·────────────────────·─·────────≈───────────·──────────●───┃ - 4.9s agent wait · compressed + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ────◆─────●────────────●───────────●────────────────────●─·────────≈───────────●──────────●───┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt index 1be6a32de..ec8a37527 100644 --- a/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt @@ -12,18 +12,3 @@ drawer.narrow.sessions · 90 × 28 implement-c31d2e · queued implementer - - - - - - - - - - - - - - - diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.txt index 1a9e02d7b..89458114f 100644 --- a/scripts/tests/fixtures/repl-study/drawer.narrow.txt +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.txt @@ -11,19 +11,3 @@ drawer.narrow · 90 × 28 ┃ A lightweight workspace for coordinating coding agents. both fields valid Submit ⌘↵ - - - - - - - - - - - - - - - - diff --git a/scripts/tests/fixtures/repl-study/drawer.too-small.txt b/scripts/tests/fixtures/repl-study/drawer.too-small.txt index 46a6ed0c3..245556ada 100644 --- a/scripts/tests/fixtures/repl-study/drawer.too-small.txt +++ b/scripts/tests/fixtures/repl-study/drawer.too-small.txt @@ -3,17 +3,3 @@ drawer.too-small · 64 × 18 Terminal too small 72 × 20 required · 64 × 18 now resize to continue - - - - - - - - - - - - - - diff --git a/scripts/tests/fixtures/repl-study/drawer.wide.txt b/scripts/tests/fixtures/repl-study/drawer.wide.txt index 187319fa8..af9420711 100644 --- a/scripts/tests/fixtures/repl-study/drawer.wide.txt +++ b/scripts/tests/fixtures/repl-study/drawer.wide.txt @@ -30,7 +30,6 @@ drawer.wide · 200 × 50 │ │ │ │ │ │ - │ │ │ INPUT REQUIRED │ suspended at · document scope · validated against the Elicit schema │ @@ -45,7 +44,8 @@ drawer.wide · 200 × 50 │ │ schema │ { - EXECUTION HISTORY │ LIVE LIVE [ Pause ] - recorded · 00:49 │ │ 00:49 - Entry 1 ──────◆────────●────────────────────◇─────────────────·─────────────────────────────────·──·──────────────≈─────────────────·─────────────────●─────┃ - 4.9s agent wait · compressed + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/empty.medium.txt b/scripts/tests/fixtures/repl-study/empty.medium.txt index 1b0ad45de..4371d2269 100644 --- a/scripts/tests/fixtures/repl-study/empty.medium.txt +++ b/scripts/tests/fixtures/repl-study/empty.medium.txt @@ -28,12 +28,9 @@ empty.medium · 140 × 38 │ │ │ │ │ │ - │ │ │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] │ Enter XMD or invoke a document… │ │ EXECUTION HISTORY IDLE [ Pause ] No recorded exec… - - diff --git a/scripts/tests/fixtures/repl-study/empty.narrow.txt b/scripts/tests/fixtures/repl-study/empty.narrow.txt index e596ebf1d..6031a9db4 100644 --- a/scripts/tests/fixtures/repl-study/empty.narrow.txt +++ b/scripts/tests/fixtures/repl-study/empty.narrow.txt @@ -26,4 +26,3 @@ empty.narrow · 90 × 28 REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] Enter XMD or invoke a document… - diff --git a/scripts/tests/fixtures/repl-study/empty.wide.txt b/scripts/tests/fixtures/repl-study/empty.wide.txt index 68a62878b..259867759 100644 --- a/scripts/tests/fixtures/repl-study/empty.wide.txt +++ b/scripts/tests/fixtures/repl-study/empty.wide.txt @@ -40,12 +40,9 @@ empty.wide · 200 × 50 │ │ │ │ │ │ - │ │ │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] │ Enter XMD or invoke a document… │ │ EXECUTION HISTORY IDLE [ Pause ] No recorded execution … - - diff --git a/scripts/tests/fixtures/repl-study/generated.medium.txt b/scripts/tests/fixtures/repl-study/generated.medium.txt index dddb3e7c4..49117ccc3 100644 --- a/scripts/tests/fixtures/repl-study/generated.medium.txt +++ b/scripts/tests/fixtures/repl-study/generated.medium.txt @@ -27,13 +27,13 @@ generated.medium · 140 × 38 │ │ Create a project README │ │ │ Provide the project name and a one-sentence description. │ │ │ ▶ ENTER │ - │ │ │ Enter the project details. │ - │ ▸ 1 more lines · ↑↓ PgUp PgDn │ + │ ▸ 2 more lines · ↑↓ PgUp PgDn │ │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] │ │ │ - EXECUTION HISTORY │ LIVE LIVE [ Pause ] - recorded · 00:48 │ │ 00:48 - Entry 1 ────◆─────●─────────────◇──────────·─────────────────────·─·─────────≈──────────·───────────●─┃ - 4.9s agent wait · compressed + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:48 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ + ────◆─────●─────────────●──────────●─────────────────────●─·─────────≈──────────●───────────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/generated.narrow.txt b/scripts/tests/fixtures/repl-study/generated.narrow.txt index f00fae617..dda99d6cc 100644 --- a/scripts/tests/fixtures/repl-study/generated.narrow.txt +++ b/scripts/tests/fixtures/repl-study/generated.narrow.txt @@ -25,5 +25,3 @@ generated.narrow · 90 × 28 │ Create a project README ▸ 4 more lines · ↑↓ PgUp PgDn DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] - - diff --git a/scripts/tests/fixtures/repl-study/generated.wide.txt b/scripts/tests/fixtures/repl-study/generated.wide.txt index e4789eafe..4f64577ff 100644 --- a/scripts/tests/fixtures/repl-study/generated.wide.txt +++ b/scripts/tests/fixtures/repl-study/generated.wide.txt @@ -40,12 +40,12 @@ generated.wide · 200 × 50 │ │ │ │ │ │ - │ │ │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] │ │ │ - EXECUTION HISTORY │ LIVE LIVE [ Pause ] - recorded · 00:48 │ │ 00:48 - Entry 1 ──────◆────────●─────────────────────◇──────────────────·────────────────────────────────·───·──────────────≈─────────────────·──────────────────●──┃ - 4.9s agent wait · compressed + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:48 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ + ──────◆────────●─────────────────────●──────────────────●────────────────────────────────●───·──────────────≈─────────────────●──────────────────●──┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/nested.medium.txt b/scripts/tests/fixtures/repl-study/nested.medium.txt index c01e552f5..e1d36bc66 100644 --- a/scripts/tests/fixtures/repl-study/nested.medium.txt +++ b/scripts/tests/fixtures/repl-study/nested.medium.txt @@ -27,13 +27,13 @@ nested.medium · 140 × 38 │ │ │ │ admitted. Nothing it returns runs until this scope admits it. │ │ │ │ │ ✓ SETTLED │ │ │ │ │ ✓ SETTLED │ - │ │ │ ● WAITING │ - │ ▸ 3 more lines · ↑↓ PgUp PgDn │ + │ ▸ 4 more lines · ↑↓ PgUp PgDn │ │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] │ │ │ - EXECUTION HISTORY │ LIVE LIVE [ Pause ] - recorded · 00:31 │ │ 00:31 - Entry 1 ──────◆────────●────────────────────◇──────────────────·────────────────────────────────·──·──┃ - digits mark coalesced checkpoints · ←/→ visits each + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────◆────────●────────────────────●──────────────────●────────────────────────────────●──·──┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt index 2425e9ea7..fe55ac798 100644 --- a/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt +++ b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt @@ -21,9 +21,3 @@ nested.narrow.bindings · 90 × 28 draft # Create a project README - - - - - - diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.txt b/scripts/tests/fixtures/repl-study/nested.narrow.txt index b5d9e1ec9..827f51dd8 100644 --- a/scripts/tests/fixtures/repl-study/nested.narrow.txt +++ b/scripts/tests/fixtures/repl-study/nested.narrow.txt @@ -25,5 +25,3 @@ nested.narrow · 90 × 28 │ │ │ ✓ SETTLED ▸ 5 more lines · ↑↓ PgUp PgDn DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] - - diff --git a/scripts/tests/fixtures/repl-study/nested.wide.txt b/scripts/tests/fixtures/repl-study/nested.wide.txt index 0a4cabfea..30c24d319 100644 --- a/scripts/tests/fixtures/repl-study/nested.wide.txt +++ b/scripts/tests/fixtures/repl-study/nested.wide.txt @@ -40,12 +40,12 @@ nested.wide · 200 × 50 │ │ │ │ │ │ - │ │ │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] │ │ │ - EXECUTION HISTORY │ LIVE LIVE [ Pause ] - recorded · 00:31 │ │ 00:31 - Entry 1 ──────────◆─────────────●────────────────────────────────◇────────────────────────────·───────────────────────────────────────────────────·────·────┃ - digits mark coalesced checkpoints · ←/→ visits each + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────────◆─────────────●────────────────────────────────●────────────────────────────●───────────────────────────────────────────────────●────·────┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-study/paused.medium.txt b/scripts/tests/fixtures/repl-study/paused.medium.txt index 39015c177..b2603a20d 100644 --- a/scripts/tests/fixtures/repl-study/paused.medium.txt +++ b/scripts/tests/fixtures/repl-study/paused.medium.txt @@ -28,12 +28,12 @@ paused.medium · 140 × 38 │ │ │ │ │ │ - │ │ │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] │ │ │ - EXECUTION HISTORY ┃ │ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] - recorded · 00:53 │ ┃ │ 00:53 - Entry 1 ──◆──●───────┃──────·───────────··────≈──────·─────●──●──●┃ - ┃ 00:12 · snapped · 41.0s before head + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ──◆──●───────●──────●───────────●·────≈──────●─────●──●──●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.history.txt b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt index 8aefe524c..d23a72ead 100644 --- a/scripts/tests/fixtures/repl-study/paused.narrow.history.txt +++ b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt @@ -1,29 +1,20 @@ paused.narrow.history · 90 × 28 EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ - HISTORY ┃ │ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] - 00:53 │ ┃ │ 00:53 - Entry 1 ─◆●─┃·───2─≈·─●●┃ - ┃ 00:12 · snapped · 41.0s before head + HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] + 00:53 ││ ││┃ 00:53 + Entry 1 ││ │ ││┃ + ─◆●─●●───2─≈●─●●┃ + ▲ 00:12 · snapped · 41.0s before head CHECKPOINTS 00:02 ◆ Entry 1 submitted REPL 00:05 ● document scope entered Entry 1 › document - 00:12 ◇ Plan entered Entry 1 › document › Plan - 00:18 · planning inputs prepared … › Plan › PlanInputs - 00:29 · planning Agent response admitted … › Plan › Prompt + 00:12 ● Plan entered Entry 1 › document › Plan + 00:18 ● planning inputs prepared … › Plan › PlanInputs + 00:29 ● planning Agent response admitted … › Plan › Prompt 00:30 · draft checked … › Plan › Check - 00:41 · review returned Approve … › Plan › Elicit + 00:41 ● review returned Approve … › Plan › Elicit 00:47 ● Plan replaced by returned program Entry 1 › document 00:49 ● project Elicit requested Entry 1 › document 00:52 ● project Elicit answered Entry 1 › document 00:53 ● confirmation Elicit requested Entry 1 › document - - - - - - - - - - diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.txt b/scripts/tests/fixtures/repl-study/paused.narrow.txt index 5cc7cdace..5d7224093 100644 --- a/scripts/tests/fixtures/repl-study/paused.narrow.txt +++ b/scripts/tests/fixtures/repl-study/paused.narrow.txt @@ -25,5 +25,3 @@ paused.narrow · 90 × 28 DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] - - diff --git a/scripts/tests/fixtures/repl-study/paused.too-small.txt b/scripts/tests/fixtures/repl-study/paused.too-small.txt index 89987e9b3..863e0e722 100644 --- a/scripts/tests/fixtures/repl-study/paused.too-small.txt +++ b/scripts/tests/fixtures/repl-study/paused.too-small.txt @@ -3,17 +3,3 @@ paused.too-small · 64 × 18 Terminal too small 72 × 20 required · 64 × 18 now resize to continue - - - - - - - - - - - - - - diff --git a/scripts/tests/fixtures/repl-study/paused.wide.txt b/scripts/tests/fixtures/repl-study/paused.wide.txt index 6ff020659..fa11e9955 100644 --- a/scripts/tests/fixtures/repl-study/paused.wide.txt +++ b/scripts/tests/fixtures/repl-study/paused.wide.txt @@ -40,12 +40,12 @@ paused.wide · 200 × 50 │ │ │ │ │ │ - │ │ │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] │ │ │ - EXECUTION HISTORY ┃ │ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] - recorded · 00:53 │ ┃ │ 00:53 - Entry 1 ───◆───●──────────┃────────·───────────────·─·──────≈────────·────────●──●────●┃ - ┃ 00:12 · snapped · 41.0s before head + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt new file mode 100644 index 000000000..4b012e3ff --- /dev/null +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt @@ -0,0 +1,51 @@ +play.generated-drawer.midpoint · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ … │ + review-b72e1d │ │ + ● responding reviewer · turn 1 · streaming │ │ + streaming · background update · selection u… │ │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ │ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●───┃ ● + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt new file mode 100644 index 000000000..ad1c7ed0d --- /dev/null +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt @@ -0,0 +1,51 @@ +play.generated-drawer.settled · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.start.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.start.txt new file mode 100644 index 000000000..f96308dd5 --- /dev/null +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.start.txt @@ -0,0 +1,51 @@ +play.generated-drawer.start · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ … │ readme + │ plan-a91f7c │ │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ # Northstar + │ │ + review-b72e1d │ │ + ● responding reviewer · turn 1 · streaming │ │ + streaming · background update · selection u… │ │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:48 + Entry 1 │ │ │ │ ┃ │ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●──┃ ● + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/settled.medium.txt b/scripts/tests/fixtures/repl-study/settled.medium.txt index 383d04c81..0a9251f6b 100644 --- a/scripts/tests/fixtures/repl-study/settled.medium.txt +++ b/scripts/tests/fixtures/repl-study/settled.medium.txt @@ -28,12 +28,12 @@ settled.medium · 140 × 38 │ │ │ │ │ │ - │ │ │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] │ Enter XMD or invoke a document… │ │ - EXECUTION HISTORY │ SETTLED IDLE [ Pause ] - recorded · 01:01 │ │ 01:01 - Entry 1 ───◆───●─────────◇────────·──────────────·─·──────≈───────·────────●──●───●─●────●────●┃ - 4.9s agent wait · compressed + EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ │ │ │ │ │ │┃ 01:01 + Entry 1 │ │ │ │ │ │ │ │ │┃ + ───◆───●─────────●────────●──────────────●─·──────≈───────●────────●──●───●─●────●────●┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-study/settled.narrow.txt b/scripts/tests/fixtures/repl-study/settled.narrow.txt index 8e487cc20..9fc409451 100644 --- a/scripts/tests/fixtures/repl-study/settled.narrow.txt +++ b/scripts/tests/fixtures/repl-study/settled.narrow.txt @@ -26,4 +26,3 @@ settled.narrow · 90 × 28 REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] Enter XMD or invoke a document… - diff --git a/scripts/tests/fixtures/repl-study/settled.wide.txt b/scripts/tests/fixtures/repl-study/settled.wide.txt index 7c724b0e4..cacb41ace 100644 --- a/scripts/tests/fixtures/repl-study/settled.wide.txt +++ b/scripts/tests/fixtures/repl-study/settled.wide.txt @@ -40,12 +40,12 @@ settled.wide · 200 × 50 │ │ │ │ │ │ - │ │ │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] │ Enter XMD or invoke a document… │ │ - EXECUTION HISTORY │ SETTLED IDLE [ Pause ] - recorded · 01:01 │ │ 01:01 - Entry 1 ─────◆──────●───────────────◇─────────────·────────────────────────·─·───────────≈─────────────·─────────────●───●──────●──●────────●──────●─┃ - 4.9s agent wait · compressed + EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ │ │ │ │ │ │ ┃ 01:01 + Entry 1 │ │ │ │ │ │ │ │ │ ┃ + ─────◆──────●───────────────●─────────────●────────────────────────●─·───────────≈─────────────●─────────────●───●──────●──●────────●──────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index af5b5b63f..482b5507c 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -17,12 +17,16 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { exec } from "@effectionx/process"; +import type { Operation } from "effection"; +import { z } from "zod"; import { exists, readdir, readTextFile } from "@effectionx/fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { captureAll, + captureText, + playFrames, PROFILE_SIZES, renderFrame, renderInto, @@ -32,11 +36,22 @@ import { import type { Size } from "../repl-study/capture.ts"; import { fixture, fixtures } from "../repl-study/fixtures.ts"; import { terminalModes } from "../repl-study/host.ts"; +import type { TraceEntry } from "../repl-study/host.ts"; import type { HarnessState } from "../repl-study/host.ts"; +import { motionAt, playbackBetween } from "../repl-study/playback.ts"; import { intersects, layoutFor, MINIMUM, PANE_MINIMUMS, profileFor } from "../repl-study/layout.ts"; import type { Profile } from "../repl-study/layout.ts"; import { MUTATIONS } from "../repl-study/mutations.ts"; -import { bandGeometry, columnFor, notchLayout, transcriptLines } from "../repl-study/render.ts"; +import { + bandGeometry, + BAND_ROWS, + columnFor, + NOTE_ROW, + TRACK_ROW, + notchHeightForDepth, + notchLayout, + transcriptLines, +} from "../repl-study/render.ts"; import { applyAnsi, createGrid, @@ -53,29 +68,101 @@ const MAIN = "scripts/repl-study/main.ts"; /** The band rows of a rendered frame, as a grid of glyphs. */ function bandRows(text: string, size: Size): string[] { const rows = text.split("\n"); - return [0, 1, 2, 3].map((offset) => rows[size.rows - 4 + offset] ?? ""); + return BAND_ROWS.map((offset) => rows[size.rows - BAND_ROWS.length + offset] ?? ""); } function glyphAt(row: string, column: number): string { return [...row][column] ?? " "; } -/** How many band rows carry something at this column. */ +/** + * How tall the notch in this column is. + * + * Only the rows a notch can reach are counted: the label row below the track + * belongs to the selection's note, and counting it would make a described + * marker look one level shallower than it is. + */ function notchHeight(rows: readonly string[], column: number): number { - return rows.filter((row) => { + return rows.slice(0, NOTE_ROW).filter((row) => { const glyph = glyphAt(row, column); return glyph !== " " && glyph !== "─"; }).length; } +function clockOf(seconds: number): string { + const minutes = String(Math.floor(seconds / 60)).padStart(2, "0"); + return `${minutes}:${String(seconds % 60).padStart(2, "0")}`; +} + +/** + * One column per depth, taken only from notches that are not sharing. + * + * A coalesced column takes its height from the shallowest scope in it, so + * measuring what a depth looks like needs a marker that has a column to itself. + */ +function soleNotchColumns( + subject: ReturnType, + geometry: { readonly trackLeft: number; readonly trackWidth: number }, +): Map { + const byDepth = new Map(); + for (const notch of notchLayout(subject.history, geometry.trackLeft, geometry.trackWidth)) { + if (notch.checkpoints.length !== 1) { + continue; + } + const [point] = notch.checkpoints; + if (!byDepth.has(point.depth)) { + byDepth.set(point.depth, notch.column); + } + } + return byDepth; +} + +/** + * Run a command with a pseudo-terminal attached. + * + * `script` is the one pty allocator both a developer's macOS machine and a + * Linux runner have, and its two dialects disagree about argument order. + */ +function ptyCommand(_command: string): string { + return "script"; +} + +function ptyArguments(command: string): string[] { + const full = `deno run --allow-all ${command}`; + if (Deno.build.os === "darwin") { + return ["-q", "/dev/null", ...full.split(" ")]; + } + if (Deno.build.os === "linux") { + return ["-qec", full, "/dev/null"]; + } + throw new Error(`this evidence needs a pseudo-terminal, and ${Deno.build.os} has no script(1)`); +} + +function* readTrace(path: string): Operation { + const text = yield* readTextFile(path); + return text + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => TRACE_ENTRY.parse(JSON.parse(line))); +} + +/** Parsed rather than cast, so a malformed trace fails here and not later. */ +const TRACE_ENTRY = z.object({ + frame: z.number(), + elapsedMs: z.number(), + deltaTime: z.number(), + animating: z.boolean(), + motionDone: z.boolean().nullable(), + bytes: z.number(), +}); + describe("fixture rendering", () => { it("renders every committed capture exactly", function* () { const captures = yield* captureAll(); expect(captures.length).toBeGreaterThan(0); for (const capture of captures) { const golden = yield* readTextFile(join(GOLDENS, `${capture.name}.txt`)); - const header = `${capture.name} · ${capture.size.cols} × ${capture.size.rows}\n`; - expect(`${header}${capture.frame.text}\n`).toBe(golden); + expect(captureText(capture)).toBe(golden); } }); @@ -112,8 +199,7 @@ describe("fixture rendering", () => { mutation: "stale-frame", }); const golden = yield* readTextFile(join(GOLDENS, "settled.wide.txt")); - const header = `settled.wide · ${PROFILE_SIZES.wide.cols} × ${PROFILE_SIZES.wide.rows}\n`; - expect(`${header}${stale.text}\n`).not.toBe(golden); + expect(golden).not.toContain(stale.text.replace(/\n+$/, "")); expect(stale.text).not.toContain("Entry 1 ✓ completed"); }); }); @@ -198,7 +284,7 @@ describe("the history footer", () => { surface: "transcript", }); expect(layout.footer).toBeDefined(); - expect(layout.footer?.y).toBe(size.rows - 4); + expect(layout.footer?.y).toBe(size.rows - BAND_ROWS.length); expect(layout.footer?.width).toBe(size.cols); expect(layout.contextual).toBeDefined(); expect(intersects(layout.contextual!, layout.footer!)).toBe(false); @@ -209,7 +295,7 @@ describe("the history footer", () => { // transport at the right, and the track with the head on it between them. expect(rows[0]).toContain("EXECUTION HISTORY"); expect(rows[0]).toContain("[ Pause ]"); - expect(rows[2]).toContain("┃"); + expect(rows[TRACK_ROW]).toContain("┃"); } }); @@ -236,13 +322,12 @@ describe("the history footer", () => { }); const rows = bandRows(frame.text, size); expect(rows[0]).not.toContain("[ Pause ]"); - expect(rows[2]).not.toContain("┃"); + expect(rows[TRACK_ROW]).not.toContain("┃"); }); - it("gives four distinguishable notch heights", function* () { + it("makes a notch's height its scope depth", function* () { const size = PROFILE_SIZES.wide; - const subject = fixture("paused"); - const view = initialView(subject); + const subject = fixture("settled"); const layout = layoutFor({ cols: size.cols, rows: size.rows, @@ -250,34 +335,73 @@ describe("the history footer", () => { surface: "transcript", }); const geometry = bandGeometry(subject, layout, layout.footer!); - const frame = yield* renderFrame({ fixture: subject, view, size }); + const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); const rows = bandRows(frame.text, size); - const offset = 1; + const byDepth = soleNotchColumns(subject, geometry); + + // The four heights four rows can spell, one for each depth. + for (const depth of [0, 1, 2, 3]) { + const column = byDepth.get(depth); + expect({ + depth, + height: column === undefined ? "no notch of its own" : notchHeight(rows, 1 + column), + }).toEqual({ depth, height: notchHeightForDepth(depth) }); + } + + // Anything deeper shares the shortest notch rather than inventing a height. + const deeper = byDepth.get(4); + if (deeper !== undefined) { + expect(notchHeight(rows, 1 + deeper)).toBe(notchHeightForDepth(3)); + } + }); + it("says selection, the head and entry status some way other than height", function* () { + const size = PROFILE_SIZES.wide; + const subject = fixture("paused"); const history = subject.history; - const selected = history.checkpoints[view.checkpoint]; - const boundary = history.checkpoints.find((point) => point.kind === "entry")!; - const minor = history.checkpoints.find( + const layout = layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: false, + surface: "transcript", + }); + const geometry = bandGeometry(subject, layout, layout.footer!); + const byDepth = soleNotchColumns(subject, geometry); + const deepColumn = byDepth.get(3) ?? byDepth.get(2)!; + const deepPoint = history.checkpoints.find( (point) => - point.kind === "event" && - notchLayout(history, geometry.trackLeft, geometry.trackWidth).find( - (notch) => - notch.column === columnFor(point.at, history, geometry.trackLeft, geometry.trackWidth), - )!.checkpoints.length === 1, + columnFor(point.at, history, geometry.trackLeft, geometry.trackWidth) === deepColumn, )!; - const column = (at: number) => - offset + columnFor(at, history, geometry.trackLeft, geometry.trackWidth); - expect(notchHeight(rows, column(selected.at))).toBe(4); - expect(notchHeight(rows, column(history.headAt))).toBe(3); - expect(notchHeight(rows, column(boundary.at))).toBe(2); - expect(notchHeight(rows, column(minor.at))).toBe(1); + const unselected = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), checkpoint: -1 }, + size, + }); + const selected = yield* renderFrame({ + fixture: subject, + view: { ...initialView(subject), checkpoint: history.checkpoints.indexOf(deepPoint) }, + size, + }); + + // Selecting a checkpoint must not make its notch taller. Height belongs to + // depth; selection is said with the caret and the label instead. + expect(notchHeight(bandRows(selected.text, size), 1 + deepColumn)).toBe( + notchHeight(bandRows(unselected.text, size), 1 + deepColumn), + ); + expect(selected.text).toContain("▲"); + expect(selected.text).toContain(`${clockOf(deepPoint.at)} · snapped`); + + // An entry boundary is a glyph, and the playhead is its own stem and label. + const boundary = history.checkpoints.find((point) => point.kind === "entry")!; + const boundaryColumn = columnFor(boundary.at, history, geometry.trackLeft, geometry.trackWidth); + expect(glyphAt(bandRows(unselected.text, size)[TRACK_ROW], 1 + boundaryColumn)).toBe("◆"); + expect(bandRows(unselected.text, size)[0]).toContain("PAUSED HEAD"); }); - it("rejects one notch height for every marker", function* () { + it("rejects one notch height for every depth", function* () { const size = PROFILE_SIZES.wide; - const subject = fixture("paused"); - const view = initialView(subject); + const subject = fixture("settled"); const layout = layoutFor({ cols: size.cols, rows: size.rows, @@ -285,12 +409,20 @@ describe("the history footer", () => { surface: "transcript", }); const geometry = bandGeometry(subject, layout, layout.footer!); - const frame = yield* renderFrame({ fixture: subject, view, size, mutation: "flatten-notches" }); + const frame = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size, + mutation: "flatten-notches", + }); const rows = bandRows(frame.text, size); - const selected = subject.history.checkpoints[view.checkpoint]; - const column = - 1 + columnFor(selected.at, subject.history, geometry.trackLeft, geometry.trackWidth); - expect(notchHeight(rows, column)).toBe(1); + const byDepth = soleNotchColumns(subject, geometry); + for (const depth of [0, 1, 2]) { + const column = byDepth.get(depth); + if (column !== undefined) { + expect(notchHeight(rows, 1 + column)).toBe(1); + } + } }); }); @@ -602,11 +734,15 @@ describe("the boundary this experiment keeps", () => { } }); - it("declares every control the evidence uses", function* () { + it("uses every control it declares", function* () { + // A control nobody passes is a claim nobody is checking, so the suite's own + // source has to mention each one. + const source = yield* readTextFile( + fileURLToPath(new URL("./repl-study.test.ts", import.meta.url)), + ); for (const mutation of MUTATIONS) { - expect(typeof mutation).toBe("string"); + expect({ mutation, used: source.includes(mutation) }).toEqual({ mutation, used: true }); } - expect(MUTATIONS.length).toBe(8); }); it("writes its captures where the goldens live", function* () { @@ -617,3 +753,144 @@ describe("the boundary this experiment keeps", () => { expect(written.filter((name) => name.endsWith(".txt")).length).toBe(captures.length); }); }); + +describe("animation", () => { + const PLAYBACK = playbackBetween("generated", "drawer")!; + + it("interpolates the drawer itself and reports that it is still moving", function* () { + const frames = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + expect(frames.length).toBeGreaterThan(3); + + // The renderer owns this one: the harness declared a transition and then + // only supplied time. + expect(frames.some((frame) => frame.animating)).toBe(true); + expect(frames[frames.length - 1].animating).toBe(false); + + const heights = frames.map((frame) => frame.bounds.contextual?.height ?? 0); + const first = heights[0]; + const last = heights[heights.length - 1]; + expect(last).toBeGreaterThan(first); + // It arrives by passing through, rather than by jumping. + expect(heights.some((height) => height > first && height < last)).toBe(true); + for (const [index, height] of heights.entries()) { + if (index > 0) { + expect(height).toBeGreaterThanOrEqual(heights[index - 1]); + } + } + }); + + it("never lets the drawer's movement cover the history footer", function* () { + const frames = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + for (const frame of frames) { + const footer = frame.bounds.footer; + const contextual = frame.bounds.contextual; + expect(footer?.y).toBe(PROFILE_SIZES.wide.rows - BAND_ROWS.length); + if (contextual !== undefined && footer !== undefined) { + expect(contextual.y + contextual.height).toBeLessThanOrEqual(footer.y + 1); + } + } + }); + + it("moves the head and reveals the transcript on the application's own clock", function* () { + const start = motionAt(PLAYBACK, 0); + const middle = motionAt(PLAYBACK, PLAYBACK.durationMs / 2); + const end = motionAt(PLAYBACK, PLAYBACK.durationMs); + + expect(start.progress).toBe(0); + expect(end.done).toBe(true); + expect(middle.headAt).toBeGreaterThan(start.headAt); + expect(end.headAt).toBeGreaterThan(middle.headAt); + expect(end.headAt).toBe(fixture(PLAYBACK.to).history.headAt); + + // Time in, frame out: the same instant renders identically every time. + const once = yield* renderFrame({ + fixture: fixture(PLAYBACK.to), + view: initialView(fixture(PLAYBACK.to)), + size: PROFILE_SIZES.wide, + motion: middle, + }); + const twice = yield* renderFrame({ + fixture: fixture(PLAYBACK.to), + view: initialView(fixture(PLAYBACK.to)), + size: PROFILE_SIZES.wide, + motion: middle, + }); + expect(once.text).toBe(twice.text); + }); + + it("captures a start, a midpoint and a settled frame that differ", function* () { + const start = yield* readTextFile( + join(GOLDENS, `play.${PLAYBACK.from}-${PLAYBACK.to}.start.txt`), + ); + const midpoint = yield* readTextFile( + join(GOLDENS, `play.${PLAYBACK.from}-${PLAYBACK.to}.midpoint.txt`), + ); + const settled = yield* readTextFile( + join(GOLDENS, `play.${PLAYBACK.from}-${PLAYBACK.to}.settled.txt`), + ); + expect(start).not.toBe(midpoint); + expect(midpoint).not.toBe(settled); + expect(settled).toContain("INPUT REQUIRED"); + expect(settled).toContain("EXECUTION HISTORY"); + }); + + it("draws one frame and stops when nothing schedules the next", function* () { + const directory = yield* useTempDirectory("repl-study-stalled"); + const trace = join(directory, "stalled.jsonl"); + const command = `${MAIN} --play generated drawer --frames 30 --trace ${trace} --mutation never-tick`; + yield* exec(ptyCommand(command), { cwd: ROOT, arguments: ptyArguments(command) }).join(); + const drawn = yield* readTrace(trace); + expect(drawn.length).toBe(1); + expect(drawn[0].motionDone).toBe(false); + }); + + it("rejects a reconstruction that lands halfway through a transition", function* () { + const subject = fixture("settled"); + const halfway = yield* renderFrame({ + fixture: subject, + view: initialView(subject), + size: PROFILE_SIZES.wide, + mutation: "restore-mid-animation", + }); + const golden = yield* readTextFile(join(GOLDENS, "settled.wide.txt")); + expect(golden).not.toContain(halfway.text.replace(/\n+$/, "")); + }); +}); + +describe("animation in a real terminal", () => { + it("keeps drawing without a keystroke", function* () { + const directory = yield* useTempDirectory("repl-study-pty"); + const trace = join(directory, "pty.jsonl"); + yield* exec(ptyCommand(`${MAIN} --play generated drawer --frames 80 --trace ${trace}`), { + cwd: ROOT, + arguments: ptyArguments(`${MAIN} --play generated drawer --frames 80 --trace ${trace}`), + }).join(); + + const drawn = yield* readTrace(trace); + // Nothing was typed at it, and it went on drawing anyway. + expect(drawn.length).toBeGreaterThan(10); + expect( + drawn.every((entry, index) => index === 0 || entry.elapsedMs > drawn[index - 1].elapsedMs), + ).toBe(true); + expect(drawn.some((entry) => entry.animating)).toBe(true); + expect(drawn[drawn.length - 1].motionDone).toBe(true); + expect(drawn.filter((entry) => entry.bytes > 0).length).toBeGreaterThan(3); + }); + + it("stops the clock and restores the terminal when interrupted mid-animation", function* () { + const directory = yield* useTempDirectory("repl-study-interrupt"); + const trace = join(directory, "interrupted.jsonl"); + const command = `${MAIN} --play generated drawer --frames 200 --interrupt-after-frames 4 --trace ${trace}`; + const result = yield* exec(ptyCommand(command), { + cwd: ROOT, + arguments: ptyArguments(command), + }).join(); + + const drawn = yield* readTrace(trace); + // The interruption arrived while the transition was still running, and no + // frame was drawn after it: the clock went down with the session. + expect(drawn.length).toBe(4); + expect(drawn[drawn.length - 1].motionDone).toBe(false); + expect(result.stdout.endsWith(new TextDecoder().decode(terminalModes().revert))).toBe(true); + }); +}); From 3662ae0269d6e1f0f6402529f3ced648cdf4f323 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 22 Sep 2026 08:46:49 -0400 Subject: [PATCH 03/57] =?UTF-8?q?=F0=9F=A9=B9=20Speak=20the=20renderer's?= =?UTF-8?q?=20unit:=20transitions=20are=20seconds=20(#838)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@bomb.sh/tty` measures both `duration` and `deltaTime` in seconds. This harness declared `duration: 260` and advanced frames by `deltaTime: 16`, meaning milliseconds on both sides — and because both were scaled by the same thousand, every test still passed and every capture came out identical. The contract was recorded wrong while the picture looked right. The boundary now converts once. `FRAME_MS` stays 16 for `sleep()` and the playback clock, the renderer is advanced by `FRAME_SECONDS`, the drawer's transition is `DRAWER_TRANSITION_SECONDS = 0.26`, and the trace records `deltaSeconds` so the unit is unmistakable where it is read. Two cases pin it where the harness cannot mark its own homework, following the library's own arithmetic: a 0.2 transition supplied 0.1 is still animating and supplied 0.3 in total is not, and one frame of seconds must *not* finish a transition declared in them. The second is the one that fails under the old reading. `RESULT.md` also claimed the renderer never measures time itself. It does: with no `deltaTime` option it advances by the monotonic time since the previous render, and passes 0 after a frame that reported `animating: false`. This harness overrides that deliberately, which is what makes a captured transition reproducible — and it now says so, along with the frame of lag before the animating flag clears. No capture changed, which is exactly what made the defect quiet. --- scripts/repl-study/README.md | 12 ++++--- scripts/repl-study/RESULT.md | 27 +++++++++++---- scripts/repl-study/capture.ts | 18 ++++++---- scripts/repl-study/host.ts | 18 ++++++---- scripts/repl-study/render.ts | 12 ++++++- scripts/tests/repl-study.test.ts | 56 +++++++++++++++++++++++++++++++- 6 files changed, 118 insertions(+), 25 deletions(-) diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index a0be8f430..887df281a 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -46,8 +46,10 @@ fixture. Two kinds of motion run during one: - **the renderer's own.** The contextual band declares a transition, so when a suspension opens the drawer `@bomb.sh/tty` interpolates its height and top edge - and reports `animating` until it arrives. The harness supplies time and nothing - else. + and reports `animating` until it arrives. The harness supplies the time and + nothing else — in seconds, which is the unit the renderer measures transitions + in, converted once at that boundary from the milliseconds everything else here + counts in. - **the application's own.** The recorded head travels along the track and the target's transcript arrives a few rows at a time, both interpolated here from elapsed milliseconds. @@ -61,9 +63,9 @@ that has already been restored. Three flags exist for running a playback without a person watching: `--frames ` leaves once the playback settles or that many frames have been drawn, `--interrupt-after-frames ` raises a real `SIGINT` at the harness mid -transition, and `--trace ` records what every frame did — elapsed time, the -delta it was given, whether the renderer was animating, and how many bytes it -emitted. +transition, and `--trace ` records what every frame did — elapsed +milliseconds, the seconds it advanced the renderer by, whether the renderer was +animating, and how many bytes it emitted. ## What it shows diff --git a/scripts/repl-study/RESULT.md b/scripts/repl-study/RESULT.md index 49d70d892..51ae2a377 100644 --- a/scripts/repl-study/RESULT.md +++ b/scripts/repl-study/RESULT.md @@ -31,14 +31,29 @@ dimension was exercised through the captures and the suite. The renderer animates, and the frame loop that drives it is small. - **A declared transition is interpolated by the renderer.** Giving the - contextual band `transition: { duration: 260, easing: "easeInOut", properties: + contextual band `transition: { duration: 0.26, easing: "easeInOut", properties: ["height", "y"] }` is the whole of what the harness does about the drawer's - movement: `render()` then reports `animating: true` and reports interpolated - cell bounds until it arrives. In one measured playback, seventeen of forty-one + movement: `render()` then reports `animating: true` and interpolated cell + bounds until it arrives. In one measured playback, seventeen of forty-one frames were still interpolating. -- **`deltaTime` is milliseconds, and the renderer never measures time itself.** - A frame given `deltaTime: 0` — which is what a keystroke or a resize gets — - advances no transition, so typing during a transition does not skip it forward. +- **The renderer's unit is seconds, for both `duration` and `deltaTime`.** This + harness counts milliseconds everywhere else, because that is what `sleep()` + and the playback clock speak, and converts once at the boundary. Getting this + wrong is quiet: scaling both sides by the same thousand produces the same + frame count and the same picture, so the mistake survives every test that only + compares the harness with itself. What catches it is the library's own + arithmetic — a 0.2 transition is halfway after 0.1 — and a case asserting that + one frame of seconds does *not* finish one. +- **The renderer times itself unless you tell it not to.** With no `deltaTime` + option it advances by the monotonic time since the previous render, and it + passes 0 after a frame that reported `animating: false`. This harness always + supplies the value: ticks get their own elapsed, and a keystroke or a resize + gets `0`, so typing during a transition does not skip it forward — and a + capture of a transition is reproducible rather than a race with the clock. +- **A transition's flag clears one frame after it arrives.** The library's own + test spends 0.3 seconds on a 0.2-second transition for this reason. A loop + that stopped at the first frame reporting the final geometry would leave the + last frame undrawn. - **Some interpolated frames emit nothing.** Sub-cell movement changes no cell, so a loop that stopped when a frame produced zero bytes would freeze halfway. `animating` is the condition to schedule on, never the byte count. diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index b55a028de..6e112f6f8 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -74,8 +74,13 @@ export interface FrameRequest { readonly surface?: SurfaceName; /** Present only while a playback is running between two fixtures. */ readonly motion?: Motion; - /** Milliseconds since the previous frame, which native transitions consume. */ - readonly deltaTime?: number; + /** + * Seconds since the previous frame, which is the unit the renderer measures + * transitions in. Leaving it out hands the renderer its own monotonic clock; + * supplying it — including `0` — overrides that, which is what makes a + * captured transition reproducible. + */ + readonly deltaSeconds?: number; } export function* useTerm(size: Size): Operation { @@ -110,9 +115,10 @@ export function renderInto(term: Term, request: FrameRequest): Frame { surface: request.surface ?? view.surface, mutation, }); - const result = term.render(renderScreen({ fixture: subject, view, layout, mutation, motion }), { - deltaTime: request.deltaTime ?? 0, - }); + const result = term.render( + renderScreen({ fixture: subject, view, layout, mutation, motion }), + request.deltaSeconds === undefined ? {} : { deltaTime: request.deltaSeconds }, + ); if (result.errors.length > 0) { throw new Error(`the renderer reported ${JSON.stringify(result.errors)}`); } @@ -157,7 +163,7 @@ export function* playFrames( view, size, motion, - deltaTime: index === 0 ? 0 : frameMs, + deltaSeconds: index === 0 ? 0 : frameMs / 1000, }); applyAnsi(screen, frame.ansi); frames.push({ ...frame, text: gridText(screen) }); diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 6dde73538..3b29ae31d 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -238,7 +238,7 @@ function draw( write: (bytes: Uint8Array) => void, mutation?: Mutation, motion?: Motion, - deltaTime = 0, + deltaMs = 0, ): Painted { // One render path for the harness and for the captures, so what a person sees // in a terminal and what a golden records cannot drift apart. @@ -248,7 +248,9 @@ function draw( size: { cols: state.cols, rows: state.rows }, mutation, motion, - deltaTime, + // The harness counts in milliseconds and the renderer in seconds. The + // conversion happens here, once, at the only place the two meet. + deltaSeconds: deltaMs / 1000, }); write(frame.ansi); return { animating: frame.animating, bytes: frame.ansi.length }; @@ -257,6 +259,9 @@ function draw( /** A frame every sixteen milliseconds, which is the rate the study was made at. */ export const FRAME_MS = 16; +/** The same frame, in the seconds the renderer measures transitions in. */ +export const FRAME_SECONDS = FRAME_MS / 1000; + /** * The clock that keeps an animation moving when nothing else is happening. * @@ -275,7 +280,8 @@ function* ticker(events: Signal): Operation { export interface TraceEntry { readonly frame: number; readonly elapsedMs: number; - readonly deltaTime: number; + /** What the renderer was advanced by, in its own unit: seconds. */ + readonly deltaSeconds: number; readonly animating: boolean; readonly motionDone: boolean | null; readonly bytes: number; @@ -366,14 +372,14 @@ export function* runInteractive(options: InteractiveOptions): Operation { * application's own transition has not finished, and halted as soon as both * have settled — so an idle REPL schedules nothing at all. */ - const paint = function* (deltaTime: number): Operation { + const paint = function* (deltaMs: number): Operation { const motion = playback === undefined ? undefined : motionAt(playback, elapsed); - const painted = draw(term, state, write, options.mutation, motion, deltaTime); + const painted = draw(term, state, write, options.mutation, motion, deltaMs); frames += 1; options.trace?.push({ frame: frames, elapsedMs: elapsed, - deltaTime, + deltaSeconds: deltaMs / 1000, animating: painted.animating, motionDone: motion === undefined ? null : motion.done, bytes: painted.bytes, diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index df72ce175..c0402e1a5 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -218,6 +218,16 @@ function rule(id: string, rect: Rect, glyph: string): Op[] { return ops; } +/** + * How long the drawer takes to arrive, in the renderer's own unit. + * + * `@bomb.sh/tty` measures transitions in **seconds** — both this duration and + * the `deltaTime` a frame is advanced by. The harness thinks in milliseconds + * everywhere else, because that is what `sleep()` and the playback clock speak, + * and converts once at the renderer's boundary. + */ +export const DRAWER_TRANSITION_SECONDS = 0.26; + /** * The drawer's own movement, which the renderer performs. * @@ -227,7 +237,7 @@ function rule(id: string, rect: Rect, glyph: string): Op[] { * interpolates the geometry and reports `animating` until it arrives. */ const DRAWER_TRANSITION = { - duration: 260, + duration: DRAWER_TRANSITION_SECONDS, easing: "easeInOut", properties: ["height", "y"], } as const; diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index 482b5507c..2652f8e36 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -17,6 +17,8 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { useTempDirectory } from "@executablemd/test-support/temp"; import { exec } from "@effectionx/process"; +import { close, fixed, grow, open, rgba, text } from "@bomb.sh/tty"; +import type { Op } from "@bomb.sh/tty"; import type { Operation } from "effection"; import { z } from "zod"; import { exists, readdir, readTextFile } from "@effectionx/fs"; @@ -39,12 +41,14 @@ import { terminalModes } from "../repl-study/host.ts"; import type { TraceEntry } from "../repl-study/host.ts"; import type { HarnessState } from "../repl-study/host.ts"; import { motionAt, playbackBetween } from "../repl-study/playback.ts"; +import { FRAME_SECONDS } from "../repl-study/host.ts"; import { intersects, layoutFor, MINIMUM, PANE_MINIMUMS, profileFor } from "../repl-study/layout.ts"; import type { Profile } from "../repl-study/layout.ts"; import { MUTATIONS } from "../repl-study/mutations.ts"; import { bandGeometry, BAND_ROWS, + DRAWER_TRANSITION_SECONDS, columnFor, NOTE_ROW, TRACK_ROW, @@ -150,7 +154,7 @@ function* readTrace(path: string): Operation { const TRACE_ENTRY = z.object({ frame: z.number(), elapsedMs: z.number(), - deltaTime: z.number(), + deltaSeconds: z.number(), animating: z.boolean(), motionDone: z.boolean().nullable(), bytes: z.number(), @@ -779,6 +783,56 @@ describe("animation", () => { } }); + it("declares and advances transitions in the renderer's unit, which is seconds", function* () { + // Scaling both sides by the same thousand is invisible: milliseconds of + // delta against a duration also written in milliseconds produces the same + // frame count and the same picture. So this pins the unit against the + // library's own documented arithmetic — a 0.2 transition is halfway after + // 0.1 and finished after 0.2 — which a millisecond reading cannot satisfy. + const term = yield* useTerm({ cols: 20, rows: 8 }); + const box = (color: number): Op[] => [ + open("root", { layout: { width: grow(), height: grow(), direction: "ttb" } }), + open("box", { + layout: { width: grow(), height: fixed(4) }, + bg: color, + transition: { duration: 0.2, easing: "linear", properties: ["bg"] }, + }), + text("box"), + close(), + close(), + ]; + + term.render(box(rgba(255, 0, 0)), { deltaTime: 0 }); + term.render(box(rgba(0, 0, 255)), { deltaTime: 0 }); + expect(term.render(box(rgba(0, 0, 255)), { deltaTime: 0.1 }).animating).toBe(true); + term.render(box(rgba(0, 0, 255)), { deltaTime: 0.15 }); + // A frame of lag: the flag clears on the render after the one that arrives, + // which is why the library's own test spends 0.3 on a 0.2 transition. + expect(term.render(box(rgba(0, 0, 255)), { deltaTime: 0.05 }).animating).toBe(false); + + // And the reading that would make this harness's own numbers wrong: one + // frame's worth of seconds must not finish a transition declared in them. + const other = yield* useTerm({ cols: 20, rows: 8 }); + other.render(box(rgba(255, 0, 0)), { deltaTime: 0 }); + other.render(box(rgba(0, 0, 255)), { deltaTime: 0 }); + expect(other.render(box(rgba(0, 0, 255)), { deltaTime: FRAME_SECONDS }).animating).toBe(true); + }); + + it("keeps the harness's own transition in that unit", function* () { + // A duration meant as milliseconds would read as several minutes here. + expect(DRAWER_TRANSITION_SECONDS).toBeLessThan(2); + expect(DRAWER_TRANSITION_SECONDS).toBeGreaterThan(0); + }); + + it("supplies every captured frame its own delta, in seconds", function* () { + const frames = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + const animating = frames.filter((frame) => frame.animating).length; + // Sixteen milliseconds is 0.016 of the renderer's seconds, so a 0.26s + // transition takes about seventeen of them. + expect(animating).toBeGreaterThan(DRAWER_TRANSITION_SECONDS / FRAME_SECONDS - 4); + expect(animating).toBeLessThan(DRAWER_TRANSITION_SECONDS / FRAME_SECONDS + 4); + }); + it("never lets the drawer's movement cover the history footer", function* () { const frames = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); for (const frame of frames) { From 1861db3c275164798f259c419e203cb4dbe6dd35 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 22 Sep 2026 09:05:18 -0400 Subject: [PATCH 04/57] =?UTF-8?q?=F0=9F=8E=9E=EF=B8=8F=20Play=20the=20whol?= =?UTF-8?q?e=20approved=20story=20with=20one=20command=20(#838)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deno task repl:study --play` now runs the demonstration end to end: empty → nested → generated → drawer → paused → settled, holding each moment between 1.2 and 2.6 seconds and animating every transition between them. Sixteen seconds, no keystrokes. When it reaches the settled entry the clock stops and that is what stays on screen until you leave. `--play ` remains as a focused diagnostic. Holds are part of the sequencing, not padding. The study's screens are dense, and a demonstration that cut between them as fast as it could render would show everything and let a person read nothing. The clock is now armed one wake-up at a time by the frame loop, which is the only thing that knows whether the next wait is a frame or the rest of a hold. A timer that decided that for itself read state the loop had not finished updating, and skipped whole segments of the story. The trace names the segment and the moment each frame belongs to, so a run can be read back as a journey rather than counted. Full-run evidence proves every fixture boundary is reached in order, that both the renderer's own interpolation and the application's happen along the way, that the run ends by settling rather than by exhausting its frame budget, that the final projection is the settled fixture exactly, that an interruption part way through cancels the journey and restores the terminal, and that no fixture carries a key belonging to a journey. Running the whole story at 200 × 50 also found a renderer limit: Clay's text-measurement cache holds 16 384 words, and one Term rendering this interface fills it after 86 frames, reporting TEXT_MEASUREMENT_CAPACITY_EXCEEDED and drawing nothing. The same journey at 80 × 24 completed 207 frames without reaching it, so it follows how much text a frame measures rather than time. The harness catches that one error, builds a fresh Term and repaints; a test proves both that it happens and that the run still finishes. Any session-lived Term will need the same recovery, and RESULT.md says so. --- scripts/repl-study/README.md | 31 +++-- scripts/repl-study/RESULT.md | 21 +++- scripts/repl-study/capture.ts | 82 ++++++++++++- scripts/repl-study/host.ts | 193 +++++++++++++++++++++++++------ scripts/repl-study/main.ts | 44 ++++--- scripts/repl-study/playback.ts | 101 ++++++++++++++++ scripts/tests/repl-study.test.ts | 173 ++++++++++++++++++++++++++- 7 files changed, 584 insertions(+), 61 deletions(-) diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index 887df281a..4393cff8f 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -13,13 +13,20 @@ it found. ## Run it ```bash -deno task repl:study # in this terminal -deno task repl:study --fixture drawer # opening on one moment -deno task repl:study --play generated drawer # playing one transition +deno task repl:study --play # the whole story, start to finish +deno task repl:study # sitting on one moment, keys below +deno task repl:study --fixture drawer # opening on a different one +deno task repl:study --play generated drawer # one transition, as a diagnostic deno task repl:study --capture captures/ # every fixture at every profile deno task repl:study --print nested wide # one frame, as text ``` +**`--play` is the demonstration.** It begins at the empty REPL and goes all the +way to the settled entry — empty → nested → generated → drawer → paused → +settled — holding each moment long enough to read and animating every transition +between them. Nothing needs pressing. When it reaches the settled entry it stays +there until you leave with `q`. Sixteen seconds, wide terminal, no keyboard. + Keys, while it is running: | Key | What it does | @@ -40,9 +47,17 @@ ring, the drawer's focus trap, and where focus returns after a suspension — is ## Moving between moments The six fixtures are stable states — reconstructable, capturable, and what a -journal would restore. A playback is the path between two of them, and it exists -only while it runs: its phase and elapsed time live in the frame loop, never in a -fixture. Two kinds of motion run during one: +journal would restore. A journey is the sequence through all of them: holds on +each moment, transitions between. Both exist only while they run — segment, +phase and elapsed time live in the frame loop, never in a fixture — so what a +journal restores is a moment, never a point halfway through a transition. + +A hold is not dead time. The study's screens are dense, and a demonstration that +cut between them as fast as it could render would show everything and let a +person read nothing. Holds run from 1.2 to 2.6 seconds, in proportion to how +much there is to take in. + +Two kinds of motion run during a transition: - **the renderer's own.** The contextual band declares a transition, so when a suspension opens the drawer `@bomb.sh/tty` interpolates its height and top edge @@ -60,7 +75,7 @@ so an idle REPL schedules nothing. Cancelling the session halts the clock with it, which is why an interruption cannot leave a frame being drawn into a terminal that has already been restored. -Three flags exist for running a playback without a person watching: +Three flags exist for running a journey or a playback without a person watching: `--frames ` leaves once the playback settles or that many frames have been drawn, `--interrupt-after-frames ` raises a real `SIGINT` at the harness mid transition, and `--trace ` records what every frame did — elapsed @@ -98,7 +113,7 @@ diff as the picture it changed. The `.ansi` files are not committed. | File | What it owns | | --- | --- | | `model.ts` | the semantic vocabulary — scopes, phases, sections, sessions, bindings, checkpoints, drawers. No cells. | -| `playback.ts` | the path between two fixtures, and the motion at one instant of it | +| `playback.ts` | the journey, the path between two fixtures, and the motion at one instant of it | | `fixtures.ts` | the six moments, from the study's own content | | `view.ts` | what the person chose: the transcript window, the selected checkpoint, the current surface | | `layout.ts` | the profile, and every region's rectangle in cells | diff --git a/scripts/repl-study/RESULT.md b/scripts/repl-study/RESULT.md index 51ae2a377..02608e940 100644 --- a/scripts/repl-study/RESULT.md +++ b/scripts/repl-study/RESULT.md @@ -21,6 +21,7 @@ needed an adaptation the study does not describe, named below. | rendered and captured | 64 × 18 | too-small | | a real pseudo-terminal, interactively, macOS `script` | 80 × 24 | narrow | | a real pseudo-terminal, animating with no input | 80 × 24 | narrow | +| a real pseudo-terminal, the whole story with no input | 80 × 24 | narrow | The interactive run opened, showed the `nested` fixture, accepted `4`, `Tab` and `q` as keystrokes, and left the terminal in the modes it found. Every other @@ -28,7 +29,11 @@ dimension was exercised through the captures and the suite. ## Animation -The renderer animates, and the frame loop that drives it is small. +The renderer animates, and the frame loop that drives it is small. `--play` runs +the whole approved story — empty, nested, generated, drawer, paused, settled — +holding each moment for between 1.2 and 2.6 seconds and animating every +transition, about sixteen seconds end to end with nobody at the keyboard. When it +arrives at the settled entry the clock stops and that is what stays on screen. - **A declared transition is interpolated by the renderer.** Giving the contextual band `transition: { duration: 0.26, easing: "easeInOut", properties: @@ -62,6 +67,20 @@ The renderer animates, and the frame loop that drives it is small. and halted as soon as both settle — an idle REPL schedules nothing at all. An interruption mid-transition halted it with the session: the trace ends at the frame the signal arrived on, and the terminal's modes were restored after it. +- **A long run exhausts the renderer, and the answer is a new one.** Clay keeps + a cache of measured words — 16 384 by default — and one `Term` rendering this + interface at 200 × 50 fills it after **86 frames**, reporting + `TEXT_MEASUREMENT_CAPACITY_EXCEEDED` and rendering nothing. The same journey at + 80 × 24 completed 207 frames without reaching the limit, so it is a function of + how much text each frame measures rather than of time. The harness now catches + that one error, builds a fresh `Term` and repaints; a viewer sees a frame, not + a fault. Any production REPL will meet this, and a `Term` that lives for a + session needs the same recovery or a raised cache. +- **One wake-up at a time beats a loop that schedules itself.** The clock is + armed by the frame loop after each frame, because only the loop knows whether + the next wait is a frame or the rest of a hold. A timer deciding that for + itself read state the loop had not finished updating, and jumped whole + segments of the story. - **Application-timed motion stays a pure function of elapsed time.** The head's travel along the track and the transcript's arrival are computed from milliseconds, so the same instant renders identically from a test, a capture diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 6e112f6f8..b6ae9f713 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -15,7 +15,7 @@ import { ensureDir, writeTextFile } from "@effectionx/fs"; import { join } from "node:path"; import { fixture, fixtures } from "./fixtures.ts"; -import { motionAt, PLAYBACKS } from "./playback.ts"; +import { JOURNEY, journeyPlan, motionAt, PLAYBACKS } from "./playback.ts"; import type { Motion, Playback } from "./playback.ts"; import type { Fixture } from "./model.ts"; import type { Profile, SurfaceName } from "./layout.ts"; @@ -93,6 +93,20 @@ export function* renderFrame(request: FrameRequest): Operation { return renderInto(term, request); } +/** + * The renderer ran out of room to measure text. + * + * Clay keeps a cache of measured words — 16 384 of them — and a long-lived Term + * rendering an interface this wordy exhausts it. It is not a failure of the + * frame: the answer is a new Term, which starts the cache again. + */ +export class RendererCapacityError extends Error { + constructor(message: string) { + super(message); + this.name = "RendererCapacityError"; + } +} + export function renderInto(term: Term, request: FrameRequest): Frame { const { view, size, mutation } = request; // A frame drawn from state the harness has already left behind. The renderer @@ -120,6 +134,12 @@ export function renderInto(term: Term, request: FrameRequest): Frame { request.deltaSeconds === undefined ? {} : { deltaTime: request.deltaSeconds }, ); if (result.errors.length > 0) { + const capacity = result.errors.find( + (error) => error.type === "TEXT_MEASUREMENT_CAPACITY_EXCEEDED", + ); + if (capacity !== undefined) { + throw new RendererCapacityError(capacity.message); + } throw new Error(`the renderer reported ${JSON.stringify(result.errors)}`); } const ansi = Uint8Array.from(result.output); @@ -175,6 +195,66 @@ export function* playFrames( return frames; } +/** + * Every frame of the whole demonstration, rendered deterministically. + * + * The screen carries across frames the way a terminal's does, so what comes + * back is what a person would have seen at each moment rather than the handful + * of cells that changed. + */ +export interface JourneyFrame extends Frame { + readonly label: string; + readonly fixture: string; +} + +export interface JourneyRun { + readonly frames: JourneyFrame[]; + /** How many times the renderer had to be rebuilt to get to the end. */ + readonly rebuilds: number; +} + +export function* journeyFrames( + size: Size, + options: { readonly frameMs?: number } = {}, +): Operation { + const frameMs = options.frameMs ?? 16; + let term = yield* useTerm(size); + let rebuilds = 0; + const screen = createGrid(size.cols, size.rows); + const frames: JourneyFrame[] = []; + for (const planned of journeyPlan(JOURNEY, frameMs)) { + const subject = fixture(planned.fixture); + const request: FrameRequest = { + fixture: subject, + view: initialView(subject), + size, + motion: planned.motion, + deltaSeconds: planned.deltaMs / 1000, + }; + let frame: Frame; + try { + frame = renderInto(term, request); + } catch (error) { + if (!(error instanceof RendererCapacityError)) { + throw error; + } + // A fresh Term starts with an empty measurement cache and repaints + // everything, so the screen this frame lands on is complete. + term = yield* useTerm(size); + rebuilds += 1; + frame = renderInto(term, { ...request, deltaSeconds: 0 }); + } + applyAnsi(screen, frame.ansi); + frames.push({ + ...frame, + text: gridText(screen), + label: planned.label, + fixture: planned.fixture, + }); + } + return { frames, rebuilds }; +} + export function captureName(fixtureName: string, profile: Profile): string { return `${fixtureName}.${profile}`; } diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 3b29ae31d..708dcc1a8 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -26,10 +26,18 @@ import { renderScreen } from "./render.ts"; import { transcriptLines } from "./render.ts"; import { initialView, moveSurface, returnToHead, scrollBy, scrubBy, toggleDrawer } from "./view.ts"; import type { View } from "./view.ts"; -import { renderInto, useTerm } from "./capture.ts"; +import { RendererCapacityError, useTerm } from "./capture.ts"; +import { renderInto } from "./capture.ts"; import type { Mutation } from "./mutations.ts"; -import { motionAt, playbackFrom } from "./playback.ts"; -import type { Motion, Playback } from "./playback.ts"; +import { + JOURNEY, + motionAt, + playbackFrom, + segmentDurationMs, + segmentFixture, + segmentLabel, +} from "./playback.ts"; +import type { Motion, Playback, Segment } from "./playback.ts"; /** The modes the harness changes, as one reversible pair. */ export function terminalModes(): Setting { @@ -138,7 +146,7 @@ export function measureTerminal(): { cols: number; rows: number } { export type HarnessEvent = | { readonly kind: "key"; readonly event: InputEvent } | { readonly kind: "resize" } - | { readonly kind: "tick" } + | { readonly kind: "tick"; readonly advanceMs: number } | { readonly kind: "quit" }; export interface HarnessState { @@ -263,17 +271,19 @@ export const FRAME_MS = 16; export const FRAME_SECONDS = FRAME_MS / 1000; /** - * The clock that keeps an animation moving when nothing else is happening. + * One wake-up, armed by the frame loop after each frame it draws. * - * It is spawned as a child of the terminal session and halted the moment - * nothing is moving, so an idle REPL costs nothing and a cancelled session - * cannot leave a timer drawing into a terminal that has already been restored. + * It is a child of the terminal session, so a cancelled session takes it with + * it and no timer is left drawing into a terminal that has been restored. It is + * armed one frame at a time rather than looping on its own, because only the + * loop knows how long the next wait should be — sixteen milliseconds while a + * transition runs, and the remainder of a hold while a moment is being read. + * A timer that decided that for itself would be deciding it from state the loop + * had not finished updating. */ -function* ticker(events: Signal): Operation { - while (true) { - yield* sleep(FRAME_MS); - events.send({ kind: "tick" }); - } +function* ticker(events: Signal, delayMs: number): Operation { + yield* sleep(delayMs); + events.send({ kind: "tick", advanceMs: delayMs }); } /** One line of what the frame loop did, for evidence that cannot watch a screen. */ @@ -285,6 +295,10 @@ export interface TraceEntry { readonly animating: boolean; readonly motionDone: boolean | null; readonly bytes: number; + /** `hold:nested`, `play:nested→generated`, or `settled` once it is over. */ + readonly segment: string; + /** The moment this frame is showing, which is always one of the fixtures. */ + readonly fixture: FixtureName; } export interface InteractiveOptions { @@ -292,6 +306,8 @@ export interface InteractiveOptions { readonly mutation?: Mutation; /** Start this playback immediately, rather than waiting for `p`. */ readonly play?: Playback; + /** Play the whole approved story, holds and all, with no keystrokes. */ + readonly journey?: readonly Segment[]; /** Leave after this many frames, so a run can end without a keystroke. */ readonly maxFrames?: number; /** Raise SIGINT at this harness once this many frames have been drawn. */ @@ -325,7 +341,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { quit: false, }; - const term = yield* useTerm({ cols: state.cols, rows: state.rows }); + let term = yield* useTerm({ cols: state.cols, rows: state.rows }); const input: Input = yield* until(createInput({})); yield* useTerminalModes(write, options.mutation); @@ -353,18 +369,88 @@ export function* runInteractive(options: InteractiveOptions): Operation { } }); + // Everything about where the demonstration has got to lives here, in this + // invocation, and is gone when it returns. No fixture knows about it, nothing + // durable records it, and reconstruction lands on a fixture rather than on a + // moment between two of them. + const journey = options.journey; + let segmentIndex = 0; let playback = options.play; let elapsed = 0; let frames = 0; let clock: Task | undefined; let interrupted = false; let settled = false; + let held: string | undefined; + let lastPainted = false; - if (playback !== undefined) { - const target = fixture(playback.to); + const currentSegment = (): Segment | undefined => + journey === undefined ? undefined : journey[segmentIndex]; + + const show = (name: FixtureName) => { + const target = fixture(name); state = { ...state, fixture: target, view: initialView(target) }; + }; + + if (journey !== undefined) { + const first = journey[0]; + show(segmentFixture(first)); + if (first.kind === "play") { + playback = first.playback; + } + } else if (playback !== undefined) { + show(playback.to); } + /** + * How long the clock should wait before the next frame. + * + * A transition wants one every sixteen milliseconds. A held moment wants + * exactly one, when the hold is over. + */ + const nextDelayMs = (): number => { + const segment = currentSegment(); + if (segment?.kind === "hold") { + return Math.max(1, segment.durationMs - elapsed); + } + return FRAME_MS; + }; + + /** + * Move the journey on by the time that just passed. + * + * A wake-up can cross a segment boundary — the end of a hold is exactly such + * a wake-up — so this consumes segments until the elapsed time fits inside + * the current one, and reports when the story has run out. + */ + const advance = (byMs: number): "running" | "finished" => { + if (journey === undefined) { + elapsed += byMs; + return "running"; + } + elapsed += byMs; + while (segmentIndex < journey.length) { + const segment = journey[segmentIndex]; + const duration = segmentDurationMs(segment); + if (elapsed < duration) { + break; + } + elapsed -= duration; + segmentIndex += 1; + const entered = journey[segmentIndex]; + if (entered === undefined) { + // The last hold ended. What is on screen is the settled entry, and it + // stays there until the person leaves. + show(segmentFixture(segment)); + playback = undefined; + return "finished"; + } + show(segmentFixture(entered)); + playback = entered.kind === "play" ? entered.playback : undefined; + } + return "running"; + }; + /** * Draw one frame, then decide whether anything is still moving. * @@ -372,35 +458,70 @@ export function* runInteractive(options: InteractiveOptions): Operation { * application's own transition has not finished, and halted as soon as both * have settled — so an idle REPL schedules nothing at all. */ - const paint = function* (deltaMs: number): Operation { + const paint = function* (deltaMs: number, finished = false): Operation { + const segment = currentSegment(); const motion = playback === undefined ? undefined : motionAt(playback, elapsed); - const painted = draw(term, state, write, options.mutation, motion, deltaMs); - frames += 1; - options.trace?.push({ - frame: frames, - elapsedMs: elapsed, - deltaSeconds: deltaMs / 1000, - animating: painted.animating, - motionDone: motion === undefined ? null : motion.done, - bytes: painted.bytes, - }); + const label = + finished || segment === undefined + ? journey === undefined + ? "focused" + : "settled" + : segmentLabel(segment); + + // A held moment is one picture. Drawing it again on the way past would cost + // a render and change nothing, so the hold is drawn once and then waited + // out. + const repeated = journey !== undefined && segment?.kind === "hold" && held === label; + if (!repeated) { + let painted: Painted; + try { + painted = draw(term, state, write, options.mutation, motion, deltaMs); + } catch (error) { + if (!(error instanceof RendererCapacityError)) { + throw error; + } + // The renderer ran out of room to measure text, which a long run in a + // wide terminal will do. A new one starts that cache again and repaints + // the whole screen, so the person watching sees nothing but a frame. + term = yield* useTerm({ cols: state.cols, rows: state.rows }); + painted = draw(term, state, write, options.mutation, motion, 0); + } + frames += 1; + options.trace?.push({ + frame: frames, + elapsedMs: elapsed, + deltaSeconds: deltaMs / 1000, + animating: painted.animating, + motionDone: motion === undefined ? null : motion.done, + bytes: painted.bytes, + segment: label, + fixture: state.fixture.name, + }); + lastPainted = painted.animating; + } + held = segment?.kind === "hold" ? label : undefined; - const moving = painted.animating || (motion !== undefined && !motion.done); + const journeyRunning = journey !== undefined && !finished; + const moving = lastPainted || (motion !== undefined && !motion.done) || journeyRunning; const active = options.mutation === "never-tick" ? false : moving; - if (active && clock === undefined) { - clock = yield* spawn(() => ticker(events)); - } - if (!active && clock !== undefined) { + if (clock !== undefined) { const running = clock; clock = undefined; yield* running.halt(); } - if (motion !== undefined && motion.done && !painted.animating) { + if (active) { + const delay = Math.max(1, Math.round(nextDelayMs())); + clock = yield* spawn(() => ticker(events, delay)); + } + if (journey === undefined && motion !== undefined && motion.done && !lastPainted) { // The transition has arrived. What remains is the fixture itself, which // is what a journal or a URL would restore. playback = undefined; settled = true; } + if (finished) { + settled = true; + } if (options.maxFrames !== undefined && !active) { // A run with a frame budget has nobody at the keyboard, so when nothing // is moving there is nothing left for it to do. A harness that scheduled @@ -433,8 +554,10 @@ export function* runInteractive(options: InteractiveOptions): Operation { } if (next.value.kind === "tick") { - elapsed += FRAME_MS; - yield* paint(FRAME_MS); + // The one-shot has fired and finished; the frame it produces arms the next. + clock = undefined; + const advanced = advance(next.value.advanceMs); + yield* paint(next.value.advanceMs, advanced === "finished"); continue; } diff --git a/scripts/repl-study/main.ts b/scripts/repl-study/main.ts index 372d1a2f4..f36866759 100644 --- a/scripts/repl-study/main.ts +++ b/scripts/repl-study/main.ts @@ -1,7 +1,8 @@ /** * The documented command. * - * deno task repl:study the harness, in this terminal + * deno task repl:study --play the whole story, start to finish + * deno task repl:study one moment, in this terminal * deno task repl:study --capture every fixture at every profile * deno task repl:study --print nested wide one frame, as text * deno task repl:study --replay the lifecycle, with no terminal @@ -16,7 +17,7 @@ import { captureAll, PROFILE_SIZES, renderFrame, writeCaptures } from "./capture import { fixture } from "./fixtures.ts"; import { runInteractive, runReplay } from "./host.ts"; import type { TraceEntry } from "./host.ts"; -import { playbackBetween } from "./playback.ts"; +import { JOURNEY, playbackBetween } from "./playback.ts"; import type { Playback } from "./playback.ts"; import { writeTextFile } from "@effectionx/fs"; import { isFixtureName } from "./model.ts"; @@ -29,7 +30,9 @@ import { initialView } from "./view.ts"; const USAGE = [ "usage:", " repl-study [--fixture ] [--mutation ]", - " repl-study --play [--frames ] [--interrupt-after-frames ] [--trace ]", + " repl-study --play the whole story, start to finish", + " repl-study --play one transition, as a diagnostic", + " [--frames ] [--interrupt-after-frames ] [--trace ]", " repl-study --capture [--mutation ]", " repl-study --print [--mutation ]", " repl-study --replay [--interrupt-after ] [--fail-after ] [--mutation ]", @@ -44,6 +47,7 @@ type Mode = readonly kind: "interactive"; readonly fixture: FixtureName; readonly play?: Playback; + readonly journey?: boolean; readonly maxFrames?: number; readonly interruptAfterFrames?: number; readonly trace?: string; @@ -77,6 +81,7 @@ export function parse(argv: readonly string[]): Invocation | string { let fixtureName: FixtureName = "nested"; let mode: Mode | undefined; let play: Playback | undefined; + let journey = false; let maxFrames: number | undefined; let interruptAfterFrames: number | undefined; let trace: string | undefined; @@ -118,16 +123,27 @@ export function parse(argv: readonly string[]): Invocation | string { } mode = { kind: "print", fixture: name, profile }; } else if (argument === "--play") { - const from = value(); - const to = value(); - if (from === undefined || !isFixtureName(from) || to === undefined || !isFixtureName(to)) { - return `--play needs two fixture names, not ${JSON.stringify([from, to])}`; + // Bare `--play` is the demonstration: the whole story, in order, with + // nobody at the keyboard. Two fixture names narrow it to one transition, + // which is a diagnostic rather than the thing to show somebody. + const from = argv[at + 1]; + const to = argv[at + 2]; + if (from === undefined || from.startsWith("--")) { + journey = true; + } else { + at += 2; + if (!isFixtureName(from) || to === undefined || !isFixtureName(to)) { + return `--play takes no arguments, or two fixture names — not ${JSON.stringify([ + from, + to, + ])}`; + } + const found = playbackBetween(from, to); + if (found === undefined) { + return `there is no playback from ${from} to ${to}`; + } + play = found; } - const found = playbackBetween(from, to); - if (found === undefined) { - return `there is no playback from ${from} to ${to}`; - } - play = found; } else if (argument === "--frames") { const count = value(); if (!isFrameCount(count)) { @@ -169,8 +185,9 @@ export function parse(argv: readonly string[]): Invocation | string { return { mode: mode ?? { kind: "interactive", - fixture: fixtureName, + fixture: journey ? "empty" : fixtureName, play, + journey, maxFrames, interruptAfterFrames, trace, @@ -232,6 +249,7 @@ function* run(invocation: Invocation): Operation { fixture: mode.fixture, mutation, play: mode.play, + journey: mode.journey === true ? JOURNEY : undefined, maxFrames: mode.maxFrames, interruptAfterFrames: mode.interruptAfterFrames, trace: tracePath === undefined ? undefined : trace, diff --git a/scripts/repl-study/playback.ts b/scripts/repl-study/playback.ts index a0f42d0df..f4732bba1 100644 --- a/scripts/repl-study/playback.ts +++ b/scripts/repl-study/playback.ts @@ -32,6 +32,61 @@ export const PLAYBACKS: readonly Playback[] = [ { from: "paused", to: "settled", durationMs: 640 }, ]; +/** + * One stretch of the demonstration: a moment held, or a transition played. + * + * A hold is not dead time. The study's moments are dense — a nested transcript, + * a form, a band with fourteen checkpoints on it — and a demonstration that cut + * between them as fast as it could render would show everything and let a + * person read nothing. + */ +export type Segment = + | { readonly kind: "hold"; readonly fixture: FixtureName; readonly durationMs: number } + | { readonly kind: "play"; readonly playback: Playback }; + +/** + * The whole approved story, start to finish, with nobody at the keyboard. + * + * Holds are proportional to how much there is to take in: the empty REPL is one + * sentence, the nested transcript and the paused band are the densest screens in + * the study. The last hold ends the journey, and what remains on screen is the + * settled entry. + */ +export const JOURNEY: readonly Segment[] = [ + { kind: "hold", fixture: "empty", durationMs: 1200 }, + { kind: "play", playback: PLAYBACKS[0] }, + { kind: "hold", fixture: "nested", durationMs: 2600 }, + { kind: "play", playback: PLAYBACKS[1] }, + { kind: "hold", fixture: "generated", durationMs: 2400 }, + { kind: "play", playback: PLAYBACKS[2] }, + { kind: "hold", fixture: "drawer", durationMs: 2400 }, + { kind: "play", playback: PLAYBACKS[3] }, + { kind: "hold", fixture: "paused", durationMs: 2600 }, + { kind: "play", playback: PLAYBACKS[4] }, + { kind: "hold", fixture: "settled", durationMs: 1600 }, +]; + +export function segmentDurationMs(segment: Segment): number { + return segment.kind === "hold" ? segment.durationMs : segment.playback.durationMs; +} + +/** The moment a segment is showing, which is a fixture either way. */ +export function segmentFixture(segment: Segment): FixtureName { + return segment.kind === "hold" ? segment.fixture : segment.playback.to; +} + +/** What a trace calls this segment, so a run can be read back as a journey. */ +export function segmentLabel(segment: Segment): string { + return segment.kind === "hold" + ? `hold:${segment.fixture}` + : `play:${segment.playback.from}→${segment.playback.to}`; +} + +/** How long the whole demonstration takes, holds included. */ +export function journeyDurationMs(journey: readonly Segment[] = JOURNEY): number { + return journey.reduce((total, segment) => total + segmentDurationMs(segment), 0); +} + export function playbackFrom(from: FixtureName): Playback | undefined { return PLAYBACKS.find((playback) => playback.from === from); } @@ -81,3 +136,49 @@ export function motionAt(playback: Playback, elapsedMs: number): Motion { export function settledFixture(playback: Playback): FixtureName { return playback.to; } + +/** One frame of a journey, described before anything is rendered. */ +export interface PlannedFrame { + readonly label: string; + readonly fixture: FixtureName; + readonly motion?: Motion; + readonly deltaMs: number; + readonly elapsedMs: number; +} + +/** + * The whole demonstration as a list of frames, at a fixed step. + * + * The live loop is driven by a real clock and draws a held moment once; this + * walks the same journey with time supplied instead of measured, so a test or a + * capture sees exactly the frames a viewer would, in the same order, without + * waiting sixteen seconds for them. + */ +export function journeyPlan(journey: readonly Segment[] = JOURNEY, frameMs = 16): PlannedFrame[] { + const planned: PlannedFrame[] = []; + let elapsed = 0; + for (const segment of journey) { + const duration = segmentDurationMs(segment); + if (segment.kind === "hold") { + planned.push({ + label: segmentLabel(segment), + fixture: segment.fixture, + deltaMs: planned.length === 0 ? 0 : frameMs, + elapsedMs: elapsed, + }); + elapsed += duration; + continue; + } + for (let within = 0; within <= duration; within += frameMs) { + planned.push({ + label: segmentLabel(segment), + fixture: segment.playback.to, + motion: motionAt(segment.playback, within), + deltaMs: planned.length === 0 ? 0 : frameMs, + elapsedMs: elapsed + within, + }); + } + elapsed += duration; + } + return planned; +} diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index 2652f8e36..64ad5abf9 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url"; import { captureAll, captureText, + journeyFrames, playFrames, PROFILE_SIZES, renderFrame, @@ -37,10 +38,18 @@ import { } from "../repl-study/capture.ts"; import type { Size } from "../repl-study/capture.ts"; import { fixture, fixtures } from "../repl-study/fixtures.ts"; +import { FIXTURE_NAMES } from "../repl-study/model.ts"; import { terminalModes } from "../repl-study/host.ts"; -import type { TraceEntry } from "../repl-study/host.ts"; import type { HarnessState } from "../repl-study/host.ts"; -import { motionAt, playbackBetween } from "../repl-study/playback.ts"; +import { + JOURNEY, + journeyDurationMs, + journeyPlan, + motionAt, + playbackBetween, + segmentDurationMs, + segmentLabel, +} from "../repl-study/playback.ts"; import { FRAME_SECONDS } from "../repl-study/host.ts"; import { intersects, layoutFor, MINIMUM, PANE_MINIMUMS, profileFor } from "../repl-study/layout.ts"; import type { Profile } from "../repl-study/layout.ts"; @@ -142,7 +151,7 @@ function ptyArguments(command: string): string[] { throw new Error(`this evidence needs a pseudo-terminal, and ${Deno.build.os} has no script(1)`); } -function* readTrace(path: string): Operation { +function* readTrace(path: string): Operation { const text = yield* readTextFile(path); return text .split("\n") @@ -158,8 +167,39 @@ const TRACE_ENTRY = z.object({ animating: z.boolean(), motionDone: z.boolean().nullable(), bytes: z.number(), + segment: z.string(), + fixture: z.string(), }); +/** A trace line, parsed. The fixture name is narrowed where the harness reads it. */ +type TracedFrame = z.infer; + +/** The order a run visited its moments in, with repeats collapsed. */ +function visited(entries: readonly T[]): string[] { + const order: string[] = []; + for (const entry of entries) { + if (order[order.length - 1] !== entry.segment) { + order.push(entry.segment); + } + } + return order; +} + +/** What the whole demonstration is supposed to visit, in order. */ +const JOURNEY_SEGMENTS = [ + "hold:empty", + "play:empty→nested", + "hold:nested", + "play:nested→generated", + "hold:generated", + "play:generated→drawer", + "hold:drawer", + "play:drawer→paused", + "hold:paused", + "play:paused→settled", + "hold:settled", +]; + describe("fixture rendering", () => { it("renders every committed capture exactly", function* () { const captures = yield* captureAll(); @@ -948,3 +988,130 @@ describe("animation in a real terminal", () => { expect(result.stdout.endsWith(new TextDecoder().decode(terminalModes().revert))).toBe(true); }); }); + +describe("the whole demonstration", () => { + it("visits every moment of the approved story, in order", function* () { + const planned = journeyPlan(); + expect(visited(planned.map((frame) => ({ segment: frame.label })))).toEqual(JOURNEY_SEGMENTS); + + // Every fixture appears, in the order the study tells them. + const moments: string[] = []; + for (const frame of planned) { + if (moments[moments.length - 1] !== frame.fixture) { + moments.push(frame.fixture); + } + } + expect(moments).toEqual([...FIXTURE_NAMES]); + }); + + it("holds each moment long enough to read it", function* () { + for (const segment of JOURNEY) { + // Nothing is on screen for less than a second unless it is moving. + const duration = segmentDurationMs(segment); + if (segment.kind === "hold") { + expect({ segment: segmentLabel(segment), long: duration >= 1000 }).toEqual({ + segment: segmentLabel(segment), + long: true, + }); + } + } + // Long enough to watch, short enough to sit through. + expect(journeyDurationMs()).toBeGreaterThan(10_000); + expect(journeyDurationMs()).toBeLessThan(30_000); + }); + + it("animates both ways along the way, and ends on the settled entry", function* () { + const { frames } = yield* journeyFrames(PROFILE_SIZES.wide); + + // The renderer's own interpolation happened. + expect(frames.some((frame) => frame.animating)).toBe(true); + // And the application's: a transition frame carrying unfinished motion. + expect(frames.some((frame) => frame.label.startsWith("play:"))).toBe(true); + + const settled = yield* readTextFile(join(GOLDENS, "settled.wide.txt")); + const last = frames[frames.length - 1]; + expect(last.label).toBe("hold:settled"); + expect(last.animating).toBe(false); + // What remains is the fixture, exactly — no trace of the journey that + // arrived at it. + expect(settled).toContain(last.text.replace(/\n+$/, "")); + }); + + it("keeps the journey out of everything that outlives it", function* () { + // The journey is derived, not stored. No fixture carries a key belonging to + // it, so there is nothing for a journal to restore halfway through one. + const forbidden = ["segment", "playback", "motion", "progress", "durationMs", "reveal"]; + const walk = (value: unknown, path: string) => { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`)); + return; + } + if (typeof value !== "object" || value === null) { + return; + } + for (const [key, nested] of Object.entries(value)) { + expect({ at: `${path}.${key}`, journeyState: forbidden.includes(key) }).toEqual({ + at: `${path}.${key}`, + journeyState: false, + }); + walk(nested, `${path}.${key}`); + } + }; + for (const subject of fixtures()) { + walk(subject, subject.name); + } + }); + + it("rebuilds the renderer when it runs out of room to measure text", function* () { + // Clay caches measured words, and a wide terminal running the whole story + // exhausts that cache part way through. The demonstration has to survive it, + // so this asserts both that it happens and that the run still finishes. + const { frames, rebuilds } = yield* journeyFrames(PROFILE_SIZES.wide); + expect(rebuilds).toBeGreaterThan(0); + expect(frames.length).toBe(journeyPlan().length); + expect(frames[frames.length - 1].label).toBe("hold:settled"); + }); +}); + +describe("the whole demonstration, in a real terminal", () => { + it("plays start to finish with nobody at the keyboard", function* () { + const directory = yield* useTempDirectory("repl-study-journey"); + const trace = join(directory, "journey.jsonl"); + const budget = 600; + const command = `${MAIN} --play --frames ${budget} --trace ${trace}`; + yield* exec(ptyCommand(command), { cwd: ROOT, arguments: ptyArguments(command) }).join(); + + const drawn = yield* readTrace(trace); + expect(visited(drawn)).toEqual([...JOURNEY_SEGMENTS, "settled"]); + expect(drawn.some((entry) => entry.animating)).toBe(true); + expect( + drawn.some((entry) => entry.segment.startsWith("play:") && entry.motionDone === false), + ).toBe(true); + + // It stopped because the story ended, not because it ran out of budget — + // which is what it means for the clock to stop after the settled state. + expect(drawn.length).toBeLessThan(budget); + const last = drawn[drawn.length - 1]; + expect(last.segment).toBe("settled"); + expect(last.fixture).toBe("settled"); + }); + + it("cancels the whole journey when interrupted part way through", function* () { + const directory = yield* useTempDirectory("repl-study-journey-interrupt"); + const trace = join(directory, "interrupted.jsonl"); + const command = `${MAIN} --play --frames 600 --interrupt-after-frames 45 --trace ${trace}`; + const result = yield* exec(ptyCommand(command), { + cwd: ROOT, + arguments: ptyArguments(command), + }).join(); + + const drawn = yield* readTrace(trace); + expect(drawn.length).toBe(45); + // It was interrupted in the middle of the story, and nothing was drawn + // afterwards: the clock went down with the session. + const last = drawn[drawn.length - 1]; + expect(last.segment).not.toBe("settled"); + expect(JOURNEY_SEGMENTS).toContain(last.segment); + expect(result.stdout.endsWith(new TextDecoder().decode(terminalModes().revert))).toBe(true); + }); +}); From 0b8687b01c0460a8ea8dd232c549e5af86231612 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 23 Sep 2026 06:39:08 -0400 Subject: [PATCH 05/57] =?UTF-8?q?=F0=9F=A7=AD=20Give=20the=20REPL=20study?= =?UTF-8?q?=20one=20location=20and=20a=20derived=20focus=20model=20(#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness kept where the person was in four loose fields on a `View`, so nothing could be reopened and there was no focus at all. Location is now one URL — the execution, the surface, the locus, the drawer stack, the inspected marker and the draft — and focus is a semantic identity resolved every frame against a registry derived from that route, the journal and the layout. All fourteen frames of the approved focus study are reachable at any of them, forward and in reverse, wide and narrow, and a state built by a long interaction rebuilds from its URL and a journal fixture alone. Two defects were found by driving bytes rather than synthetic events. A lone `ESC` never arrived, because the reader dropped `ScanResult.pending`; a real Shift+Tab never arrived either, because it is `Backtab` with no shift flag. Both are repaired and both carry a control that reproduces them. `view.ts` is absorbed into `store.ts`: two places holding where the person is was the defect this removes. `SURFACES` is unchanged and no #838 golden moved. --- scripts/repl-study/README.md | 86 +- scripts/repl-study/RESULT-focus.md | 190 +++++ scripts/repl-study/RESULT.md | 11 +- scripts/repl-study/capture.ts | 45 +- scripts/repl-study/fixtures.ts | 79 +- scripts/repl-study/focus.ts | 368 +++++++++ scripts/repl-study/frames.ts | 354 ++++++++ scripts/repl-study/host.ts | 241 +++--- scripts/repl-study/journal.ts | 343 ++++++++ scripts/repl-study/main.ts | 57 +- scripts/repl-study/mutations.ts | 18 + scripts/repl-study/render.ts | 199 ++++- scripts/repl-study/route.ts | 174 ++++ scripts/repl-study/store.ts | 650 +++++++++++++++ scripts/repl-study/view.ts | 78 -- scripts/runtime-test-exclusions.ts | 6 + .../fixtures/repl-focus/frame-01.narrow.txt | 28 + .../fixtures/repl-focus/frame-01.wide.txt | 48 ++ .../fixtures/repl-focus/frame-02.wide.txt | 48 ++ .../fixtures/repl-focus/frame-03.wide.txt | 48 ++ .../fixtures/repl-focus/frame-04.wide.txt | 48 ++ .../fixtures/repl-focus/frame-05.narrow.txt | 15 + .../fixtures/repl-focus/frame-05.wide.txt | 51 ++ .../fixtures/repl-focus/frame-06.wide.txt | 51 ++ .../fixtures/repl-focus/frame-07.narrow.txt | 13 + .../fixtures/repl-focus/frame-07.wide.txt | 51 ++ .../fixtures/repl-focus/frame-08.wide.txt | 51 ++ .../fixtures/repl-focus/frame-09.wide.txt | 51 ++ .../fixtures/repl-focus/frame-10.wide.txt | 51 ++ .../fixtures/repl-focus/frame-11.wide.txt | 51 ++ .../fixtures/repl-focus/frame-12.narrow.txt | 20 + .../fixtures/repl-focus/frame-12.wide.txt | 51 ++ .../fixtures/repl-focus/frame-13.wide.txt | 51 ++ .../fixtures/repl-focus/frame-14.narrow.txt | 28 + .../fixtures/repl-focus/frame-14.wide.txt | 51 ++ scripts/tests/repl-focus.test.ts | 767 ++++++++++++++++++ scripts/tests/repl-study.test.ts | 19 +- 37 files changed, 4240 insertions(+), 251 deletions(-) create mode 100644 scripts/repl-study/RESULT-focus.md create mode 100644 scripts/repl-study/focus.ts create mode 100644 scripts/repl-study/frames.ts create mode 100644 scripts/repl-study/journal.ts create mode 100644 scripts/repl-study/route.ts create mode 100644 scripts/repl-study/store.ts delete mode 100644 scripts/repl-study/view.ts create mode 100644 scripts/tests/fixtures/repl-focus/frame-01.narrow.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-01.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-02.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-03.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-04.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-05.narrow.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-05.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-06.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-07.narrow.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-07.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-08.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-09.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-10.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-11.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-12.narrow.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-12.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-13.wide.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-14.narrow.txt create mode 100644 scripts/tests/fixtures/repl-focus/frame-14.wide.txt create mode 100644 scripts/tests/repl-focus.test.ts diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index 4393cff8f..5fc584591 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -1,14 +1,18 @@ # REPL interaction study, in a real terminal -A bounded experiment for [#838](https://github.com/taras/executable.md/issues/838), -under the REPL quest [#827](https://github.com/taras/executable.md/issues/827). +A bounded experiment for [#838](https://github.com/taras/executable.md/issues/838) +and [#839](https://github.com/taras/executable.md/issues/839), under the REPL +quest [#827](https://github.com/taras/executable.md/issues/827). It renders the Product Owner's approved `XMD REPL Terminal Interface` study from fixture data through `@bomb.sh/tty` 0.9.0, to answer whether that renderer can carry the design in terminal cells. -It executes no XMD, opens no Agent session, reads no journal and writes nothing -but its captures. The implementation may be discarded; `RESULT.md` records what -it found. +#839 added the half #838 did not answer: one location said as a URL, and a focus +model derived from it. `RESULT-focus.md` records what that found. + +It executes no XMD, opens no Agent session, reads no real journal and writes +nothing but its captures. The implementation may be discarded; `RESULT.md` and +`RESULT-focus.md` record what it found. ## Run it @@ -19,8 +23,31 @@ deno task repl:study --fixture drawer # opening on a different one deno task repl:study --play generated drawer # one transition, as a diagnostic deno task repl:study --capture captures/ # every fixture at every profile deno task repl:study --print nested wide # one frame, as text + +deno task repl:study --frame 07 # one frame of the focus study +deno task repl:study --route 'xmd://repl/e1/transcript/entry-1/plan/+project' +deno task repl:study --frame 12 --focus-map # with the numbered overlay on +deno task repl:study --capture-focus captures/ # the focus study's frames, as text +``` + +**`--frame` and `--route` are the same door.** A frame is a location the +Product Owner's focus study names, and `--frame 07` is shorthand for its URL +plus how far the execution had recorded when it was taken. `--route` takes any +location at all: + +```text +xmd://repl//[/]*[/+]*[?at=][&draft=] ``` +The surface is one of `sessions`, `transcript`, `bindings`, `input`, `history`. +A `+` marks a drawer, so a drawer is never mistaken for a scope of the same +name, and the last drawer in the path is the top — the only one that is visible +and interactive. `at` names the recorded marker under inspection, and leaving it +out is the one spelling of "following the live head". Everything else — where +the transcript is scrolled to, which marker the scrubber is on, whether the +overlay is drawn, which target is focused right now — is disposable and +deliberately not in the URL. + **`--play` is the demonstration.** It begins at the empty REPL and goes all the way to the settled entry — empty → nested → generated → drawer → paused → settled — holding each moment long enough to read and animating every transition @@ -31,18 +58,32 @@ Keys, while it is running: | Key | What it does | | --- | --- | -| `1`–`6` | show a fixture: empty, nested, generated, drawer, paused, settled | +| `Tab` / `Shift+Tab` | move focus around the ring, forward and in reverse | +| `1`–`5` | jump straight to a region | +| `F1` | show or hide the numbered focus map | +| `Enter` | activate the focused target | +| `Esc` | Back, and never destructive — see below | | `↑` `↓` `PgUp` `PgDn` | move the transcript window | -| `←` `→` | move the selected checkpoint, one at a time | -| `Esc` | return to the head | -| `Tab` / `Shift+Tab` | move between surfaces, which matters in the narrow profile | -| `d` | open or close the drawer | +| `←` `→` | move the selected marker, one at a time | +| `Ctrl+↑` / `Ctrl+↓` | move the locus out to the parent scope, or in to the first child | +| `d` | open or close the suspension that is waiting | | `p` | play the transition out of this moment into the next | -| `q` or `Ctrl+C` | leave, restoring the terminal | - -These are the harness's own controls. The accepted focus model — the five-region -ring, the drawer's focus trap, and where focus returns after a suspension — is -[#839](https://github.com/taras/executable.md/issues/839), not this experiment. +| `q` | leave, restoring the terminal | +| `Ctrl+C` | interrupt a running entry; else clear the draft; else leave | + +**The ring is five regions with each region's own controls inlined after it** — +Sessions, Transcript, Bindings, REPL input, Execution History — and it wraps. +The numbers the overlay draws are assigned separately: regions take 1–5 and +controls take 6 upward, which is why `Run` is numbered after the footer and +traversed before it. While a drawer is open the ring is the drawer's own +controls and the Execution History region, and nothing else: the footer is +inside the trap deliberately, because it is the one way out of it. + +**`Esc` is Back.** It closes the top drawer, restoring whatever opened it; then +leaves a reconstruction for the paused head; then returns from a control to the +region that owns it; then pops one navigation entry. It never discards the draft +and never answers a suspension — which is one deliberate divergence from the +study, recorded in `RESULT-focus.md`. ## Moving between moments @@ -108,20 +149,29 @@ the exact byte stream. The `.txt` frames under `scripts/tests/repl-study.test.ts` checks, so a rendering change shows up in a diff as the picture it changed. The `.ansi` files are not committed. +`--capture-focus ` does the same for the focus study's frames, with the +numbered overlay on, under `scripts/tests/fixtures/repl-focus/`. The study +states its frames as numbered target lists, so numbering them on screen is what +makes a capture legible as evidence against the frame it reproduces. + ## How it is put together | File | What it owns | | --- | --- | | `model.ts` | the semantic vocabulary — scopes, phases, sections, sessions, bindings, checkpoints, drawers. No cells. | | `playback.ts` | the journey, the path between two fixtures, and the motion at one instant of it | -| `fixtures.ts` | the six moments, from the study's own content | -| `view.ts` | what the person chose: the transcript window, the selected checkpoint, the current surface | +| `fixtures.ts` | the six moments and the three drawers, from the study's own content | +| `route.ts` | the URL schema, parsing, formatting, and push versus replace | +| `focus.ts` | targets, the map, the registry, traversal, resolution and counterparts | +| `journal.ts` | the hand-authored journal fixture, and the fold that reconstructs a moment from it | +| `store.ts` | `ReplState`, its reducer, `hydrate()` and `projection()` | +| `frames.ts` | the focus study's fourteen frames, as addressable states | | `layout.ts` | the profile, and every region's rectangle in cells | | `render.ts` | those rectangles and that fixture, as `@bomb.sh/tty` operations | | `screen.ts` | a terminal's cells, reconstructed from the bytes, so a frame can be read back | | `host.ts` | the only module that touches the terminal: modes, raw input, signals, restoration | | `capture.ts` | one frame, away from a terminal, in bytes and in cells | -| `mutations.ts` | the eight ways the evidence breaks this on purpose | +| `mutations.ts` | the nineteen ways the evidence breaks this on purpose | | `main.ts` | the documented command | `--replay` runs the same lifecycle with no terminal attached, writing its byte diff --git a/scripts/repl-study/RESULT-focus.md b/scripts/repl-study/RESULT-focus.md new file mode 100644 index 000000000..9f14b12f3 --- /dev/null +++ b/scripts/repl-study/RESULT-focus.md @@ -0,0 +1,190 @@ +# What the routing and focus experiment found + +Issue [#839](https://github.com/taras/executable.md/issues/839) asked whether a +REPL can have one coherent location that survives resizing, drawer nesting, +historical inspection and the loss of its in-memory store — and whether focus +can be derived rather than remembered, so that background work never moves +somebody somewhere else. + +**Decision: retain the model, and adopt three things it settled.** One URL +carries location. One registry, rebuilt every frame, carries focus. Between them +they answer all fourteen frames of the Product Owner's approved focus study, +forward and in reverse, at the wide and the narrow profile, and a state built by +a long interaction rebuilds from its URL and a journal alone. Two defects were +found on the way, and both were only findable by driving bytes. + +## The two defects, and why the suite could not see them + +**A lone Escape never arrived.** `@bomb.sh/tty` buffers a solitary `ESC` — it +cannot yet know whether an escape sequence is following — and returns +`pending: { delay: 25 }` with an empty event list, asking the caller to re-scan +after that delay. #838's reader took `scanned.events` and dropped +`scanned.pending`, so Escape was swallowed until some other key was pressed +behind it. The key was documented in the README, handled in the reducer, and +covered by a test that handed the reducer a synthetic `{ code: "Escape" }` no +terminal had produced. + +**A real Shift+Tab never arrived either.** It is `ESC [ Z`, which the decoder +reports as key code `Backtab` with **no** shift flag. The reducer tested +`code === "Tab" && shift`, which is an event only a test had ever constructed. + +Both are the same mistake: a keyboard claim checked against invented events. +Everything this slice asserts about Escape and about reverse traversal is +therefore driven as bytes through `input.scan()`, pending flush included, and +two controls — `swallow-pending-escape` and `ignore-backtab` — reproduce the +pre-repair behaviour exactly so the repair cannot regress into a synthetic test +again. + +The general lesson is narrow and worth keeping: **a decoder's contract includes +what it does not hand you yet.** A harness that reads only the events out of a +scan has not finished reading the scan. + +## What location turned out to be + +```text +xmd://repl//[/]*[/+]*[?at=][&draft=] +``` + +The REPL has exactly three kinds of state, and telling them apart is what made +every acceptance criterion reachable: + +1. **Execution truth** belongs to the journal. Which scope is open, what has been + published, what is waiting for an answer, whether the run is live or paused — + none of that is location, and none of it is in the URL. This experiment folds + a hand-authored journal fixture; #842 owns the real one. +2. **Location** is the URL, and nothing else is location. +3. **Everything else is disposable** — the scroll anchor, the scrubber's + position, whether the overlay is drawn, which target is focused right now. + +Three decisions inside the schema earned their keep: + +- **A drawer segment wears `+`.** Drawers are nested routes, and a path is how + nesting is said; the prefix is what stops `.../project/+project` being + ambiguous. The suite checks exactly that URL. +- **Pause is not in the URL.** Whether the runtime is live or paused is + execution truth. A URL that could describe "paused" independently of the + execution it names would be able to describe pausing a finished run. +- **`at` absent is the live head.** There is no `at=head` sentinel, so two URLs + cannot render the same screen and hydrate into different states. + +**Scrubbing replaces and entering inspection pushes**, and that has a visible +consequence the study does not state: Back from an inspected marker returns to +the head, not back through every marker the scrubber passed. Draft editing +replaces for the same reason — both are continuous adjustments rather than +places somebody went. + +## What focus turned out to be + +Focus is never a coordinate and never an index. It is a semantic identity — +`region:transcript`, `control:transport.pause`, `field:drawer.project.name` — and +every frame a registry is derived from the route, the journal and the layout. +Asking where focus is means resolving one identity against the registry that +exists *now*. + +That single mechanism answers two of the issue's criteria at once. A background +update cannot steal focus, because nothing writes focus when one arrives — the +suite asserts **reference** equality of the route, the focus, the selection and +the anchor across a background event, since a reducer that rebuilt an equal +route would pass a deep comparison having already lost the property. And a route +transition restores a stable identity or the nearest surviving owner, because a +vanished identity is resolved by walking its owner chain rather than by anybody +remembering to move anything. + +Four things the fourteen frames settled: + +- **Two lists, not one.** The **registry** is visible ∧ enabled and is what Tab + walks. The **map** is visible whether enabled or not and is what the overlay + numbers. Frame 12 numbers a dimmed `Continue` as target 6 and says Tab skips + it, so a disabled control is in one list and never in the other. Conflating + them is the trap, and `focus-hidden-target` is the control that does. +- **Traversal order is not numbering order.** The ring is the five regions with + each region's own controls inlined immediately after it; numbering assigns 1–5 + to the regions and 6 upward to the controls. Frames 05, 11 and 14 only agree + with each other under that reading — frame 14 numbers `Run` as 6 and traverses + it *before* region 5, because `Run` belongs to the input. +- **Ownership is read from the identity.** Resolution has to answer "who owns + this?" for a target that is already gone, so it cannot be a lookup in the + registry that no longer contains it. The naming scheme is the ownership. +- **A transport control declares a counterpart.** Frame 13 requires that leaving + history with `Continue` focused lands on `Pause`, so a declared counterpart is + preferred over the owner walk. Nothing else needed one. + +**The drawer's trap is the drawer's controls and the Execution History region, +and nothing else.** The footer is inside the trap deliberately — the study calls +it "the one way out" — which is how #827's "keeps the fixed history footer +reachable" survives a suspension. Opening a drawer records the identity that +invoked it; closing it restores that identity through the same resolution walk, +so a drawer whose invoking scope no longer exists falls back rather than +dangling. + +## Divergences from the study, named rather than hidden + +1. **Escape closes the top drawer without answering it.** Study frame 09 gives + the confirmation drawer `esc declines` — Escape as an *answer*. This + experiment owns navigation and answers no `Elicit` request; answering is + #840's and #842's. Escape here is Back, and Back is non-destructive: the + suspension is still waiting afterwards, which the suite asserts. +2. **"Entered" is being focused within.** The study says `history` exposes its + controls once the region is entered with Enter. The rule this harness + implements is that the controls are in the sequence while focus is within the + region *and an execution has been recorded*. All fourteen frames agree with + it: frame 03 focuses region 5 and lists no controls because the REPL is + empty, not because it was not entered. A state with a recorded execution, + focus in the footer and no controls exposed does not appear in the study, so + nothing here distinguishes the two readings and the simpler one was taken. +3. **The overlay is a legend, not floating callouts.** The study numbers its + targets on top of the interface, which a browser can do because it measured + them. In cells the honest equivalent is a right-anchored legend carrying the + same numbers in the same order, dimming a target that is visible but + disabled. +4. **Focus is a glyph, not a colour.** The focused region wears `▌` at its + top-left and a focused control wears `▸` beside its label — including inside + the footer's `[▸Continue ]`, where the marker replaces the space inside the + bracket rather than widening it, because the track's room is computed from + that string and a focused control that shortened the track would make focus a + layout decision. The study's own "glyph + word, never colour alone" rule + applies here too, and a committed `.txt` capture records glyphs. +5. **The harness's own moment keys changed.** #838 used `1`–`6` to switch + fixture. The moment is now a function of the route and the journal, so the + digits do what study frame 02 says they do — jump straight to a region — and + `--frame` and `--route` are how a particular moment is opened. + +## What `SURFACES` is, and what it is not + +#838 has **four** routing surfaces and the study has **five** focus regions. +These are different things and `layout.ts` was not changed. Narrow routing +promotes one region to a whole screen; the REPL input is never one of those, +because `layout.ts` already renders it inside the transcript. It is still +somewhere focus can be, so it is a route surface and not a layout surface, and +the one line of reconciliation lives in `store.ts`. No #838 golden moved. + +## Scoped limits + +- **Three regions expose no controls.** `sessions`, `transcript` and `bindings` + are declared explicit and none of the fourteen frames gives any of them a + control, so nothing here says what their controls would be. +- **The journal is a fixture.** Pausing and resuming extend it to the record the + story already contains rather than recording anything, and no state is durable + past the process. #842 owns a real journal, journal storage and replay. +- **The six #838 fixtures are the available content.** The route and the journal + choose which one a moment shows and override its transport, its badge and its + open drawer, so `--route` and `--frame` genuinely drive the picture. They do + not synthesise content the fixture set does not have: there is no "paused at + the live head" transcript distinct from the reconstruction's, and a frame that + wants three sessions borrows the fixture that has three. +- **Structural navigation is two of the four arrows.** `Ctrl+↑` and `Ctrl+↓` move + the locus out and in. Sibling movement needs a sibling list, which is a + question about the execution tree that the journal fixture does not answer. +- **The journey is a projector.** While `--play` runs it supplies the moment on + screen; the store still reduces every keystroke, and the two meet again the + moment the journey ends. +- **Nothing here answers an `Elicit`, executes XMD or opens an Agent session.** + Every claim is about navigation. + +## What the evidence rests on + +Nineteen controls, nine of them new, each breaking exactly one claim and each +rejected by name by the same oracle that admits the honest run. The two that +matter most are the two that reproduce the defects above, because they are the +only reason to believe the byte-driven cases would notice if the repair were +undone. diff --git a/scripts/repl-study/RESULT.md b/scripts/repl-study/RESULT.md index 02608e940..3b771268f 100644 --- a/scripts/repl-study/RESULT.md +++ b/scripts/repl-study/RESULT.md @@ -160,7 +160,16 @@ goldens. ## What was not answered - Focus, the ring, the drawer's trap and where focus returns after a suspension - are #839's, and nothing here establishes them. + are #839's, and nothing here establishes them. They are answered now, in + [`RESULT-focus.md`](./RESULT-focus.md). + +**One correction this experiment owes.** #838 documented `Esc` as "return to the +head" and its suite exercised that path by handing the reducer a synthetic +`{ code: "Escape" }`. No terminal ever produced one: the decoder buffers a lone +`ESC` and asks to be re-scanned, and this harness read `scanned.events` and +dropped `scanned.pending`, so pressing Escape did nothing at all. Shift+Tab was +the same shape of mistake — a real one arrives as `Backtab` carrying no shift +flag. #839 found both by driving bytes rather than events, and repaired them. - Only one native transition is exercised — the drawer's opening. A drawer *closing* would need the moment being left to stay renderable through the transition, which is a question about what a playback holds, and #842's diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index b6ae9f713..3f04d91a9 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -21,9 +21,12 @@ import type { Fixture } from "./model.ts"; import type { Profile, SurfaceName } from "./layout.ts"; import { layoutFor } from "./layout.ts"; import { renderScreen } from "./render.ts"; +import type { FocusView } from "./render.ts"; import { applyAnsi, createGrid, gridText } from "./screen.ts"; -import { initialView } from "./view.ts"; -import type { View } from "./view.ts"; +import { initialView } from "./store.ts"; +import type { View } from "./store.ts"; +import { focusIn, mapOf, fixtureFor, viewOf } from "./store.ts"; +import { FRAMES, stateFor } from "./frames.ts"; import type { Mutation } from "./mutations.ts"; export interface Size { @@ -74,6 +77,8 @@ export interface FrameRequest { readonly surface?: SurfaceName; /** Present only while a playback is running between two fixtures. */ readonly motion?: Motion; + /** Where focus is. Left out, the frame says nothing about focus at all. */ + readonly focus?: FocusView; /** * Seconds since the previous frame, which is the unit the renderer measures * transitions in. Leaving it out hands the renderer its own monotonic clock; @@ -130,7 +135,7 @@ export function renderInto(term: Term, request: FrameRequest): Frame { mutation, }); const result = term.render( - renderScreen({ fixture: subject, view, layout, mutation, motion }), + renderScreen({ fixture: subject, view, layout, mutation, motion, focus: request.focus }), request.deltaSeconds === undefined ? {} : { deltaTime: request.deltaSeconds }, ); if (result.errors.length > 0) { @@ -357,3 +362,37 @@ export function* writeCaptures(directory: string, captures: readonly Capture[]): ); } } + +/** + * The study's frames, at the profiles a reader can check them at. + * + * Every frame is drawn with the numbered overlay on, because the study states + * its frames as numbered targets: numbering them on screen is what makes a + * capture legible as evidence against the frame it reproduces. The narrow set + * is representative rather than exhaustive — the suite checks all fourteen at + * both profiles, and a golden's job here is to be read. + */ +const NARROW_FRAMES = ["01", "05", "07", "12", "14"]; + +export function* captureFocus(): Operation { + const captures: Capture[] = []; + for (const subject of FRAMES) { + const state = stateFor(subject); + const profiles: Profile[] = NARROW_FRAMES.includes(subject.id) ? ["wide", "narrow"] : ["wide"]; + for (const profile of profiles) { + const size = PROFILE_SIZES[profile]; + const frame = yield* renderFrame({ + fixture: fixtureFor(state), + view: viewOf(state), + size, + focus: { + here: focusIn(state, size), + map: mapOf(state, size), + overlay: true, + }, + }); + captures.push({ name: `frame-${subject.id}.${profile}`, profile, size, frame }); + } + } + return captures; +} diff --git a/scripts/repl-study/fixtures.ts b/scripts/repl-study/fixtures.ts index 74500cca3..d3b46917a 100644 --- a/scripts/repl-study/fixtures.ts +++ b/scripts/repl-study/fixtures.ts @@ -13,7 +13,7 @@ * checkpoints of the Journal Time Travel animation. */ -import type { Checkpoint, Fixture, Session, TranscriptRow } from "./model.ts"; +import type { Checkpoint, Drawer, Fixture, Session, TranscriptRow } from "./model.ts"; import { FIXTURE_NAMES } from "./model.ts"; const RETURNED_PROGRAM = [ @@ -434,6 +434,68 @@ const SETTLED_ROWS: readonly TranscriptRow[] = [ { kind: "prose", text: "no REPL bindings published · 1 file written", depth: 0, emphasis: "dim" }, ]; +/** The three suspensions the approved story opens. */ +export const DRAWER_KINDS = ["project", "review", "confirm"] as const; + +export type DrawerKind = (typeof DRAWER_KINDS)[number]; + +export function isDrawerKind(value: string): value is DrawerKind { + return (DRAWER_KINDS as readonly string[]).includes(value); +} + +/** + * The three drawers, keyed by the name a route opens them with. + * + * `model.ts` has carried all three shapes since #838, but only the project form + * had content. Study frames 08 and 09 are the other two, and a drawer a route + * can name has to be a drawer the harness can draw. + */ +const DRAWERS: Record = { + project: { + kind: "project", + heading: "INPUT REQUIRED", + origin: + 'suspended at \u00b7 document scope \u00b7 validated against the Elicit schema', + prompt: "Enter the project details.", + fields: [ + { label: "Project name", value: "Northstar" }, + { label: "Description", value: "A lightweight workspace for coordinating coding agents." }, + ], + schema: ["{", " name: string (required),", " description: string (required)", "}"], + validation: "both fields valid", + submit: "Submit \u2318\u21b5", + }, + review: { + kind: "review", + heading: "REVIEW REQUIRED", + origin: 'suspended at \u00b7 Plan scope \u00b7 59 lines returned', + plan: RETURNED_PROGRAM.slice(0, 6), + more: "\u25b8 53 more lines \u00b7 \u2325\u2193 scrolls the Plan", + decisions: [ + { label: "Approve", chosen: true }, + { label: "Request changes", chosen: false, note: "adds a required feedback field" }, + { label: "Stop", chosen: false }, + ], + submit: "Submit \u2318\u21b5", + }, + confirm: { + kind: "confirm", + heading: "CONFIRMATION REQUIRED", + origin: 'suspended at \u00b7 document scope', + prompt: "Create README.md with the content shown above?", + preview: README, + actions: [ + { label: "Approve", primary: true }, + { label: "Decline", primary: false }, + ], + hint: "\u2318\u21b5 approves \u00b7 Esc closes the drawer without answering it", + }, +}; + +export function drawerOf(kind: DrawerKind): Drawer { + return DRAWERS[kind]; +} + const FIXTURES: Record = { empty: { name: "empty", @@ -587,20 +649,7 @@ const FIXTURES: Record = { scopeName: "document scope", bindings: [{ name: "readme", note: "markdown · 3 lines", lines: ["# Northstar"] }], }, - drawer: { - kind: "project", - heading: "INPUT REQUIRED", - origin: - 'suspended at · document scope · validated against the Elicit schema', - prompt: "Enter the project details.", - fields: [ - { label: "Project name", value: "Northstar" }, - { label: "Description", value: "A lightweight workspace for coordinating coding agents." }, - ], - schema: ["{", " name: string (required),", " description: string (required)", "}"], - validation: "both fields valid", - submit: "Submit ⌘↵", - }, + drawer: DRAWERS.project, input: { label: "DRAFT · ENTRY 2", hint: "Run unavailable while Entry 1 is active", diff --git a/scripts/repl-study/focus.ts b/scripts/repl-study/focus.ts new file mode 100644 index 000000000..c58fd0bde --- /dev/null +++ b/scripts/repl-study/focus.ts @@ -0,0 +1,368 @@ +/** + * Where focus is, derived rather than remembered. + * + * Focus is never a coordinate and never an index into a list that was true last + * frame. It is a semantic identity — `region:transcript`, `control:transport.pause`, + * `field:drawer.project.name` — and every frame a registry of the identities + * that exist *now* is built from the route, the journal and the layout. Asking + * where focus is means resolving one identity against that registry. + * + * That is the whole answer to two of #839's acceptance criteria at once. A + * background update cannot steal focus, because nothing ever writes focus when + * one arrives. And a route transition restores a stable identity or the nearest + * surviving owner, because a vanished identity is resolved by walking its owner + * chain rather than by remembering to move anything. + * + * Two lists, and conflating them is the trap. The **registry** is visible and + * enabled, and is what Tab moves through. The **map** is visible whether enabled + * or not, and is what the `F1` overlay numbers — study frame 12 numbers a dimmed + * `Continue` and says Tab skips it. + */ + +import type { Layout } from "./layout.ts"; +import type { Mutation } from "./mutations.ts"; +import type { ReplState } from "./store.ts"; +import { DRAWER_KINDS } from "./fixtures.ts"; +import type { DrawerKind } from "./fixtures.ts"; + +export interface FocusTarget { + readonly id: string; + readonly kind: "region" | "control" | "field"; + readonly label: string; + /** The identity resolution walks to when this one disappears. */ + readonly owner?: string; + readonly enabled: boolean; +} + +/** The five regions, in the study's forward order. */ +export const REGIONS = ["sessions", "transcript", "bindings", "input", "history"] as const; + +/** + * Who owns an identity, read from the identity itself. + * + * Resolution has to answer this for a target that is already gone, so it cannot + * be a lookup in the registry that no longer contains it. The naming scheme is + * the ownership, which is why identities are structured rather than opaque. + */ +export function ownerOf(identity: string): string | undefined { + if (identity.startsWith("region:")) { + return undefined; + } + if (identity.startsWith("control:input.")) { + return "region:input"; + } + if (identity.startsWith("control:transport.")) { + return "region:history"; + } + if (identity.startsWith("control:drawer.") || identity.startsWith("field:drawer.")) { + return "region:transcript"; + } + return undefined; +} + +/** + * The live counterpart of a transport control. + * + * Study frame 13 requires that leaving history with `Continue` focused lands on + * `Pause` — the control that undoes what the focused one did — rather than on + * the region that owned it. A counterpart is preferred over the owner walk. + */ +export function counterpartOf(identity: string): string | undefined { + if (identity === "control:transport.continue") { + return "control:transport.pause"; + } + if (identity === "control:transport.pause") { + return "control:transport.continue"; + } + return undefined; +} + +interface DrawerTarget { + readonly id: string; + readonly kind: "control" | "field"; + readonly label: string; +} + +/** Each drawer's own sequence, taken from study frames 07, 08 and 09. */ +const DRAWER_TARGETS: Record = { + project: [ + { id: "field:drawer.project.name", kind: "field", label: "Project name" }, + { id: "field:drawer.project.description", kind: "field", label: "Description" }, + { id: "control:drawer.project.schema", kind: "control", label: "Schema disclosure · ⌥S" }, + { id: "control:drawer.project.submit", kind: "control", label: "Submit" }, + ], + review: [ + { id: "control:drawer.review.scroll", kind: "control", label: "Plan review · scroll region" }, + { id: "control:drawer.review.approve", kind: "control", label: "Approve" }, + { id: "control:drawer.review.request", kind: "control", label: "Request changes" }, + { id: "control:drawer.review.stop", kind: "control", label: "Stop" }, + { id: "control:drawer.review.submit", kind: "control", label: "Submit" }, + ], + confirm: [ + { + id: "control:drawer.confirm.preview", + kind: "control", + label: "README preview · scroll region", + }, + { id: "control:drawer.confirm.approve", kind: "control", label: "Approve" }, + { id: "control:drawer.confirm.decline", kind: "control", label: "Decline" }, + ], +}; + +export function drawerTargets(kind: DrawerKind): readonly DrawerTarget[] { + return DRAWER_TARGETS[kind]; +} + +/** True while the identity is the history region itself or one of its controls. */ +function withinHistory(identity: string): boolean { + return identity === "region:history" || identity.startsWith("control:transport."); +} + +function regionLabel(state: ReplState, region: (typeof REGIONS)[number]): string { + if (region === "sessions") { + const journal = state.route.at !== undefined || state.selection >= 0; + return journal ? "Journal · checkpoint list" : `Sessions · ${state.moment.sessions}`; + } + if (region === "transcript") { + return state.route.at === undefined ? "Transcript" : "Transcript · read-only"; + } + if (region === "bindings") { + return "Bindings"; + } + if (region === "input") { + return state.moment.entry === "running" ? "REPL input · Run disabled" : "REPL input"; + } + return "Execution History"; +} + +/** + * The transport controls the footer is exposing. + * + * `history` is an **explicit** region: its controls join the sequence only once + * the region has been entered, which is what study frame 03 shows by focusing + * region 5 and listing no controls at all. Being entered is being focused + * within it. There is nothing to expose before an execution has been recorded, + * so an empty REPL has no transport however focus got there. + */ +function transportTargets(state: ReplState): FocusTarget[] { + if (state.moment.entry === "none" || !withinHistory(state.focus)) { + return []; + } + const owner = "region:history"; + if (state.moment.transport === "live") { + return [ + { id: "control:transport.pause", kind: "control", label: "Pause", owner, enabled: true }, + ]; + } + if (state.moment.transport === "paused") { + return [ + { + id: "control:transport.continue", + kind: "control", + label: "Continue", + owner, + enabled: true, + }, + { + id: "control:transport.return-head", + kind: "control", + label: "Return to paused head", + owner, + enabled: true, + }, + ]; + } + if (state.moment.transport === "inspecting") { + return [ + // Visible, dimmed, and numbered — but skipped by Tab. Resuming from a + // reconstruction is not a thing this state can do. + { + id: "control:transport.continue", + kind: "control", + label: "Continue · disabled while inspecting", + owner, + enabled: false, + }, + { + id: "control:transport.return-head", + kind: "control", + label: "Return to paused head", + owner, + enabled: true, + }, + { + id: "control:transport.fork", + kind: "control", + label: "Fork from here", + owner, + enabled: true, + }, + ]; + } + return []; +} + +/** + * Every visible target, in traversal order. + * + * The ring is the five regions with **each region's own controls inlined + * immediately after it**, which is why the study's numbers are not the Tab + * order: numbering assigns 1–5 to the regions and 6 upward to the controls, + * while traversal visits `Run` between the input and the footer. Study frames + * 05, 11 and 14 only agree with each other under that reading. + * + * While a drawer is open the sequence is the top drawer's own controls and the + * Execution History region, and nothing else. The footer is inside the trap + * deliberately: the study calls it "the one way out", and it is how the fixed + * history footer stays reachable through a suspension. + */ +export function focusMap( + state: ReplState, + layout: Layout, + mutation?: Mutation, +): readonly FocusTarget[] { + if (layout.profile === "too-small") { + return []; + } + const top = state.route.drawers[state.route.drawers.length - 1]; + const trapped = top !== undefined && (DRAWER_KINDS as readonly string[]).includes(top); + if (trapped && mutation !== "leak-drawer-trap") { + const kind = DRAWER_KINDS.find((one) => one === top)!; + return [ + ...DRAWER_TARGETS[kind].map((target) => ({ + ...target, + owner: ownerOf(target.id), + enabled: true, + })), + { + id: "region:history", + kind: "region" as const, + label: "Execution History · still reachable", + enabled: true, + }, + ]; + } + + const targets: FocusTarget[] = []; + for (const region of REGIONS) { + targets.push({ + id: `region:${region}`, + kind: "region", + label: regionLabel(state, region), + enabled: true, + }); + if (region === "input") { + // `input` is an **adjacent** region: Run is listed whenever it is enabled, + // with no Enter required. It is enabled when there is something to run and + // nothing already running, which is why the empty REPL of frames 01–04 + // numbers five targets and the settled entry of frame 14 numbers six. + const runnable = state.moment.entry !== "running" && state.route.draft !== ""; + if (runnable) { + targets.push({ + id: "control:input.run", + kind: "control", + label: "Run", + owner: "region:input", + enabled: true, + }); + } + } + if (region === "history") { + targets.push(...transportTargets(state)); + } + } + return targets; +} + +/** The map, less every target Tab is not allowed to land on. */ +export function registry( + state: ReplState, + layout: Layout, + mutation?: Mutation, +): readonly FocusTarget[] { + const map = focusMap(state, layout, mutation); + // The control admits a target the overlay shows but the ring excludes, which + // is the one distinction between the two lists. + return mutation === "focus-hidden-target" ? map : map.filter((target) => target.enabled); +} + +/** + * The number the `F1` overlay writes beside a target. + * + * Regions take 1–5 and controls take 6 upward, which is assigned separately + * from traversal order. Inside a drawer the trap is numbered straight through, + * as frames 07, 08 and 09 do. + */ +export function numbering(targets: readonly FocusTarget[]): Map { + const numbers = new Map(); + const regions = targets.filter((target) => target.kind === "region"); + const rest = targets.filter((target) => target.kind !== "region"); + if (regions.length !== REGIONS.length) { + targets.forEach((target, index) => numbers.set(target.id, index + 1)); + return numbers; + } + regions.forEach((target, index) => numbers.set(target.id, index + 1)); + rest.forEach((target, index) => numbers.set(target.id, regions.length + index + 1)); + return numbers; +} + +/** + * The identity focus actually lands on. + * + * An identity that is present and enabled is returned unchanged. Otherwise a + * declared counterpart is preferred, then the owner chain is walked upward to + * the nearest surviving enabled target, and an exhausted chain falls back to the + * first target in the ring. Nothing has to remember to move focus, because this + * is asked fresh every frame. + */ +export function resolve(identity: string, targets: readonly FocusTarget[]): string { + const alive = (id: string): boolean => + targets.some((target) => target.id === id && target.enabled); + if (alive(identity)) { + return identity; + } + const counterpart = counterpartOf(identity); + if (counterpart !== undefined && alive(counterpart)) { + return counterpart; + } + let owner = ownerOf(identity); + const seen = new Set([identity]); + while (owner !== undefined && !seen.has(owner)) { + if (alive(owner)) { + return owner; + } + seen.add(owner); + owner = ownerOf(owner); + } + return targets.find((target) => target.enabled)?.id ?? ""; +} + +/** One step around the ring, forward or in reverse, wrapping at both ends. */ +export function step(identity: string, targets: readonly FocusTarget[], delta: 1 | -1): string { + const enabled = targets.filter((target) => target.enabled); + if (enabled.length === 0) { + return ""; + } + const from = resolve(identity, targets); + const at = Math.max( + 0, + enabled.findIndex((target) => target.id === from), + ); + const next = (at + delta + enabled.length) % enabled.length; + return enabled[next].id; +} + +/** + * The map in the order the overlay numbers it. + * + * Traversal order and numbering order are not the same list, which is the whole + * reason the study's numbers look out of sequence: `Run` is numbered after the + * Execution History region and traversed before it, because it belongs to the + * input. The ring is what `focusMap` returns; this is what `F1` draws. + */ +export function mapOrder(targets: readonly FocusTarget[]): readonly FocusTarget[] { + const numbers = numbering(targets); + return [...targets].toSorted( + (one, other) => (numbers.get(one.id) ?? 0) - (numbers.get(other.id) ?? 0), + ); +} diff --git a/scripts/repl-study/frames.ts b/scripts/repl-study/frames.ts new file mode 100644 index 000000000..eba5eb1f9 --- /dev/null +++ b/scripts/repl-study/frames.ts @@ -0,0 +1,354 @@ +/** + * The Product Owner's focus study, as fourteen addressable states. + * + * Each frame of the approved study carries a numbered target list, a focused + * number, and a `meta` record naming what Tab and Shift+Tab do from there. That + * is the acceptance source for #839, so it is transcribed here rather than + * paraphrased: `study` is the label the study prints, `id` is the identity this + * harness answers with, and `tab` and `shift` are the identities the study's + * prose names. + * + * `url` is what makes a frame reachable — `deno task repl:study --frame 07` + * opens it — and `head` is how far the execution had got when it was taken, + * which is journal truth and deliberately not in the URL. + */ + +import { journalThrough } from "./journal.ts"; +import type { FixtureName } from "./model.ts"; +import { hydrate } from "./store.ts"; +import type { ReplState } from "./store.ts"; + +export interface StudyTarget { + /** The number the study's overlay writes beside this target. */ + readonly n: number; + readonly id: string; + readonly kind: "region" | "control" | "field"; + /** The label the study prints. The harness writes its own, from state. */ + readonly study: string; +} + +export interface StudyFrame { + readonly id: string; + readonly title: string; + /** What the study says produced this frame. */ + readonly key: string; + readonly url: string; + /** The journal marker the execution had recorded. Absent is a fresh REPL. */ + readonly head?: string; + readonly focus: string; + readonly fixture: FixtureName; + /** Whether the study drew the numbered overlay in this frame. */ + readonly overlay: boolean; + /** Where the scrubber was, for the one frame that had moved it. */ + readonly selection?: string; + readonly targets: readonly StudyTarget[]; + /** The identity Tab lands on, and the study's own wording for it. */ + readonly tab: string; + readonly shift: string; + readonly meta: { readonly tab: string; readonly shift: string; readonly trap: boolean }; +} + +const REGIONS: readonly StudyTarget[] = [ + { n: 1, id: "region:sessions", kind: "region", study: "Sessions / Journal / State" }, + { n: 2, id: "region:transcript", kind: "region", study: "Transcript" }, + { n: 3, id: "region:bindings", kind: "region", study: "Bindings" }, + { n: 4, id: "region:input", kind: "region", study: "REPL input" }, + { n: 5, id: "region:history", kind: "region", study: "Execution History" }, +]; + +const PAUSE: StudyTarget = { n: 6, id: "control:transport.pause", kind: "control", study: "Pause" }; +const CONTINUE: StudyTarget = { + n: 6, + id: "control:transport.continue", + kind: "control", + study: "Continue", +}; +const RETURN_HEAD: StudyTarget = { + n: 7, + id: "control:transport.return-head", + kind: "control", + study: "Return to paused head", +}; +const FORK: StudyTarget = { + n: 8, + id: "control:transport.fork", + kind: "control", + study: "Fork from here", +}; + +export const FRAMES: readonly StudyFrame[] = [ + { + id: "01", + title: "Empty REPL · focus in the input", + key: "initial focus on load", + url: "xmd://repl/e1/input", + focus: "region:input", + fixture: "empty", + overlay: false, + targets: [REGIONS[3]], + tab: "region:history", + shift: "region:bindings", + meta: { tab: "region 5 · Execution History", shift: "region 3 · Bindings", trap: false }, + }, + { + id: "02", + title: "Focus map overlay activated", + key: "F1 · toggle focus map", + url: "xmd://repl/e1/input", + focus: "region:input", + fixture: "empty", + overlay: true, + targets: REGIONS, + tab: "region:history", + shift: "region:bindings", + meta: { tab: "5 · Execution History", shift: "3 · Bindings", trap: false }, + }, + { + id: "03", + title: "Forward Tab · into Execution History", + key: "Tab", + url: "xmd://repl/e1/history", + focus: "region:history", + fixture: "empty", + overlay: true, + targets: REGIONS, + tab: "region:sessions", + shift: "region:input", + meta: { tab: "1 · Sessions — the ring wraps", shift: "4 · REPL input", trap: false }, + }, + { + id: "04", + title: "Reverse Shift+Tab · back to Bindings", + key: "Shift+Tab ×2 from region 5", + url: "xmd://repl/e1/bindings", + focus: "region:bindings", + fixture: "empty", + overlay: true, + targets: REGIONS, + tab: "region:input", + shift: "region:transcript", + meta: { tab: "4 · REPL input", shift: "2 · Transcript", trap: false }, + }, + { + id: "05", + title: "Running transcript · footer controls reachable", + key: "Tab ×2 from the transcript", + url: "xmd://repl/e1/history/entry-1/document", + head: "cp-06", + focus: "control:transport.pause", + fixture: "nested", + overlay: true, + targets: [...REGIONS, PAUSE], + tab: "region:sessions", + shift: "region:history", + meta: { + tab: "1 · Sessions — leaves the footer", + shift: "5 · Execution History region", + trap: false, + }, + }, + { + id: "06", + title: "Three Agent sessions · background activity does not steal focus", + key: "no keypress — reviewer session starts streaming", + url: "xmd://repl/e1/transcript/entry-1/document", + head: "cp-11", + focus: "region:transcript", + fixture: "drawer", + overlay: true, + targets: REGIONS, + tab: "region:bindings", + shift: "region:sessions", + meta: { tab: "3 · Bindings", shift: "1 · Sessions", trap: false }, + }, + { + id: "07", + title: "Project-details Elicit · focus trapped in the drawer", + key: 'execution suspends at ', + url: "xmd://repl/e1/transcript/entry-1/document/+project", + head: "cp-12", + focus: "field:drawer.project.name", + fixture: "drawer", + overlay: true, + targets: [ + { n: 1, id: "field:drawer.project.name", kind: "field", study: "Project name" }, + { n: 2, id: "field:drawer.project.description", kind: "field", study: "Description" }, + { + n: 3, + id: "control:drawer.project.schema", + kind: "control", + study: "Schema disclosure · ⌥S", + }, + { n: 4, id: "control:drawer.project.submit", kind: "control", study: "Submit" }, + { + n: 5, + id: "region:history", + kind: "region", + study: "Execution History · still reachable", + }, + ], + tab: "field:drawer.project.description", + shift: "region:history", + meta: { + tab: "2 · Description", + shift: "5 · Execution History — the one way out of the trap", + trap: true, + }, + }, + { + id: "08", + title: "Plan-review Elicit · scroll region then decisions", + key: "Tab ×1 from the review scroll region", + url: "xmd://repl/e1/transcript/entry-1/document/plan/+review", + head: "cp-08", + focus: "control:drawer.review.approve", + fixture: "nested", + overlay: true, + targets: [ + { + n: 1, + id: "control:drawer.review.scroll", + kind: "control", + study: "Plan review · scroll region", + }, + { n: 2, id: "control:drawer.review.approve", kind: "control", study: "Approve" }, + { n: 3, id: "control:drawer.review.request", kind: "control", study: "Request changes" }, + { n: 4, id: "control:drawer.review.stop", kind: "control", study: "Stop" }, + { n: 5, id: "control:drawer.review.submit", kind: "control", study: "Submit" }, + { n: 6, id: "region:history", kind: "region", study: "Execution History" }, + ], + tab: "control:drawer.review.request", + shift: "control:drawer.review.scroll", + meta: { tab: "3 · Request changes", shift: "1 · review scroll region", trap: true }, + }, + { + id: "09", + title: "README-confirmation Elicit · Approve and Decline", + key: "Tab ×1 from the preview region", + url: "xmd://repl/e1/transcript/entry-1/document/+confirm", + head: "cp-14", + focus: "control:drawer.confirm.approve", + fixture: "drawer", + overlay: true, + targets: [ + { + n: 1, + id: "control:drawer.confirm.preview", + kind: "control", + study: "README preview · scroll region", + }, + { n: 2, id: "control:drawer.confirm.approve", kind: "control", study: "Approve" }, + { n: 3, id: "control:drawer.confirm.decline", kind: "control", study: "Decline" }, + { n: 4, id: "region:history", kind: "region", study: "Execution History" }, + ], + tab: "control:drawer.confirm.decline", + shift: "control:drawer.confirm.preview", + meta: { tab: "3 · Decline", shift: "1 · README preview", trap: true }, + }, + { + id: "10", + title: "Paused at the live head", + key: "Enter on Pause, from frame 05", + url: "xmd://repl/e1/history/entry-1/document", + head: "cp-16", + focus: "control:transport.continue", + fixture: "paused", + overlay: true, + targets: [...REGIONS, CONTINUE, RETURN_HEAD], + tab: "control:transport.return-head", + shift: "region:history", + meta: { tab: "7 · Return to paused head", shift: "5 · Execution History region", trap: false }, + }, + { + id: "11", + title: "Execution History navigation · checkpoint selected", + key: "← ← · step back two checkpoints", + url: "xmd://repl/e1/history/entry-1/document", + head: "cp-16", + focus: "region:history", + fixture: "paused", + overlay: true, + selection: "cp-14", + targets: [...REGIONS, CONTINUE, RETURN_HEAD], + tab: "control:transport.continue", + shift: "region:input", + meta: { tab: "6 · Continue", shift: "4 · REPL input", trap: false }, + }, + { + id: "12", + title: "Historical inspection · reconstructed, read-only", + key: "Enter on the selected checkpoint", + url: "xmd://repl/e1/history/entry-1/document/plan?at=cp-04", + head: "cp-16", + focus: "control:transport.fork", + fixture: "paused", + overlay: true, + targets: [ + ...REGIONS, + { + n: 6, + id: "control:transport.continue", + kind: "control", + study: "Continue · disabled while inspecting", + }, + RETURN_HEAD, + FORK, + ], + tab: "region:sessions", + shift: "control:transport.return-head", + meta: { tab: "1 · Journal — the ring wraps", shift: "7 · Return to paused head", trap: false }, + }, + { + id: "13", + title: "Return to live execution", + key: "Enter on Return to paused head, then Continue", + url: "xmd://repl/e1/history/entry-1/document", + head: "cp-17", + focus: "control:transport.pause", + fixture: "drawer", + overlay: true, + targets: [...REGIONS, PAUSE], + tab: "region:sessions", + shift: "region:history", + meta: { tab: "1 · Sessions", shift: "5 · Execution History region", trap: false }, + }, + { + id: "14", + title: "Settled entry · REPL input ready for Entry 2", + key: "no keypress — Entry 1 completes", + url: "xmd://repl/e1/input?draft=%3CPlan%3E", + head: "cp-19", + focus: "region:input", + fixture: "settled", + overlay: true, + targets: [ + ...REGIONS, + { n: 6, id: "control:input.run", kind: "control", study: "Run · enabled again" }, + ], + tab: "control:input.run", + shift: "region:bindings", + meta: { tab: "6 · Run", shift: "3 · Bindings", trap: false }, + }, +]; + +export function frame(id: string): StudyFrame | undefined { + return FRAMES.find((one) => one.id === id); +} + +/** + * One frame, as a state. + * + * The URL and the journal do all of the rebuilding. The frame then declares + * where focus was, because focus is disposable and no URL claims to carry it — + * which is exactly why the evidence has to check that the declared identity is + * still a live target in the state the URL rebuilt. + */ +export function stateFor(subject: StudyFrame): ReplState { + const journal = journalThrough(subject.head); + const state = hydrate(subject.url, journal); + const selection = + subject.selection === undefined + ? -1 + : journal.findIndex((record) => record.marker === subject.selection); + return { ...state, focus: subject.focus, selection, overlay: subject.overlay }; +} diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 708dcc1a8..29840766b 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -14,23 +14,24 @@ */ import { alternateBuffer, createInput, cursor, settings } from "@bomb.sh/tty"; -import type { Input, InputEvent, Setting, Term } from "@bomb.sh/tty"; +import type { Input, InputEvent, ScanResult, Setting, Term } from "@bomb.sh/tty"; import { createSignal, ensure, resource, sleep, spawn, until } from "effection"; import type { Operation, Signal, Task } from "effection"; import { fixture, fixtures } from "./fixtures.ts"; -import { FIXTURE_NAMES } from "./model.ts"; import type { Fixture, FixtureName } from "./model.ts"; -import { layoutFor, SURFACES } from "./layout.ts"; -import { renderScreen } from "./render.ts"; +import { SURFACES } from "./layout.ts"; import { transcriptLines } from "./render.ts"; -import { initialView, moveSurface, returnToHead, scrollBy, scrubBy, toggleDrawer } from "./view.ts"; -import type { View } from "./view.ts"; +import type { FocusView } from "./render.ts"; +import { initialView } from "./store.ts"; +import { asKey, fixtureFor, focusIn, hydrate, mapOf, reduce, viewOf } from "./store.ts"; +import type { HarnessEvent, ReplState, View } from "./store.ts"; +import { journalThrough, markerShowing } from "./journal.ts"; +import { formatRoute } from "./route.ts"; import { RendererCapacityError, useTerm } from "./capture.ts"; import { renderInto } from "./capture.ts"; import type { Mutation } from "./mutations.ts"; import { - JOURNEY, motionAt, playbackFrom, segmentDurationMs, @@ -143,11 +144,7 @@ export function measureTerminal(): { cols: number; rows: number } { return { cols: ASSUMED_SIZE.cols, rows: ASSUMED_SIZE.rows }; } -export type HarnessEvent = - | { readonly kind: "key"; readonly event: InputEvent } - | { readonly kind: "resize" } - | { readonly kind: "tick"; readonly advanceMs: number } - | { readonly kind: "quit" }; +export type { HarnessEvent }; export interface HarnessState { readonly view: View; @@ -157,81 +154,42 @@ export interface HarnessState { readonly quit: boolean; } -function fixtureAt(index: number): FixtureName { - return FIXTURE_NAMES[Math.max(0, Math.min(FIXTURE_NAMES.length - 1, index))]; -} - /** - * How one event changes what is shown. + * One chunk of raw keystrokes, decoded completely. + * + * A lone `ESC` is ambiguous until the terminal has had its say, so the decoder + * buffers it and asks to be re-scanned with an empty buffer after its own + * latency. A harness that reads `scanned.events` and drops `scanned.pending` + * swallows every Escape the user presses — the key is documented, the reducer + * handles it, and pressing it does nothing. Honouring `pending` here is what + * makes Escape arrive at all. * - * Pure, so the same transitions the interactive harness performs can be - * replayed without a terminal. + * The flush is bounded: the decoder reports `pending` again when re-scanned + * before its latency has elapsed, and a loop that trusted it without a ceiling + * would spin on a terminal whose clock disagreed. */ -export function reduce(state: HarnessState, event: HarnessEvent): HarnessState { - if (event.kind === "quit") { - return { ...state, quit: true }; - } - if (event.kind === "resize") { - const size = measureTerminal(); - return { ...state, cols: size.cols, rows: size.rows }; - } - if (event.kind === "tick") { - // A frame passing changes what is drawn, never what is shown: the motion is - // a function of elapsed time, which the frame loop owns. - return state; - } - const key = event.event; - if (key.type !== "keydown") { - return state; - } - if (key.code === "q" || (key.ctrl === true && key.code === "c")) { - return { ...state, quit: true }; - } - const digit = Number(key.code); - if (!Number.isNaN(digit) && digit >= 1 && digit <= FIXTURE_NAMES.length) { - const next = fixture(fixtureAt(digit - 1)); - return { ...state, fixture: next, view: initialView(next) }; - } - const layout = layoutFor({ - cols: state.cols, - rows: state.rows, - drawer: state.fixture.drawer !== undefined && state.view.drawerOpen, - surface: state.view.surface, - }); - const width = Math.max(1, (layout.transcript?.width ?? state.cols) - 2); - const height = layout.transcript?.height ?? state.rows; - const total = state.fixture.entry ? transcriptLines(state.fixture.entry, width).length : 0; - const limit = Math.max(0, total - Math.max(1, height - 3)); - const checkpoints = state.fixture.history.checkpoints.length; - - if (key.code === "ArrowUp") { - return { ...state, view: scrollBy(state.view, -1, limit) }; - } - if (key.code === "ArrowDown") { - return { ...state, view: scrollBy(state.view, 1, limit) }; - } - if (key.code === "PageUp") { - return { ...state, view: scrollBy(state.view, -Math.max(1, height - 4), limit) }; - } - if (key.code === "PageDown") { - return { ...state, view: scrollBy(state.view, Math.max(1, height - 4), limit) }; - } - if (key.code === "ArrowLeft") { - return { ...state, view: scrubBy(state.view, -1, checkpoints) }; - } - if (key.code === "ArrowRight") { - return { ...state, view: scrubBy(state.view, 1, checkpoints) }; - } - if (key.code === "Escape") { - return { ...state, view: returnToHead(state.view) }; - } - if (key.code === "Tab") { - return { ...state, view: moveSurface(state.view, key.shift === true ? -1 : 1) }; - } - if (key.code === "d") { - return { ...state, view: toggleDrawer(state.view) }; +const FLUSH_ATTEMPTS = 4; + +export function* scanKeys( + input: Input, + chunk: Uint8Array | undefined, + deliver: (event: InputEvent) => void, + mutation?: Mutation, +): Operation { + const dispatch = (scanned: ScanResult): ScanResult["pending"] => { + for (const event of scanned.events) { + deliver(event); + } + return scanned.pending; + }; + let pending = dispatch(input.scan(chunk)); + for (let attempt = 0; attempt < FLUSH_ATTEMPTS; attempt += 1) { + if (pending === undefined || mutation === "swallow-pending-escape") { + return; + } + yield* sleep(pending.delay); + pending = dispatch(input.scan()); } - return state; } /** One frame drawn, and whether the renderer is still moving. */ @@ -247,6 +205,7 @@ function draw( mutation?: Mutation, motion?: Motion, deltaMs = 0, + focus?: FocusView, ): Painted { // One render path for the harness and for the captures, so what a person sees // in a terminal and what a golden records cannot drift apart. @@ -256,6 +215,7 @@ function draw( size: { cols: state.cols, rows: state.rows }, mutation, motion, + focus, // The harness counts in milliseconds and the renderer in seconds. The // conversion happens here, once, at the only place the two meet. deltaSeconds: deltaMs / 1000, @@ -301,9 +261,51 @@ export interface TraceEntry { readonly fixture: FixtureName; } +/** + * The state a run opens at. + * + * `--route` says it outright. A fixture name says it indirectly: the journal + * knows which marker reconstructs that moment, and a moment with a suspension + * waiting opens the drawer that is waiting, because that is what an execution + * suspending does. + */ +export function openingState(options: { + readonly fixture: FixtureName; + readonly route?: string; + readonly head?: string; + readonly focusMap?: boolean; +}): ReplState { + const head = options.head ?? markerShowing(options.fixture); + const journal = journalThrough(head); + if (options.route !== undefined) { + const opened = hydrate(options.route, journal); + return { ...opened, overlay: options.focusMap === true }; + } + const start = { + execution: "e1", + surface: "transcript" as const, + scopes: [], + drawers: [], + draft: "", + }; + const opened = hydrate(formatRoute(start), journal); + const waiting = opened.moment.suspension; + const routed = + waiting === undefined + ? opened + : hydrate(formatRoute({ ...start, drawers: [waiting] }), journal); + return { ...routed, overlay: options.focusMap === true }; +} + export interface InteractiveOptions { readonly fixture: FixtureName; readonly mutation?: Mutation; + /** Open at this URL instead of at a fixture's own moment. */ + readonly route?: string; + /** How far the execution has recorded, which a URL never carries. */ + readonly head?: string; + /** Start with the numbered focus map drawn. */ + readonly focusMap?: boolean; /** Start this playback immediately, rather than waiting for `p`. */ readonly play?: Playback; /** Play the whole approved story, holds and all, with no keystrokes. */ @@ -333,9 +335,13 @@ export function* runInteractive(options: InteractiveOptions): Operation { Deno.stdout.writeSync(bytes); }; const size = measureTerminal(); + // One owner for where the person is. The journey below is a projector rather + // than a place: while it runs it supplies the moment on screen, and the store + // is what every keystroke acts on. + let repl = openingState(options); let state: HarnessState = { - view: initialView(fixture(options.fixture)), - fixture: fixture(options.fixture), + view: viewOf(repl), + fixture: fixtureFor(repl), cols: size.cols, rows: size.rows, quit: false, @@ -350,7 +356,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { const events = createSignal(); const subscription = yield* events; - yield* useSignalListener("SIGWINCH", () => events.send({ kind: "resize" })); + yield* useSignalListener("SIGWINCH", () => events.send({ kind: "resize", ...measureTerminal() })); yield* useSignalListener("SIGINT", () => events.send({ kind: "quit" })); yield* useSignalListener("SIGTERM", () => events.send({ kind: "quit" })); @@ -362,10 +368,14 @@ export function* runInteractive(options: InteractiveOptions): Operation { events.send({ kind: "quit" }); return; } - const scanned = input.scan(chunk.value); - for (const event of scanned.events) { - events.send({ kind: "key", event }); - } + // The flush runs inside the reader task, so a cancelled session takes it + // along and nothing re-scans into a terminal that has been restored. + yield* scanKeys( + input, + chunk.value, + (event) => events.send({ kind: "key", event }), + options.mutation, + ); } }); @@ -392,6 +402,11 @@ export function* runInteractive(options: InteractiveOptions): Operation { state = { ...state, fixture: target, view: initialView(target) }; }; + /** Where the store says we are, once the projector is not overriding it. */ + const follow = () => { + state = { ...state, fixture: fixtureFor(repl), view: viewOf(repl), quit: repl.quit }; + }; + if (journey !== undefined) { const first = journey[0]; show(segmentFixture(first)); @@ -473,9 +488,15 @@ export function* runInteractive(options: InteractiveOptions): Operation { // out. const repeated = journey !== undefined && segment?.kind === "hold" && held === label; if (!repeated) { + const measured = { cols: state.cols, rows: state.rows }; + const focus: FocusView = { + here: focusIn(repl, measured, options.mutation), + map: mapOf(repl, measured, options.mutation), + overlay: repl.overlay, + }; let painted: Painted; try { - painted = draw(term, state, write, options.mutation, motion, deltaMs); + painted = draw(term, state, write, options.mutation, motion, deltaMs, focus); } catch (error) { if (!(error instanceof RendererCapacityError)) { throw error; @@ -483,8 +504,8 @@ export function* runInteractive(options: InteractiveOptions): Operation { // The renderer ran out of room to measure text, which a long run in a // wide terminal will do. A new one starts that cache again and repaints // the whole screen, so the person watching sees nothing but a frame. - term = yield* useTerm({ cols: state.cols, rows: state.rows }); - painted = draw(term, state, write, options.mutation, motion, 0); + term = yield* useTerm(measured); + painted = draw(term, state, write, options.mutation, motion, 0, focus); } frames += 1; options.trace?.push({ @@ -564,7 +585,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { // A keystroke or a resize is not time passing, so the renderer is told no // time has passed: a transition in flight keeps its own pace instead of // jumping forward because somebody typed. - const pressed = next.value.kind === "key" ? next.value.event : undefined; + const pressed = next.value.kind === "key" ? asKey(next.value.event) : undefined; if (pressed !== undefined && pressed.type === "keydown") { const code = pressed.code; if (code === "p") { @@ -584,10 +605,34 @@ export function* runInteractive(options: InteractiveOptions): Operation { // Ignoring a resize means ignoring it completely — the renderer keeps the // dimensions it had, and goes on addressing cells the terminal no longer // has. - state = - next.value.kind === "resize" && options.mutation === "skip-resize-update" - ? state - : reduce(state, next.value); + if (next.value.kind === "resize") { + if (options.mutation !== "skip-resize-update") { + const measured = { cols: next.value.cols, rows: next.value.rows }; + state = { ...state, cols: measured.cols, rows: measured.rows }; + repl = reduce(repl, next.value, { + size: measured, + mutation: options.mutation, + scrollLimit: 0, + }); + if (journey === undefined && playback === undefined) { + follow(); + } + } + } else { + const lines = state.fixture.entry + ? transcriptLines(state.fixture.entry, Math.max(1, state.cols - 2)).length + : 0; + repl = reduce(repl, next.value, { + size: { cols: state.cols, rows: state.rows }, + mutation: options.mutation, + scrollLimit: Math.max(0, lines - Math.max(1, state.rows - 8)), + }); + if (journey === undefined && playback === undefined) { + follow(); + } else { + state = { ...state, quit: repl.quit }; + } + } if (state.quit) { return; } diff --git a/scripts/repl-study/journal.ts b/scripts/repl-study/journal.ts new file mode 100644 index 000000000..20f354093 --- /dev/null +++ b/scripts/repl-study/journal.ts @@ -0,0 +1,343 @@ +/** + * Execution truth, as a record list. + * + * #842 owns the real journal. This is a fixture of one: the approved story's + * twenty recorded moments, written out by hand so that folding them is the only + * way to learn what was open, what had been published and what was waiting at + * any of them. + * + * It is authored from the study rather than generated from `fixtures.ts` on + * purpose. A journal derived from the fixtures would make the hydration case + * compare the fixtures with themselves, and pass while proving nothing about + * rebuilding a state from a URL. + */ + +import type { FixtureName, TransportMode } from "./model.ts"; +import type { DrawerKind } from "./fixtures.ts"; + +export type JournalKind = + | "entry.submitted" + | "scope.enter" + | "scope.exit" + | "binding.published" + | "session.started" + | "suspension.opened" + | "suspension.answered" + | "paused" + | "resumed" + | "entry.settled"; + +export interface JournalRecord { + /** The marker a URL names this moment by. */ + readonly marker: string; + /** Recorded seconds, which is what the Execution History band measures. */ + readonly at: number; + readonly kind: JournalKind; + /** The scope the record was made in, named as the study names it. */ + readonly scope: string; + /** The binding, session, drawer or entry the record is about. */ + readonly detail: string; + /** The moment following the head here shows, which is one of the six. */ + readonly shows: FixtureName; + /** + * The moment *inspecting* this marker shows, when that is a different one. + * + * Following the head at 00:12 is the Plan opening live; reconstructing 00:12 + * is the read-only Plan scope with the head still out at the end. Same + * marker, two pictures, and only a fold that is told which question it is + * answering can tell them apart. + */ + readonly reconstructs?: FixtureName; +} + +export type JournalFixture = readonly JournalRecord[]; + +export const JOURNAL: JournalFixture = [ + { + marker: "cp-01", + at: 2, + kind: "entry.submitted", + scope: "REPL", + detail: "Entry 1", + shows: "nested", + }, + { + marker: "cp-02", + at: 5, + kind: "scope.enter", + scope: "document", + detail: "document", + shows: "nested", + }, + { + marker: "cp-03", + at: 6, + kind: "session.started", + scope: "document", + detail: "plan-a91f7c", + shows: "nested", + }, + { + marker: "cp-04", + at: 12, + kind: "scope.enter", + scope: "Plan", + detail: "Plan", + shows: "nested", + reconstructs: "paused", + }, + { + marker: "cp-05", + at: 18, + kind: "binding.published", + scope: "Plan", + detail: "inputs", + shows: "nested", + }, + { + marker: "cp-06", + at: 29, + kind: "binding.published", + scope: "Plan", + detail: "draft", + shows: "nested", + }, + { + marker: "cp-07", + at: 30, + kind: "session.started", + scope: "Plan", + detail: "review-b72e1d", + shows: "nested", + }, + { + marker: "cp-08", + at: 35, + kind: "suspension.opened", + scope: "Plan", + detail: "review", + shows: "nested", + }, + { + marker: "cp-09", + at: 41, + kind: "suspension.answered", + scope: "Plan", + detail: "review", + shows: "nested", + }, + { + marker: "cp-10", + at: 47, + kind: "scope.exit", + scope: "Plan", + detail: "Plan", + shows: "generated", + }, + { + marker: "cp-11", + at: 48, + kind: "session.started", + scope: "document", + detail: "implement-c31d2e", + shows: "drawer", + }, + { + marker: "cp-12", + at: 49, + kind: "suspension.opened", + scope: "document", + detail: "project", + shows: "drawer", + }, + { + marker: "cp-13", + at: 52, + kind: "suspension.answered", + scope: "document", + detail: "project", + shows: "drawer", + }, + { + marker: "cp-14", + at: 53, + kind: "suspension.opened", + scope: "document", + detail: "confirm", + shows: "drawer", + }, + { + marker: "cp-15", + at: 54, + kind: "suspension.answered", + scope: "document", + detail: "confirm", + shows: "drawer", + }, + { + marker: "cp-16", + at: 55, + kind: "paused", + scope: "document", + detail: "Entry 1", + shows: "paused", + }, + { + marker: "cp-17", + at: 57, + kind: "resumed", + scope: "document", + detail: "Entry 1", + shows: "drawer", + }, + { + marker: "cp-18", + at: 60, + kind: "binding.published", + scope: "document", + detail: "readme", + shows: "drawer", + }, + { + marker: "cp-19", + at: 61, + kind: "entry.settled", + scope: "REPL", + detail: "Entry 1", + shows: "settled", + }, +]; + +/** What the execution had got to, and what was true there. */ +export interface Moment { + /** The marker this moment sits at, absent when nothing has been recorded. */ + readonly marker?: string; + readonly at: number; + readonly transport: TransportMode; + /** The innermost scope that was open. */ + readonly scope: string; + /** The bindings that scope had published by then, in the order they arrived. */ + readonly published: readonly string[]; + /** The suspension waiting for an answer, when one was. */ + readonly suspension?: DrawerKind; + readonly sessions: number; + readonly entry: "none" | "running" | "settled"; + readonly shows: FixtureName; +} + +function isDrawerKind(value: string): value is DrawerKind { + return value === "project" || value === "review" || value === "confirm"; +} + +/** The records up to and including one marker, which is the journal as it stood. */ +export function journalThrough( + marker: string | undefined, + journal: JournalFixture = JOURNAL, +): JournalFixture { + if (marker === undefined) { + return []; + } + const at = journal.findIndex((record) => record.marker === marker); + if (at === -1) { + throw new Error(`no such journal marker: ${marker}`); + } + return journal.slice(0, at + 1); +} + +/** + * Fold a journal into the moment it describes. + * + * With `upTo` the fold stops at that marker and the result is a reconstruction: + * the transport says `inspecting`, because what is on screen is a recorded + * moment rather than the head. Without it the fold runs to the end of whatever + * journal it was handed, which is the head by definition. + */ +export function fold(journal: JournalFixture, upTo?: string): Moment { + const scopes: string[] = ["REPL"]; + const published = new Map([["REPL", []]]); + let sessions = 0; + let suspension: DrawerKind | undefined; + let entry: Moment["entry"] = "none"; + let transport: TransportMode = "idle"; + let shows: FixtureName = "empty"; + let marker: string | undefined; + let at = 0; + let reached = upTo === undefined; + + for (const record of journal) { + if (record.kind === "entry.submitted") { + entry = "running"; + transport = "live"; + } + if (record.kind === "scope.enter") { + scopes.push(record.detail); + published.set(record.detail, []); + } + if (record.kind === "scope.exit") { + const left = scopes.lastIndexOf(record.detail); + if (left > 0) { + scopes.splice(left, 1); + } + published.delete(record.detail); + } + if (record.kind === "binding.published") { + published.get(scopes[scopes.length - 1])?.push(record.detail); + } + if (record.kind === "session.started") { + sessions += 1; + } + if (record.kind === "suspension.opened" && isDrawerKind(record.detail)) { + suspension = record.detail; + } + if (record.kind === "suspension.answered") { + suspension = undefined; + } + if (record.kind === "paused") { + transport = "paused"; + } + if (record.kind === "resumed") { + transport = "live"; + } + if (record.kind === "entry.settled") { + entry = "settled"; + transport = "idle"; + } + marker = record.marker; + at = record.at; + shows = upTo === undefined ? record.shows : (record.reconstructs ?? record.shows); + if (upTo !== undefined && record.marker === upTo) { + reached = true; + break; + } + } + + if (!reached) { + throw new Error(`the journal handed to this fold never reaches ${upTo}`); + } + + const scope = scopes[scopes.length - 1]; + return { + marker, + at, + transport: upTo === undefined ? transport : "inspecting", + scope: `${scope} scope`, + published: published.get(scope) ?? [], + suspension, + sessions, + entry, + shows, + }; +} + +/** The markers a scrubber steps through, which is every recorded moment. */ +export function markers(journal: JournalFixture): readonly string[] { + return journal.map((record) => record.marker); +} + +/** The first marker whose moment reconstructs to one of the six fixtures. */ +export function markerShowing( + name: FixtureName, + journal: JournalFixture = JOURNAL, +): string | undefined { + return journal.find((record) => record.shows === name)?.marker; +} diff --git a/scripts/repl-study/main.ts b/scripts/repl-study/main.ts index f36866759..37ff0b39b 100644 --- a/scripts/repl-study/main.ts +++ b/scripts/repl-study/main.ts @@ -3,6 +3,9 @@ * * deno task repl:study --play the whole story, start to finish * deno task repl:study one moment, in this terminal + * deno task repl:study --frame 07 one frame of the focus study + * deno task repl:study --route any location, said as a URL + * deno task repl:study --focus-map open with the numbered overlay on * deno task repl:study --capture every fixture at every profile * deno task repl:study --print nested wide one frame, as text * deno task repl:study --replay the lifecycle, with no terminal @@ -13,7 +16,9 @@ import { ensure, exit, main } from "effection"; import type { Operation } from "effection"; -import { captureAll, PROFILE_SIZES, renderFrame, writeCaptures } from "./capture.ts"; +import { captureAll, captureFocus, PROFILE_SIZES, renderFrame, writeCaptures } from "./capture.ts"; +import { frame as studyFrame, FRAMES } from "./frames.ts"; +import { parseRoute } from "./route.ts"; import { fixture } from "./fixtures.ts"; import { runInteractive, runReplay } from "./host.ts"; import type { TraceEntry } from "./host.ts"; @@ -25,7 +30,7 @@ import type { FixtureName } from "./model.ts"; import type { Profile } from "./layout.ts"; import { isMutation } from "./mutations.ts"; import type { Mutation } from "./mutations.ts"; -import { initialView } from "./view.ts"; +import { initialView } from "./store.ts"; const USAGE = [ "usage:", @@ -33,13 +38,18 @@ const USAGE = [ " repl-study --play the whole story, start to finish", " repl-study --play one transition, as a diagnostic", " [--frames ] [--interrupt-after-frames ] [--trace ]", + " repl-study --frame one frame of the approved focus study", + " repl-study --route one location, said as a URL", + " repl-study [--frame ] --focus-map with the numbered focus map drawn", " repl-study --capture [--mutation ]", + " repl-study --capture-focus the focus study's frames, as text", " repl-study --print [--mutation ]", " repl-study --replay [--interrupt-after ] [--fail-after ] [--mutation ]", "", "fixtures: empty, nested, generated, drawer, paused, settled", "profiles: wide, medium, narrow, too-small", "playbacks: empty→nested, nested→generated, generated→drawer, drawer→paused, paused→settled", + `frames: ${FRAMES.map((one) => one.id).join(", ")}`, ].join("\n"); type Mode = @@ -51,8 +61,11 @@ type Mode = readonly maxFrames?: number; readonly interruptAfterFrames?: number; readonly trace?: string; + readonly route?: string; + readonly head?: string; + readonly focusMap?: boolean; } - | { readonly kind: "capture"; readonly directory: string } + | { readonly kind: "capture"; readonly directory: string; readonly focus?: boolean } | { readonly kind: "print"; readonly fixture: FixtureName; readonly profile: Profile } | { readonly kind: "replay"; readonly interruptAfter?: number; readonly failAfter?: number }; @@ -85,6 +98,9 @@ export function parse(argv: readonly string[]): Invocation | string { let maxFrames: number | undefined; let interruptAfterFrames: number | undefined; let trace: string | undefined; + let route: string | undefined; + let head: string | undefined; + let focusMap = false; let at = 0; const value = (): string | undefined => { @@ -162,6 +178,33 @@ export function parse(argv: readonly string[]): Invocation | string { return "--trace needs a file to write"; } trace = path; + } else if (argument === "--frame") { + const id = value(); + const found = id === undefined ? undefined : studyFrame(id); + if (found === undefined) { + return `--frame needs one of ${FRAMES.map((one) => one.id).join(", ")}, not ${JSON.stringify(id)}`; + } + route = found.url; + head = found.head; + fixtureName = found.fixture; + } else if (argument === "--route") { + const url = value(); + const parsed = url === undefined ? undefined : parseRoute(url); + if (parsed === undefined) { + return "--route needs a REPL URL"; + } + if (!parsed.ok) { + return parsed.error.message; + } + route = url; + } else if (argument === "--focus-map") { + focusMap = true; + } else if (argument === "--capture-focus") { + const directory = value(); + if (directory === undefined) { + return "--capture-focus needs a directory to write into"; + } + mode = { kind: "capture", directory, focus: true }; } else if (argument === "--replay") { mode = { kind: "replay" }; } else if (argument === "--interrupt-after" || argument === "--fail-after") { @@ -191,6 +234,9 @@ export function parse(argv: readonly string[]): Invocation | string { maxFrames, interruptAfterFrames, trace, + route, + head, + focusMap, }, mutation, }; @@ -200,7 +246,7 @@ function* run(invocation: Invocation): Operation { const { mode, mutation } = invocation; if (mode.kind === "capture") { - const captures = yield* captureAll(); + const captures = mode.focus === true ? yield* captureFocus() : yield* captureAll(); yield* writeCaptures(mode.directory, captures); console.log(`wrote ${captures.length} captures to ${mode.directory}`); return; @@ -247,6 +293,9 @@ function* run(invocation: Invocation): Operation { } yield* runInteractive({ fixture: mode.fixture, + route: mode.route, + head: mode.head, + focusMap: mode.focusMap, mutation, play: mode.play, journey: mode.journey === true ? JOURNEY : undefined, diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index 1cd5abe0d..fe7759c72 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -28,6 +28,24 @@ export const MUTATIONS = [ "never-tick", /** Reconstruct a moment as a half-finished animation rather than a state. */ "restore-mid-animation", + /** Move focus when a background update arrives. */ + "steal-focus-on-background", + /** Let Tab escape an open drawer into the panes behind it. */ + "leak-drawer-trap", + /** Admit a target the map shows but the registry excludes. */ + "focus-hidden-target", + /** Push a navigation entry for every keystroke in the draft. */ + "push-draft-edits", + /** Rebuild the route from the profile instead of preserving it. */ + "drop-route-on-resize", + /** Leave focus where it was when a drawer closes. */ + "forget-drawer-invoker", + /** Permit a mutation while a recorded moment is under inspection. */ + "mutate-while-inspecting", + /** Drop `ScanResult.pending`, so a lone Escape is never delivered. */ + "swallow-pending-escape", + /** Accept only a synthetic Tab+shift as reverse traversal. */ + "ignore-backtab", ] as const; export type Mutation = (typeof MUTATIONS)[number]; diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index c0402e1a5..32a2deca1 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -18,8 +18,10 @@ import type { Op } from "@bomb.sh/tty"; import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow } from "./model.ts"; import type { Layout, Rect } from "./layout.ts"; import { MINIMUM } from "./layout.ts"; -import type { View } from "./view.ts"; +import type { View } from "./store.ts"; import type { Mutation } from "./mutations.ts"; +import type { FocusTarget } from "./focus.ts"; +import { mapOrder, numbering } from "./focus.ts"; import type { Motion } from "./playback.ts"; const C = { @@ -37,6 +39,7 @@ const C = { gold: rgba(0xc9, 0xa8, 0x6a), fail: rgba(0xd2, 0x4b, 0x3f), rule: rgba(0x16, 0x1c, 0x21), + focus: rgba(0x9a, 0xe0, 0xa8), }; const BG = { @@ -242,6 +245,102 @@ const DRAWER_TRANSITION = { properties: ["height", "y"], } as const; +/** + * What the renderer is told about focus. + * + * It is handed the answer rather than asked to work one out: `focus.ts` derives + * the registry and the map every frame, and drawing is not a place where a + * second opinion about where focus is may be formed. + */ +export interface FocusView { + /** The identity focus resolved to. */ + readonly here: string; + /** Every visible target, enabled or not, which is what the overlay numbers. */ + readonly map: readonly FocusTarget[]; + readonly overlay: boolean; +} + +/** The glyph a focused region wears, so focus survives a monochrome terminal. */ +const FOCUS_GLYPH = "\u258c"; + +/** The glyph beside a focused control or field. */ +const FOCUS_MARK = "\u25b8"; + +/** Where the region carrying one identity was composed, if it is on screen. */ +function regionRect(layout: Layout, identity: string): Rect | undefined { + if (identity === "region:sessions") { + return layout.sidebar; + } + if (identity === "region:transcript") { + return layout.transcript; + } + if (identity === "region:bindings") { + return layout.bindings; + } + if (identity === "region:input") { + return layout.contextual; + } + if (identity === "region:history") { + return layout.footer; + } + return undefined; +} + +function focusMarkerOps(layout: Layout, focus: FocusView | undefined): Op[] { + if (focus === undefined) { + return []; + } + const rect = regionRect(layout, focus.here); + if (rect === undefined) { + return []; + } + return [ + open("focus-marker", { + layout: { width: fixed(1), height: fixed(1) }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + }), + text(FOCUS_GLYPH, { color: C.focus }), + close(), + ]; +} + +/** The word the footer draws for a transport control, keyed by its identity. */ +const TRANSPORT_WORDS: Record = { + "control:transport.pause": ["Pause"], + "control:transport.continue": ["Continue"], + "control:transport.return-head": ["Return to paused head", "Return"], + "control:transport.fork": ["Fork from here", "Fork"], +}; + +/** + * The numbered focus map, as a legend rather than as floating callouts. + * + * The study numbers its targets on top of the interface, which a browser can do + * because it measured them. In cells the honest equivalent is a legend: the + * same numbers, in the same order, with a disabled target dimmed and present — + * study frame 12 numbers a dimmed `Continue` and says Tab skips it, so the map + * has to show what the ring does not. + */ +function focusMapRegion(layout: Layout, focus: FocusView): Op[] { + const ordered = mapOrder(focus.map); + const numbers = numbering(focus.map); + const width = Math.min(34, Math.max(18, Math.round(layout.cols * 0.24))); + const height = Math.min(layout.rows, ordered.length + 2); + const rect = { x: Math.max(0, layout.cols - width - 1), y: 1, width, height }; + const lines: VisualLine[] = [label("FOCUS MAP · F1")]; + for (const target of ordered) { + const on = target.id === focus.here; + lines.push({ + segments: [ + { text: on ? `${FOCUS_MARK} ` : " ", color: C.focus, width: 2 }, + { text: `${numbers.get(target.id) ?? 0}`, color: target.enabled ? C.out : C.dim, width: 3 }, + { text: target.label, color: target.enabled ? C.src : C.dim }, + ], + }); + } + return region("focus-map", rect, lines, { bg: BG.drawer }); +} + function blank(): VisualLine { return { segments: [{ text: "" }] }; } @@ -552,8 +651,18 @@ function bindingsRegion(fixture: Fixture, layout: Layout, rect: Rect): Op[] { return region("bindings", rect, lines, { bg: BG.bind }); } -function contextualRegion(fixture: Fixture, view: View, layout: Layout, rect: Rect): Op[] { +function contextualRegion( + fixture: Fixture, + view: View, + layout: Layout, + rect: Rect, + focus?: FocusView, +): Op[] { const width = Math.max(0, rect.width - 2); + // With nothing to say about focus the drawer is drawn exactly as #838 drew + // it, which is what keeps a frame that is not about focus byte-identical. + const mark = (id: string): string => + focus === undefined ? "" : focus.here === id ? `${FOCUS_MARK} ` : " "; if (fixture.drawer && view.drawerOpen) { const drawer = fixture.drawer; const lines: VisualLine[] = [plain(drawer.heading, C.hold)]; @@ -569,7 +678,8 @@ function contextualRegion(fixture: Fixture, view: View, layout: Layout, rect: Re } lines.push(blank()); for (const field of drawer.fields) { - lines.push(label(field.label)); + const id = `field:drawer.project.${field.label === "Project name" ? "name" : "description"}`; + lines.push(label(`${mark(id)}${field.label}`)); lines.push({ segments: [ { text: "┃ ", color: C.rule, width: 2 }, @@ -580,22 +690,24 @@ function contextualRegion(fixture: Fixture, view: View, layout: Layout, rect: Re lines.push(blank(), { segments: [ { text: drawer.validation, color: C.dim, width: Math.min(width, 20) }, - { text: drawer.submit, color: C.tick }, + { text: `${mark("control:drawer.project.submit")}${drawer.submit}`, color: C.tick }, ], }); if (!layout.dense) { - lines.push(blank(), label("schema")); + lines.push(blank(), label(`${mark("control:drawer.project.schema")}schema`)); for (const schema of drawer.schema) { lines.push(plain(schema, C.settledText)); } } } if (drawer.kind === "review") { - for (const planLine of drawer.plan) { + lines.push(plain(`${mark("control:drawer.review.scroll")}${drawer.plan[0] ?? ""}`, C.src)); + for (const planLine of drawer.plan.slice(1)) { lines.push(plain(planLine, C.src)); } lines.push(plain(drawer.more, C.dim), blank()); - for (const decision of drawer.decisions) { + const decided = ["approve", "request", "stop"]; + drawer.decisions.forEach((decision, index) => { lines.push({ segments: [ { @@ -603,29 +715,35 @@ function contextualRegion(fixture: Fixture, view: View, layout: Layout, rect: Re color: decision.chosen ? C.tick : C.label, width: 4, }, - { text: decision.label, color: decision.chosen ? C.out : C.src }, + { + text: `${mark(`control:drawer.review.${decided[index] ?? index}`)}${decision.label}`, + color: decision.chosen ? C.out : C.src, + }, ], }); - } - lines.push(blank(), plain(drawer.submit, C.tick)); + }); + lines.push(blank(), plain(`${mark("control:drawer.review.submit")}${drawer.submit}`, C.tick)); } if (drawer.kind === "confirm") { for (const wrapped of wrapText(drawer.prompt, width)) { lines.push(plain(wrapped, C.src)); } - for (const preview of drawer.preview) { + drawer.preview.forEach((preview, index) => { lines.push({ segments: [ { text: "│ ", color: C.rule, width: 2 }, - { text: preview, color: C.src }, + { + text: index === 0 ? `${mark("control:drawer.confirm.preview")}${preview}` : preview, + color: C.src, + }, ], }); - } + }); lines.push(blank(), { segments: drawer.actions.map((action) => ({ - text: `[ ${action.label} ]`, + text: `[ ${mark(`control:drawer.confirm.${action.label.toLowerCase()}`)}${action.label} ]`, color: action.primary ? C.tick : C.label, - width: action.label.length + 6, + width: action.label.length + 6 + (focus === undefined ? 0 : 2), })), }); lines.push(plain(drawer.hint, C.dim)); @@ -820,17 +938,19 @@ function footerRegion( rect: Rect, mutation?: Mutation, motion?: Motion, + focus?: FocusView, ): Op[] { const history = fixture.history; // While a playback runs, the head is where the application says it is; the // recorded head is where it will be when the motion settles. const headAt = motion !== undefined && !motion.done ? motion.headAt : history.headAt; const flat = mutation === "flatten-notches"; - const { transport, right, inner, labelWidth, trackLeft, trackWidth } = bandGeometry( - fixture, - layout, - rect, - ); + const geometry = bandGeometry(fixture, layout, rect); + const { transport, inner, labelWidth, trackLeft, trackWidth } = geometry; + // The marker replaces the space inside the bracket rather than widening it: + // the track's room is computed from this string, and a focused control that + // shortened the track would make focus a layout decision. + const right = markTransport(geometry.right, focus); const grid: string[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => " ")); const colors: number[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => C.dim)); @@ -997,6 +1117,20 @@ function footerRegion( return region("footer", rect, lines, { bg: BG.footer, padding: { left: 1, right: 1 } }); } +/** `[ Continue ]` becomes `[▸Continue ]` — the same width, one glyph louder. */ +function markTransport(right: string, focus: FocusView | undefined): string { + if (focus === undefined) { + return right; + } + for (const word of TRANSPORT_WORDS[focus.here] ?? []) { + const bracketed = `[ ${word} ]`; + if (right.includes(bracketed)) { + return right.replace(bracketed, `[${FOCUS_MARK}${word} ]`); + } + } + return right; +} + /** Keep each cell's colour when a grid row becomes segments. */ function runsOf(glyphs: readonly string[], colors: readonly number[]): Segment[] { const segments: Segment[] = []; @@ -1081,10 +1215,12 @@ export interface ScreenRequest { readonly mutation?: Mutation; /** Present only while a playback is running between two fixtures. */ readonly motion?: Motion; + /** Where focus is, and what the overlay would number. */ + readonly focus?: FocusView; } export function renderScreen(request: ScreenRequest): Op[] { - const { fixture, view, layout, mutation, motion } = request; + const { fixture, view, layout, mutation, motion, focus } = request; const ops: Op[] = [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: BG.app }), ]; @@ -1112,24 +1248,33 @@ export function renderScreen(request: ScreenRequest): Op[] { const covering = mutation === "drawer-covers-footer" && layout.footer !== undefined && view.drawerOpen; if (layout.contextual && !covering) { - ops.push(...contextualRegion(fixture, view, layout, layout.contextual)); + ops.push(...contextualRegion(fixture, view, layout, layout.contextual, focus)); } if (layout.footer) { - ops.push(...footerRegion(fixture, view, layout, layout.footer, mutation, motion)); + ops.push(...footerRegion(fixture, view, layout, layout.footer, mutation, motion, focus)); } if (layout.contextual && covering) { // Drawn last, so it lands on top of the band the study says is never // covered — which is the point of this control. ops.push( - ...contextualRegion(fixture, view, layout, { - ...layout.contextual, - height: layout.contextual.height + layout.footer!.height, - }), + ...contextualRegion( + fixture, + view, + layout, + { ...layout.contextual, height: layout.contextual.height + layout.footer!.height }, + focus, + ), ); } for (const [index, separator] of layout.separators.entries()) { ops.push(...rule(`rule.${index}`, separator, separator.width === 1 ? "│" : "─")); } + // Focus is drawn last, over the regions it describes, because a marker under + // the thing it marks is a marker nobody sees. + ops.push(...focusMarkerOps(layout, focus)); + if (focus?.overlay === true) { + ops.push(...focusMapRegion(layout, focus)); + } ops.push(close()); return ops; } diff --git a/scripts/repl-study/route.ts b/scripts/repl-study/route.ts new file mode 100644 index 000000000..27ee9e678 --- /dev/null +++ b/scripts/repl-study/route.ts @@ -0,0 +1,174 @@ +/** + * Where you are, said as one URL. + * + * A REPL that cannot be reopened has no location, only a pile of fields. This + * module is the whole of what "location" means here: the execution, the surface + * that owns focus, the entry and scopes you have opened inside it, the drawers + * stacked on top, the recorded marker you are inspecting, and the draft you have + * typed but not run. Scroll offsets, the phase of an animation and which target + * is focused right now are deliberately not in it — they can be thrown away + * without changing what the REPL means. + * + * xmd://repl/e1/transcript/entry-1/document/+project?at=cp-07&draft=%3CPlan%3E + * + * Parsing refuses rather than guesses, because a URL that quietly lost a drawer + * would reopen a suspended execution as if nothing were waiting. + */ + +import { Err, Ok } from "effection"; +import type { Result } from "effection"; + +/** + * The five regions a route can name. + * + * These are not `layout.ts`'s `SURFACES`. That list is the four regions narrow + * routing promotes to a whole screen; the REPL input is never one of those + * because narrow already renders it inside the transcript. It is still a place + * focus can be, so it is a route surface and not a layout surface. + */ +export const ROUTE_SURFACES = ["sessions", "transcript", "bindings", "input", "history"] as const; + +export type RouteSurface = (typeof ROUTE_SURFACES)[number]; + +export function isRouteSurface(value: string): value is RouteSurface { + return (ROUTE_SURFACES as readonly string[]).includes(value); +} + +export interface Route { + readonly execution: string; + readonly surface: RouteSurface; + /** The entry, then the visible scopes opened inside it. */ + readonly scopes: readonly string[]; + /** The drawer stack. The last one is the top, and only the top is interactive. */ + readonly drawers: readonly string[]; + /** The recorded marker under inspection. Absent means following the live head. */ + readonly at?: string; + /** What has been typed and not run. Empty is the same as nothing typed. */ + readonly draft: string; +} + +/** The authority is `repl`, because this URL addresses a REPL and not a document. */ +const PREFIX = "xmd://repl/"; + +/** A drawer segment wears this, so a drawer is never mistaken for a scope. */ +const DRAWER_PREFIX = "+"; + +export function formatRoute(route: Route): string { + const path = [ + encodeURIComponent(route.execution), + route.surface, + ...route.scopes.map((scope) => encodeURIComponent(scope)), + ...route.drawers.map((drawer) => `${DRAWER_PREFIX}${encodeURIComponent(drawer)}`), + ].join("/"); + const query: string[] = []; + if (route.at !== undefined) { + query.push(`at=${encodeURIComponent(route.at)}`); + } + if (route.draft !== "") { + query.push(`draft=${encodeURIComponent(route.draft)}`); + } + return query.length === 0 ? `${PREFIX}${path}` : `${PREFIX}${path}?${query.join("&")}`; +} + +/** + * One URL, parsed, or the reason it was refused. + * + * Percent-decoding is `decodeURIComponent` alone: `+` is a literal plus here, + * which is what lets a drawer segment wear one. + */ +export function parseRoute(url: string): Result { + if (!url.startsWith(PREFIX)) { + return Err( + new Error(`a REPL route starts with ${PREFIX}, and ${JSON.stringify(url)} does not`), + ); + } + const rest = url.slice(PREFIX.length); + const split = rest.indexOf("?"); + const path = split === -1 ? rest : rest.slice(0, split); + const query = split === -1 ? "" : rest.slice(split + 1); + const segments = path.split("/"); + if (segments.length < 2) { + return Err(new Error(`${JSON.stringify(url)} names no surface`)); + } + const execution = decodeURIComponent(segments[0]); + if (execution === "") { + return Err(new Error(`${JSON.stringify(url)} names no execution`)); + } + const surface = segments[1]; + if (!isRouteSurface(surface)) { + return Err( + new Error( + `${JSON.stringify(surface)} is not a surface; the surfaces are ${ROUTE_SURFACES.join(", ")}`, + ), + ); + } + const scopes: string[] = []; + const drawers: string[] = []; + for (const segment of segments.slice(2)) { + if (segment === "") { + return Err(new Error(`${JSON.stringify(url)} has an empty path segment`)); + } + if (segment.startsWith(DRAWER_PREFIX)) { + drawers.push(decodeURIComponent(segment.slice(DRAWER_PREFIX.length))); + continue; + } + if (drawers.length > 0) { + return Err( + new Error(`${JSON.stringify(segment)} is a scope below a drawer, which cannot be reopened`), + ); + } + scopes.push(decodeURIComponent(segment)); + } + + let at: string | undefined; + let draft = ""; + for (const pair of query === "" ? [] : query.split("&")) { + const equals = pair.indexOf("="); + const key = equals === -1 ? pair : pair.slice(0, equals); + const value = equals === -1 ? "" : decodeURIComponent(pair.slice(equals + 1)); + if (key === "at") { + if (value === "") { + return Err(new Error("at= names no marker; leave it out to follow the live head")); + } + at = value; + continue; + } + if (key === "draft") { + draft = value; + continue; + } + return Err(new Error(`${JSON.stringify(key)} is not part of a REPL route`)); + } + + return Ok({ execution, surface, scopes, drawers, at, draft }); +} + +/** The top drawer, which is the only one that is visible and interactive. */ +export function topDrawer(route: Route): string | undefined { + return route.drawers[route.drawers.length - 1]; +} + +/** True while a recorded moment is being inspected rather than the head followed. */ +export function inspecting(route: Route): boolean { + return route.at !== undefined; +} + +/** + * The kinds of move a route can make. + * + * Naming them is what lets push and replace be a decision rather than a habit. + */ +export type RouteChange = "surface" | "locus" | "drawer" | "inspection" | "scrub" | "draft"; + +export type Navigation = "push" | "replace"; + +/** + * Whether a change adds a navigation entry or overwrites the current one. + * + * Scrubbing and draft editing replace, and both are continuous adjustments + * rather than places you went: Back from an inspected marker returns to the + * head rather than walking back through every marker the scrubber passed. + */ +export function navigationFor(change: RouteChange): Navigation { + return change === "scrub" || change === "draft" ? "replace" : "push"; +} diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts new file mode 100644 index 000000000..6c11866f9 --- /dev/null +++ b/scripts/repl-study/store.ts @@ -0,0 +1,650 @@ +/** + * One place that holds where the person is. + * + * #838 kept location in four loose fields on a `View`, mutated by a reducer that + * read keys directly, and that is the defect #839 exists to remove: nothing + * could be reopened, because nothing had been said. Here the REPL has exactly + * three kinds of state, and telling them apart is what makes every acceptance + * criterion reachable. + * + * **Execution truth** belongs to the journal — `journal.ts` for this experiment, + * #842 for the real one. **Location** is the URL in `route.ts`, and nothing else + * is location. **Everything else is disposable**: the scroll anchor, the + * scrubber's selection, whether the overlay is on, which target is focused right + * now. Throwing the disposable half away and rebuilding from the durable half is + * `hydrate()`, and `projection()` is what two states are then compared by. + */ + +import { fixture, drawerOf, isDrawerKind } from "./fixtures.ts"; +import type { DrawerKind } from "./fixtures.ts"; +import { fold, JOURNAL, journalThrough } from "./journal.ts"; +import type { JournalFixture, JournalRecord, Moment } from "./journal.ts"; +import { layoutFor, SURFACES } from "./layout.ts"; +import type { Layout, SurfaceName } from "./layout.ts"; +import { focusMap, registry, resolve, step } from "./focus.ts"; +import type { FocusTarget } from "./focus.ts"; +import type { FixtureName, Fixture, TransportMode } from "./model.ts"; +import type { Mutation } from "./mutations.ts"; +import { formatRoute, navigationFor, parseRoute, topDrawer } from "./route.ts"; +import type { Route, RouteChange, RouteSurface } from "./route.ts"; + +/** + * What the renderer reads. + * + * Every field is derived from the state below it. It exists because the + * renderer clips rather than scrolls, so the window over a long transcript and + * the selected marker have to be told to it in its own terms — not because the + * harness keeps a second copy of where the person is. + */ +export interface View { + readonly fixture: FixtureName; + /** Index of the first visible transcript line. */ + readonly anchor: number; + /** Index into the fixture's checkpoints, or -1 for "following the head". */ + readonly checkpoint: number; + readonly surface: SurfaceName; + readonly drawerOpen: boolean; +} + +export function initialView(subject: Fixture): View { + const selected = subject.history.selectedAt; + const checkpoint = + selected === undefined + ? -1 + : subject.history.checkpoints.findIndex((point) => point.at === selected); + return { + fixture: subject.name, + anchor: 0, + checkpoint, + surface: "transcript", + drawerOpen: subject.drawer !== undefined, + }; +} + +export function scrollBy(view: View, delta: number, limit: number): View { + const anchor = Math.max(0, Math.min(limit, view.anchor + delta)); + return anchor === view.anchor ? view : { ...view, anchor }; +} + +export function moveSurface(view: View, delta: number): View { + const at = SURFACES.indexOf(view.surface); + const next = SURFACES[(at + delta + SURFACES.length) % SURFACES.length]; + return { ...view, surface: next }; +} + +/** + * The four layout surfaces are not the five route surfaces. + * + * Narrow routing promotes one region to the whole screen, and the REPL input is + * never one of those because `layout.ts` already renders it inside the + * transcript. It is still somewhere focus can be, so it is a route surface; + * mapping it here is the whole of the reconciliation. + */ +export function surfaceOf(route: Route): SurfaceName { + return route.surface === "input" ? "transcript" : route.surface; +} + +export interface ReplState { + /** Parsed from the URL: the durable half, and the only thing reopening needs. */ + readonly route: Route; + /** Execution truth, as it stands. A fixture here; #842 owns the real one. */ + readonly journal: JournalFixture; + /** The fold of that journal at this route, minted with them and never alone. */ + readonly moment: Moment; + /** A semantic identity, resolved against the registry that exists now. */ + readonly focus: string; + /** Disposable: the transcript window. */ + readonly anchor: number; + /** Disposable: which recorded marker the scrubber is on, or -1 for none. */ + readonly selection: number; + /** Disposable: whether the F1 focus map is drawn. */ + readonly overlay: boolean; + /** Which identity opened each drawer, so closing one can restore it. */ + readonly invokers: Readonly>; + /** The navigation stack, for Back. Entries are URLs. */ + readonly history: readonly string[]; + /** How many times a running entry has been interrupted, which never exits. */ + readonly interrupts: number; + readonly quit: boolean; +} + +function mint( + route: Route, + journal: JournalFixture, + rest: Omit, +): ReplState { + return { route, journal, moment: fold(journal, route.at), ...rest }; +} + +/** Rebuild everything durable from a URL and a journal, with nothing else. */ +export function hydrate(url: string, journal: JournalFixture): ReplState { + const parsed = parseRoute(url); + if (!parsed.ok) { + throw parsed.error; + } + return hydrateRoute(parsed.value, journal); +} + +export function hydrateRoute(route: Route, journal: JournalFixture): ReplState { + return mint(route, journal, { + focus: `region:${route.surface}`, + anchor: 0, + selection: -1, + overlay: false, + invokers: {}, + history: [], + interrupts: 0, + quit: false, + }); +} + +/** The semantic projection two states are compared by. Nothing disposable is in it. */ +export interface Projection { + readonly url: string; + readonly surface: RouteSurface; + readonly scopes: readonly string[]; + readonly drawers: readonly string[]; + readonly at?: string; + readonly draft: string; + readonly head?: string; + readonly transport: TransportMode; + readonly scope: string; + readonly published: readonly string[]; + readonly suspension?: DrawerKind; + readonly entry: Moment["entry"]; +} + +export function projection(state: ReplState): Projection { + return { + url: formatRoute(state.route), + surface: state.route.surface, + scopes: state.route.scopes, + drawers: state.route.drawers, + at: state.route.at, + draft: state.route.draft, + head: state.journal[state.journal.length - 1]?.marker, + transport: state.moment.transport, + scope: state.moment.scope, + published: state.moment.published, + suspension: state.moment.suspension, + entry: state.moment.entry, + }; +} + +export interface Size { + readonly cols: number; + readonly rows: number; +} + +export function layoutOf(state: ReplState, size: Size, mutation?: Mutation): Layout { + return layoutFor({ + cols: size.cols, + rows: size.rows, + drawer: topDrawer(state.route) !== undefined, + surface: surfaceOf(state.route), + mutation, + }); +} + +/** + * The fixture this state is showing, with the journal's own answers on it. + * + * The moment decides the transport, whether what is on screen is a + * reconstruction, and which drawer is open — so `--route` and `--frame` really + * do drive the picture, rather than picking a fixture and hoping. + */ +export function fixtureFor(state: ReplState): Fixture { + const base = fixture(state.moment.shows); + const top = topDrawer(state.route); + const drawer = top !== undefined && isDrawerKind(top) ? drawerOf(top) : undefined; + const inspecting = state.route.at !== undefined; + const selected = state.journal[state.selection]?.at; + return { + ...base, + badge: inspecting ? base.badge : undefined, + readOnly: inspecting, + drawer, + history: { + ...base.history, + transport: state.moment.transport, + selectedAt: inspecting ? state.moment.at : selected, + }, + }; +} + +export function viewOf(state: ReplState): View { + const subject = fixtureFor(state); + const selectedAt = subject.history.selectedAt; + return { + fixture: subject.name, + anchor: state.anchor, + checkpoint: + selectedAt === undefined + ? -1 + : subject.history.checkpoints.findIndex((point) => point.at === selectedAt), + surface: surfaceOf(state.route), + drawerOpen: subject.drawer !== undefined, + }; +} + +/** The ring: every target Tab may land on, in traversal order. */ +export function targets(state: ReplState, size: Size, mutation?: Mutation): readonly FocusTarget[] { + return registry(state, layoutOf(state, size, mutation), mutation); +} + +/** The map: every visible target, enabled or not, which is what F1 numbers. */ +export function mapOf(state: ReplState, size: Size, mutation?: Mutation): readonly FocusTarget[] { + return focusMap(state, layoutOf(state, size, mutation), mutation); +} + +/** Where focus actually is, asked fresh rather than remembered. */ +export function focusIn(state: ReplState, size: Size, mutation?: Mutation): string { + return resolve(state.focus, targets(state, size, mutation)); +} + +/** + * Go somewhere, and decide whether that is a place you can come Back from. + * + * A push records the URL being left. A replace does not, which is what keeps + * Back from an inspected marker returning to the head instead of walking back + * through every marker the scrubber passed. + */ +function go(state: ReplState, route: Route, change: RouteChange, mutation?: Mutation): ReplState { + const navigation = + mutation === "push-draft-edits" && change === "draft" ? "push" : navigationFor(change); + return mint(route, state.journal, { + focus: state.focus, + anchor: state.anchor, + selection: state.selection, + overlay: state.overlay, + invokers: state.invokers, + history: navigation === "push" ? [...state.history, formatRoute(state.route)] : state.history, + interrupts: state.interrupts, + quit: state.quit, + }); +} + +function withJournal(state: ReplState, journal: JournalFixture): ReplState { + return mint(state.route, journal, { + focus: state.focus, + anchor: state.anchor, + selection: state.selection, + overlay: state.overlay, + invokers: state.invokers, + history: state.history, + interrupts: state.interrupts, + quit: state.quit, + }); +} + +/** The journal as it stands once the execution records its next `kind`. */ +function extendTo(state: ReplState, kind: JournalRecord["kind"]): ReplState { + const from = state.journal.length; + const next = JOURNAL.slice(from).find((record) => record.kind === kind); + if (next === undefined) { + return state; + } + return withJournal(state, journalThrough(next.marker)); +} + +export type HarnessEvent = + /** Whatever the decoder produced. It is parsed here, never assumed. */ + | { readonly kind: "key"; readonly event: unknown } + | { readonly kind: "resize"; readonly cols: number; readonly rows: number } + | { readonly kind: "tick"; readonly advanceMs: number } + | { readonly kind: "background"; readonly record: JournalRecord } + | { readonly kind: "quit" }; + +export interface ReduceContext { + readonly size: Size; + readonly mutation?: Mutation; + /** How many transcript lines the window may scroll past. */ + readonly scrollLimit: number; +} + +export interface Key { + readonly type: string; + readonly code?: string; + readonly text?: string; + readonly ctrl?: boolean; + readonly shift?: boolean; + readonly alt?: boolean; +} + +/** A decoded key, read rather than assumed: the decoder's shape is its own. */ +export function asKey(event: unknown): Key { + const record = typeof event === "object" && event !== null ? { ...event } : {}; + const read = (name: string): string | undefined => { + const value = Reflect.get(record, name); + return typeof value === "string" ? value : undefined; + }; + const flag = (name: string): boolean => Reflect.get(record, name) === true; + return { + type: read("type") ?? "", + code: read("code"), + text: read("text"), + ctrl: flag("ctrl"), + shift: flag("shift"), + alt: flag("alt"), + }; +} + +/** True where typing has to reach the target rather than the navigator. */ +function editable(identity: string): boolean { + return identity === "region:input" || identity.startsWith("field:"); +} + +/** + * Reverse traversal, as a real terminal spells it. + * + * A terminal sends Shift+Tab as `ESC [ Z`, which this decoder reports as the + * key code `Backtab` carrying no shift flag. A reducer that tested `Tab` with + * `shift` was testing an event only a test had ever produced. + */ +function reverseTab(key: Key, mutation?: Mutation): boolean { + if (key.code === "Tab" && key.shift === true) { + return true; + } + return key.code === "Backtab" && mutation !== "ignore-backtab"; +} + +/** A recorded moment is read-only, so nothing that changes the run may happen in one. */ +function frozen(state: ReplState, mutation?: Mutation): boolean { + return state.route.at !== undefined && mutation !== "mutate-while-inspecting"; +} + +/** + * How one event changes where you are. + * + * Pure, so every transition the interactive harness performs can be driven from + * a test without a terminal — and so that a background update can be shown to + * return state whose route, focus, selection and anchor are the *same + * references* it was handed. + */ +export function reduce(state: ReplState, event: HarnessEvent, context: ReduceContext): ReplState { + const { mutation } = context; + if (event.kind === "quit") { + return { ...state, quit: true }; + } + if (event.kind === "tick" || event.kind === "resize") { + // A frame passing and a terminal resizing change what is drawn, never where + // you are. The route survives a resize because the profile was never + // recorded in it. + if (event.kind === "resize" && mutation === "drop-route-on-resize") { + return hydrateRoute( + { + execution: state.route.execution, + surface: "transcript", + scopes: [], + drawers: [], + draft: "", + }, + state.journal, + ); + } + return state; + } + if (event.kind === "background") { + // Nothing here writes focus, the route, the selection or the anchor, which + // is the whole of why a background update cannot steal any of them. + const extended = withJournal(state, [...state.journal, event.record]); + return mutation === "steal-focus-on-background" + ? { ...extended, focus: "region:sessions" } + : extended; + } + + const key = asKey(event.event); + if (key.type !== "keydown") { + return state; + } + const live = targets(state, context.size, mutation); + const here = resolve(state.focus, live); + + if (key.code === "q" && !editable(here)) { + return { ...state, quit: true }; + } + if (key.ctrl === true && key.code === "c") { + if (state.moment.entry === "running" && state.moment.transport === "live") { + return { ...state, interrupts: state.interrupts + 1 }; + } + if (state.route.draft !== "") { + return go(state, { ...state.route, draft: "" }, "draft", mutation); + } + return { ...state, quit: true }; + } + if (key.code === "F1") { + return { ...state, overlay: !state.overlay }; + } + if (key.code === "Tab" || key.code === "Backtab") { + return { ...state, focus: step(here, live, reverseTab(key, mutation) ? -1 : 1) }; + } + if (key.code === "Escape") { + return back(state, here, live, context.size, mutation); + } + if (key.code === "Enter") { + return activate(state, here, mutation); + } + + const digit = Number(key.code); + if (!editable(here) && Number.isInteger(digit) && digit >= 1 && digit <= 5) { + const surface = (["sessions", "transcript", "bindings", "input", "history"] as const)[ + digit - 1 + ]; + return { + ...go(state, { ...state.route, surface }, "surface", mutation), + focus: `region:${surface}`, + }; + } + + if (key.ctrl === true && key.code !== undefined && key.code.startsWith("Arrow")) { + return structural(state, key.code, mutation); + } + + if (key.code === "ArrowUp") { + return { ...state, anchor: Math.max(0, state.anchor - 1) }; + } + if (key.code === "ArrowDown") { + return { ...state, anchor: Math.min(context.scrollLimit, state.anchor + 1) }; + } + if (key.code === "PageUp") { + return { ...state, anchor: Math.max(0, state.anchor - 10) }; + } + if (key.code === "PageDown") { + return { ...state, anchor: Math.min(context.scrollLimit, state.anchor + 10) }; + } + if (key.code === "ArrowLeft" || key.code === "ArrowRight") { + return scrub(state, key.code === "ArrowLeft" ? -1 : 1, mutation); + } + if (key.code === "d" && !editable(here)) { + return toggleDrawer(state, here, context.size, mutation); + } + + return type(state, key, here, mutation); +} + +/** + * Back, which never discards the draft and never answers anything. + * + * The order is the study's: a drawer first, then a reconstruction, then a + * control, then the navigation stack. Every step of it is non-destructive, + * which is what lets Escape be the one key a person can always press. + */ +function back( + state: ReplState, + here: string, + live: readonly FocusTarget[], + size: Size, + mutation?: Mutation, +): ReplState { + const top = topDrawer(state.route); + if (top !== undefined) { + const closed = go( + state, + { ...state.route, drawers: state.route.drawers.slice(0, -1) }, + "drawer", + mutation, + ); + if (mutation === "forget-drawer-invoker") { + return closed; + } + const invoker = state.invokers[top] ?? "region:transcript"; + return { ...closed, focus: resolve(invoker, targets(closed, size, mutation)) }; + } + if (state.route.at !== undefined) { + return go(state, { ...state.route, at: undefined }, "inspection", mutation); + } + if (!here.startsWith("region:")) { + return { ...state, focus: resolve(ownerRegion(here), live) }; + } + const previous = state.history[state.history.length - 1]; + if (previous === undefined) { + return state; + } + const parsed = parseRoute(previous); + if (!parsed.ok) { + return state; + } + return mint(parsed.value, state.journal, { + focus: state.focus, + anchor: state.anchor, + selection: state.selection, + overlay: state.overlay, + invokers: state.invokers, + history: state.history.slice(0, -1), + interrupts: state.interrupts, + quit: state.quit, + }); +} + +function ownerRegion(identity: string): string { + if (identity.startsWith("control:transport.")) { + return "region:history"; + } + if (identity.startsWith("control:input.")) { + return "region:input"; + } + return "region:transcript"; +} + +/** Enter: what the focused target does when it is activated. */ +function activate(state: ReplState, here: string, mutation?: Mutation): ReplState { + if (here === "control:transport.pause") { + return frozen(state, mutation) ? state : extendTo(state, "paused"); + } + if (here === "control:transport.continue") { + return frozen(state, mutation) ? state : extendTo(state, "resumed"); + } + if (here === "control:transport.return-head") { + return go(state, { ...state.route, at: undefined }, "inspection", mutation); + } + if (here === "region:history" && state.selection >= 0) { + const marker = state.journal[state.selection]?.marker; + if (marker !== undefined) { + // A reconstruction has no live suspension, so the drawer stack does not + // survive into one. That is what makes study frame 12's focus walk real: + // the trapped controls leave the sequence and focus has to resolve to the + // nearest owner that did survive. + return go(state, { ...state.route, at: marker, drawers: [] }, "inspection", mutation); + } + } + return state; +} + +/** + * The chronological axis: one semantic marker at a time. + * + * Selection moves and focus does not — the study is explicit that moving the + * selection never moves focus. While a reconstruction is open the selection is + * the reconstruction, so the URL moves with it, and it replaces rather than + * pushes. + */ +function scrub(state: ReplState, delta: number, mutation?: Mutation): ReplState { + const count = state.journal.length; + if (count === 0) { + return state; + } + const from = state.selection === -1 ? count : state.selection; + const selection = Math.max(0, Math.min(count - 1, from + delta)); + const moved = { ...state, selection }; + if (state.route.at === undefined) { + return moved; + } + const marker = state.journal[selection].marker; + return { ...go(moved, { ...state.route, at: marker }, "scrub", mutation), selection }; +} + +/** + * The structural axis: the locus, not the timeline. + * + * These move where you are in the execution's own tree, so they push. They act + * only when focus is not in an editable target, which is what keeps a modified + * arrow from being stolen out of a draft somebody is typing. + */ +function structural(state: ReplState, code: string, mutation?: Mutation): ReplState { + const scopes = state.route.scopes; + if (code === "ArrowUp") { + return scopes.length === 0 + ? state + : go(state, { ...state.route, scopes: scopes.slice(0, -1) }, "locus", mutation); + } + if (code === "ArrowDown") { + const child = state.moment.scope.replace(/ scope$/, ""); + return scopes[scopes.length - 1] === child + ? state + : go(state, { ...state.route, scopes: [...scopes, child] }, "locus", mutation); + } + return state; +} + +/** `d` opens the suspension that is waiting, or closes the one that is open. */ +function toggleDrawer(state: ReplState, here: string, size: Size, mutation?: Mutation): ReplState { + const top = topDrawer(state.route); + if (top !== undefined) { + return back(state, here, targets(state, size, mutation), size, mutation); + } + const waiting = state.moment.suspension; + if (waiting === undefined || frozen(state, mutation)) { + return state; + } + return openDrawer(state, waiting, here, size, mutation); +} + +/** Opening records the identity that invoked it, so closing can restore it. */ +export function openDrawer( + state: ReplState, + kind: DrawerKind, + invoker: string, + size: Size, + mutation?: Mutation, +): ReplState { + const opened = go( + state, + { ...state.route, drawers: [...state.route.drawers, kind] }, + "drawer", + mutation, + ); + // A suspension puts focus on the first meaningful control in the drawer + // rather than on the drawer itself, which is what study frame 07 shows. + return { + ...opened, + invokers: { ...state.invokers, [kind]: invoker }, + focus: targets(opened, size, mutation)[0]?.id ?? state.focus, + }; +} + +/** Typing edits the draft, which replaces the current URL rather than adding to it. */ +function type(state: ReplState, key: Key, here: string, mutation?: Mutation): ReplState { + if (!editable(here) || frozen(state, mutation)) { + return state; + } + if (key.code === "Backspace") { + return go(state, { ...state.route, draft: state.route.draft.slice(0, -1) }, "draft", mutation); + } + const glyph = key.text ?? (key.code !== undefined && [...key.code].length === 1 ? key.code : ""); + if (glyph === "" || key.ctrl === true || key.alt === true) { + return state; + } + return go(state, { ...state.route, draft: state.route.draft + glyph }, "draft", mutation); +} + +export { JOURNAL, journalThrough }; diff --git a/scripts/repl-study/view.ts b/scripts/repl-study/view.ts deleted file mode 100644 index 65aabb900..000000000 --- a/scripts/repl-study/view.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * What the person looking at the harness has chosen. - * - * The renderer clips; it does not scroll. So the window over a long transcript, - * the selected checkpoint, and which surface narrow routing is showing are the - * application's to own — and they survive every resize, which is the property - * #838 asks a profile transition to preserve. - */ - -import type { FixtureName } from "./model.ts"; -import type { Fixture } from "./model.ts"; -import type { SurfaceName } from "./layout.ts"; -import { SURFACES } from "./layout.ts"; - -export interface View { - readonly fixture: FixtureName; - /** Index of the first visible transcript line. */ - readonly anchor: number; - /** Index into the fixture's checkpoints, or -1 for "following the head". */ - readonly checkpoint: number; - readonly surface: SurfaceName; - readonly drawerOpen: boolean; -} - -export function initialView(fixture: Fixture): View { - const selected = fixture.history.selectedAt; - const checkpoint = - selected === undefined - ? -1 - : fixture.history.checkpoints.findIndex((point) => point.at === selected); - return { - fixture: fixture.name, - anchor: 0, - checkpoint, - surface: "transcript", - drawerOpen: fixture.drawer !== undefined, - }; -} - -export function scrollBy(view: View, delta: number, limit: number): View { - const anchor = Math.max(0, Math.min(limit, view.anchor + delta)); - return anchor === view.anchor ? view : { ...view, anchor }; -} - -/** - * Move the selection one checkpoint at a time. - * - * Navigation runs over the checkpoint list rather than over the columns the band - * drew, so a marker that had to share a column with its neighbour is still - * reachable — which is the whole reason the band is allowed to summarize. - */ -export function scrubBy(view: View, delta: number, count: number): View { - if (count === 0) { - return view; - } - const from = view.checkpoint === -1 ? count : view.checkpoint; - const checkpoint = Math.max(0, Math.min(count - 1, from + delta)); - return checkpoint === view.checkpoint ? view : { ...view, checkpoint }; -} - -/** Return to the head, abandoning a historical selection. */ -export function returnToHead(view: View): View { - return view.checkpoint === -1 ? view : { ...view, checkpoint: -1 }; -} - -export function moveSurface(view: View, delta: number): View { - const at = SURFACES.indexOf(view.surface); - const next = SURFACES[(at + delta + SURFACES.length) % SURFACES.length]; - return { ...view, surface: next }; -} - -export function showSurface(view: View, surface: SurfaceName): View { - return view.surface === surface ? view : { ...view, surface }; -} - -export function toggleDrawer(view: View): View { - return { ...view, drawerOpen: !view.drawerOpen }; -} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index da21d0e9d..07d5743fd 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -41,6 +41,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "its subject is the Deno terminal harness in scripts/repl-study: the host reads Deno.consoleSize(), sets Deno.stdin raw mode, installs Deno signal listeners, and the restoration cases run `deno run` as a child. A Node or Bun shard has no `deno` on PATH and no equivalent of the host it is testing", issue: "https://github.com/taras/executable.md/issues/838", }, + { + path: "scripts/tests/repl-focus.test.ts", + reason: + "its subject is the route and focus model of the same Deno terminal harness: it drives `@bomb.sh/tty`'s decoder for the pending-Escape flush and for Backtab, renders through the harness's Deno-only host, and runs `deno run` as a child to check the documented command. A Node or Bun shard has no `deno` on PATH and no equivalent of the host it is testing", + issue: "https://github.com/taras/executable.md/issues/839", + }, { path: "scripts/tests/build-npm.test.ts", reason: diff --git a/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt new file mode 100644 index 000000000..6847007f6 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt @@ -0,0 +1,28 @@ +frame-01.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL Tab ▸ + TRANSCRIPT FOCUS MAP · F1 + 1 Sessions · 0 + No executions yet. 2 Transcript + 3 Bindings + Submitted blocks append here as immutable entries. Each entry keep ▸ 4 REPL input + rendered output, and the bindings it published. 5 Execution Hist… + + + + + + + + + + + + + + + + + + +▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-focus/frame-01.wide.txt b/scripts/tests/fixtures/repl-focus/frame-01.wide.txt new file mode 100644 index 000000000..1b82cefca --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-01.wide.txt @@ -0,0 +1,48 @@ +frame-01.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ 3 Bindings + │ No executions yet. ▸ 4 REPL input + Agent sessions appear here as executions open │ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-02.wide.txt b/scripts/tests/fixtures/repl-focus/frame-02.wide.txt new file mode 100644 index 000000000..b97a2b24f --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-02.wide.txt @@ -0,0 +1,48 @@ +frame-02.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ 3 Bindings + │ No executions yet. ▸ 4 REPL input + Agent sessions appear here as executions open │ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-03.wide.txt b/scripts/tests/fixtures/repl-focus/frame-03.wide.txt new file mode 100644 index 000000000..3c8ff86fa --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-03.wide.txt @@ -0,0 +1,48 @@ +frame-03.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ 3 Bindings + │ No executions yet. 4 REPL input + Agent sessions appear here as executions open │ ▸ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ +▌EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-04.wide.txt b/scripts/tests/fixtures/repl-focus/frame-04.wide.txt new file mode 100644 index 000000000..90019177b --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-04.wide.txt @@ -0,0 +1,48 @@ +frame-04.wide · 200 × 50 + XMD REPL │ REPL + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + │ TRANSCRIPT 2 Transcript + No sessions yet │ ▸ 3 Bindings + │ No executions yet. 4 REPL input + Agent sessions appear here as executions open │ 5 Execution History + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt new file mode 100644 index 000000000..fbe57ae71 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt @@ -0,0 +1,15 @@ +frame-05.narrow · 90 × 28 + EXECUTION HISTORY · 4 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ + HISTORY │ ┃ LI FOCUS MAP · F1 + 00:31 │ │ ┃ 00 1 Sessions · 1 + Entry 1 │ │ │ ┃ 2 Transcript + ───◆────●───────────●─────────●──────────────────●·─┃ 3 Bindings + notch height is scope depth · digits mark coalesced chec 4 REPL input · R… + 5 Execution Hist… + CHECKPOINTS ▸ 6 Pause + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document + 00:12 ● Plan entered Entry 1 › document › Plan + 00:18 ● planning inputs prepared … › Plan › PlanInputs + 00:29 ● planning Agent response admitted … › Plan › Prompt + 00:30 · draft checked … › Plan › Check diff --git a/scripts/tests/fixtures/repl-focus/frame-05.wide.txt b/scripts/tests/fixtures/repl-focus/frame-05.wide.txt new file mode 100644 index 000000000..42eebae39 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-05.wide.txt @@ -0,0 +1,51 @@ +frame-05.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan · active + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 1 ─ + │ Entry 1 ● running · 31.4s ↳ Plan scope open 2 Transcript + SESSIONS · 1 │ 3 Bindings + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input · Run disabled + │ document 5 Execution History + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running ▸ 6 Pause + ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ ENTER + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt new file mode 100644 index 000000000..b14c81861 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt @@ -0,0 +1,13 @@ +frame-07.narrow · 90 × 28 + INPUT REQUIRED + suspended at · document scope · validated ag FOCUS MAP · F1 + schema ▸ 1 Project name + 2 Description + Enter the project details. 3 Schema disclos… + 4 Submit + ▸ Project name 5 Execution Hist… + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. + + both fields valid Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-focus/frame-07.wide.txt b/scripts/tests/fixtures/repl-focus/frame-07.wide.txt new file mode 100644 index 000000000..9a1950720 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-07.wide.txt @@ -0,0 +1,51 @@ +frame-07.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ▸ 1 Project name ─ + │ Entry 1 ● running · 48.9s ↳ document scope suspended 2 Description + SESSIONS · 3 │ 3 Schema disclosure · ⌥S + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Submit + │ document 5 Execution History · still … + │ plan-a91f7c │ ▶ ENTER + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ ▸ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-08.wide.txt b/scripts/tests/fixtures/repl-focus/frame-08.wide.txt new file mode 100644 index 000000000..0593906f3 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-08.wide.txt @@ -0,0 +1,51 @@ +frame-08.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan · active + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Plan review · scroll region ─ + │ Entry 1 ● running · 31.4s ↳ Plan scope open ▸ 2 Approve + SESSIONS · 1 │ 3 Request changes + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Stop + │ document 5 Submit + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running 6 Execution History · still … + ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ · Plan scope · 59 lines returned + │ + │ # Create a project README + │ + │ Provide the project name and a one-sentence description. + │ + │ + │ Enter the project details. + │ ▸ 53 more lines · ⌥↓ scrolls the Plan + │ + │ (•) ▸ Approve + │ ( ) Request changes + │ ( ) Stop + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────────◆─────────────●────────────────────────────────●────────────────────────────●───────────────────────────────────────────────────●────·────┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-focus/frame-09.wide.txt b/scripts/tests/fixtures/repl-focus/frame-09.wide.txt new file mode 100644 index 000000000..19d031327 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-09.wide.txt @@ -0,0 +1,51 @@ +frame-09.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 README preview · scroll re… ─ + │ Entry 1 ● running · 48.9s ↳ document scope suspended ▸ 2 Approve + SESSIONS · 3 │ 3 Decline + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Execution History · still … + │ document + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ CONFIRMATION REQUIRED + │ suspended at · document scope + │ + │ Create README.md with the content shown above? + │ │ # Northstar + │ │ + │ │ A lightweight workspace for coordinating coding agents. + │ + │ [ ▸ Approve ] [ Decline ] + │ ⌘↵ approves · Esc closes the drawer without answering it + │ + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-10.wide.txt b/scripts/tests/fixtures/repl-focus/frame-10.wide.txt new file mode 100644 index 000000000..a517cab5d --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-10.wide.txt @@ -0,0 +1,51 @@ +frame-10.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 3 ─ + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript + ENTRY 1 · CREATE PROJECT README │ 3 Bindings + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input · Run disabled + │ Plan 5 Execution History + 00:02 ◆ Entry 1 submitted │ ● ACTIVE ▸ 6 Continue + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ PAUSED HEAD PAUSED [▸Continue ] [ Return to paused head ] + recorded · 00:53 │ │ │ │ │ ┃ 00:53 + Entry 1 │ │ │ │ │ │ ┃ + ────◆─────●──────────────●───────────●──────────────────────●─·──────────≈───────────●────────────●───●─────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-11.wide.txt b/scripts/tests/fixtures/repl-focus/frame-11.wide.txt new file mode 100644 index 000000000..b5ff00d89 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-11.wide.txt @@ -0,0 +1,51 @@ +frame-11.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Journal · checkpoint list ─ + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript + ENTRY 1 · CREATE PROJECT README │ 3 Bindings + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input · Run disabled + │ Plan ▸ 5 Execution History + 00:02 ◆ Entry 1 submitted │ ● ACTIVE 6 Continue + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + confirmation Elicit requested │ │ + 00:53 elapsed · Entry 1 › document │ │ + · elicit.requested confirmation │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ +▌EXECUTION HISTORY │ ┃ PAUSED HEAD PAUSED [ Continue ] [ Return to paused head ] + recorded · 00:53 │ │ │ │ │ ┃ 00:53 + Entry 1 │ │ │ │ │ │ ┃ + ────◆─────●──────────────●───────────●──────────────────────●─·──────────≈───────────●────────────●───●─────●─┃ + ▲ 00:53 · snapped · 0.0s before head diff --git a/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt new file mode 100644 index 000000000..6b969012c --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt @@ -0,0 +1,20 @@ +frame-12.narrow · 90 × 28 + EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ + HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue FOCUS MAP · F1 + 00:53 ││ ││┃ 00:53 1 Journal · chec… + Entry 1 ││ │ ││┃ 2 Transcript · r… + ─◆●─●●───2─≈●─●●┃ 3 Bindings + ▲ 00:12 · snapped · 41.0s before head 4 REPL input · R… + 5 Execution Hist… + CHECKPOINTS 6 Continue · dis… + 00:02 ◆ Entry 1 submitted REPL 7 Return to paus… + 00:05 ● document scope entered Entry ▸ 8 Fork from here + 00:12 ● Plan entered Entry + 00:18 ● planning inputs prepared … › Plan › PlanInputs + 00:29 ● planning Agent response admitted … › Plan › Prompt + 00:30 · draft checked … › Plan › Check + 00:41 ● review returned Approve … › Plan › Elicit + 00:47 ● Plan replaced by returned program Entry 1 › document + 00:49 ● project Elicit requested Entry 1 › document + 00:52 ● project Elicit answered Entry 1 › document + 00:53 ● confirmation Elicit requested Entry 1 › document diff --git a/scripts/tests/fixtures/repl-focus/frame-12.wide.txt b/scripts/tests/fixtures/repl-focus/frame-12.wide.txt new file mode 100644 index 000000000..938eb2389 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-12.wide.txt @@ -0,0 +1,51 @@ +frame-12.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Journal · checkpoint list ─ + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript · read-only + ENTRY 1 · CREATE PROJECT README │ 3 Bindings + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input · Run disabled + │ Plan 5 Execution History + 00:02 ◆ Entry 1 submitted │ ● ACTIVE 6 Continue · disabled while … + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs ▸ 8 Fork from here + 00:18 ● planning inputs prepared │ │ ✓ SETTLED + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › document › Plan │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [▸Fork from here ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-focus/frame-13.wide.txt b/scripts/tests/fixtures/repl-focus/frame-13.wide.txt new file mode 100644 index 000000000..30ff271fa --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-13.wide.txt @@ -0,0 +1,51 @@ +frame-13.wide · 200 × 50 + XMD REPL │ REPL › Entry 1 › document · suspended + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 3 ─ + │ Entry 1 ● running · 48.9s ↳ document scope suspended 2 Transcript + SESSIONS · 3 │ 3 Bindings + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input · Run disabled + │ document 5 Execution History + │ plan-a91f7c │ ▶ ENTER ▸ 6 Pause + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ + EXECUTION HISTORY │ ┃ LIVE LIVE [▸Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt new file mode 100644 index 000000000..ca11ce6e5 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt @@ -0,0 +1,28 @@ +frame-14.narrow · 90 × 28 + TRANSCRIPT · 2 / 4 REPL · Entry 1 settled Tab ▸ + Entry 1 ✓ completed · 41.2s ▸ source · 8 lines FOCUS MAP · F1 + 1 Sessions · 3 + Create a project README 2 Transcript + Provide the project name and a one-sentence description. 3 Bindings + │ MARKDOWN ▸ 4 REPL input + │ # Northstar 5 Execution Hist… + │ 6 Run + │ A lightweight workspace for coordinating coding agents. + │ README.md · 63 bytes · +3 lines + README.md was created for Northstar. + no REPL bindings published · 1 file written + + + + + + + + + + + + + +▌REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-focus/frame-14.wide.txt b/scripts/tests/fixtures/repl-focus/frame-14.wide.txt new file mode 100644 index 000000000..f840ff4e2 --- /dev/null +++ b/scripts/tests/fixtures/repl-focus/frame-14.wide.txt @@ -0,0 +1,51 @@ +frame-14.wide · 200 × 50 + XMD REPL │ REPL · Entry 1 settled + │ FOCUS MAP · F1 + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 3 ─ + │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines 2 Transcript + SESSIONS · 3 │ 3 Bindings + persist after settling │ Create a project README ▸ 4 REPL input + │ Provide the project name and a one-sentence description. 5 Execution History + │ plan-a91f7c │ │ MARKDOWN 6 Run + ✓ completed planner · turn 1 · returned 5… │ │ # Northstar + │ │ │ + review-b72e1d │ │ A lightweight workspace for coordinating coding agents. │ + ● responding reviewer · turn 1 · streaming │ │ README.md · 63 bytes · +3 lines │ + streaming · background update · selection u… │ README.md was created for Northstar. │ + │ no REPL bindings published · 1 file written │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │▌REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │ Enter XMD or invoke a document… + │ + │ + EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ │ │ │ │ │ │ ┃ 01:01 + Entry 1 │ │ │ │ │ │ │ │ │ ┃ + ─────◆──────●───────────────●─────────────●────────────────────────●─·───────────≈─────────────●─────────────●───●──────●──●────────●──────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts new file mode 100644 index 000000000..b99a74ff0 --- /dev/null +++ b/scripts/tests/repl-focus.test.ts @@ -0,0 +1,767 @@ +/** + * The route and the focus model, checked against the approved focus study. + * + * The study states fourteen frames as numbered target lists with a focused + * number and a `meta` record naming what Tab and Shift+Tab do from there. That + * is the acceptance source, so most of this suite is the same question asked of + * every frame: rebuild the state from its URL, and ask the registry, the map + * and the ring whether they agree with the study. + * + * Two claims are driven as **bytes** rather than as synthetic events, because + * synthetic events are what hid the defects this slice repairs. A lone `ESC` + * never reached the harness at a real keyboard, and a real Shift+Tab arrives as + * `Backtab` with no shift flag — both were handled, tested, and unreachable. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createInput } from "@bomb.sh/tty"; +import type { Input, InputEvent } from "@bomb.sh/tty"; +import { readTextFile } from "@effectionx/fs"; +import { exec } from "@effectionx/process"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { captureFocus, captureText, PROFILE_SIZES, renderFrame } from "../repl-study/capture.ts"; +import { FRAMES, frame, stateFor } from "../repl-study/frames.ts"; +import type { StudyFrame } from "../repl-study/frames.ts"; +import { + counterpartOf, + focusMap, + mapOrder, + numbering, + ownerOf, + registry, + resolve, + step, +} from "../repl-study/focus.ts"; +import { scanKeys } from "../repl-study/host.ts"; +import { fold, JOURNAL, journalThrough, markers } from "../repl-study/journal.ts"; +import { formatRoute, navigationFor, parseRoute, ROUTE_SURFACES } from "../repl-study/route.ts"; +import type { Route } from "../repl-study/route.ts"; +import { + fixtureFor, + focusIn, + hydrate, + layoutOf, + mapOf, + openDrawer, + projection, + reduce, + targets, + viewOf, +} from "../repl-study/store.ts"; +import type { HarnessEvent, ReplState, Size } from "../repl-study/store.ts"; +import type { Mutation } from "../repl-study/mutations.ts"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const GOLDENS = fileURLToPath(new URL("./fixtures/repl-focus/", import.meta.url)); +const MAIN = "scripts/repl-study/main.ts"; + +const WIDE: Size = PROFILE_SIZES.wide; +const NARROW: Size = PROFILE_SIZES.narrow; + +function context(size: Size, mutation?: Mutation) { + return { size, mutation, scrollLimit: 40 }; +} + +/** One keystroke, as the decoder would report it. */ +function key(code: string, extra: Record = {}): HarnessEvent { + return { kind: "key", event: { type: "keydown", key: code, code, ...extra } }; +} + +function press(state: ReplState, code: string, size: Size = WIDE, mutation?: Mutation): ReplState { + return reduce(state, key(code), context(size, mutation)); +} + +/** The identities in the ring, in the order Tab walks them. */ +function ring(state: ReplState, size: Size = WIDE, mutation?: Mutation): string[] { + return targets(state, size, mutation).map((target) => target.id); +} + +function bytes(...codes: number[]): Uint8Array { + return Uint8Array.from(codes); +} + +/** + * Feed raw bytes to the harness's own decoding path. + * + * Nothing synthesises an event here: the escape sequence goes in and whatever + * the decoder produces comes out, including whatever the pending flush + * eventually releases. + */ +function* decoded(input: Input, chunk: Uint8Array, mutation?: Mutation): Operation { + const events: InputEvent[] = []; + yield* scanKeys(input, chunk, (event) => events.push(event), mutation); + return events; +} + +const ESC = 0x1b; + +describe("the URL that says where you are", () => { + it("round-trips every frame's location", function* () { + for (const subject of FRAMES) { + const parsed = parseRoute(subject.url); + expect({ id: subject.id, ok: parsed.ok }).toEqual({ id: subject.id, ok: true }); + if (!parsed.ok) { + continue; + } + expect(formatRoute(parsed.value)).toBe(subject.url); + } + }); + + it("parses every part of the schema, and refuses what is not in it", function* () { + const parsed = parseRoute( + "xmd://repl/e1/transcript/entry-1/plan/+project?at=cp-07&draft=%3CPlan%3E", + ); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.value).toEqual({ + execution: "e1", + surface: "transcript", + scopes: ["entry-1", "plan"], + drawers: ["project"], + at: "cp-07", + draft: "", + }); + + const refusals = [ + "https://repl/e1/transcript", + "xmd://repl/e1/nowhere", + "xmd://repl//transcript", + "xmd://repl/e1/transcript/+project/plan", + "xmd://repl/e1/transcript?zoom=2", + "xmd://repl/e1/transcript?at=", + ]; + for (const url of refusals) { + const result = parseRoute(url); + expect({ url, ok: result.ok }).toEqual({ url, ok: false }); + } + }); + + it("spells the live head exactly one way", function* () { + // There is no `at=head` sentinel, so two URLs cannot render the same state + // and hydrate differently. + const following = hydrate("xmd://repl/e1/history", journalThrough("cp-16")); + expect(following.route.at).toBeUndefined(); + expect(following.moment.transport).toBe("paused"); + const inspecting = hydrate("xmd://repl/e1/history?at=cp-04", journalThrough("cp-16")); + expect(inspecting.moment.transport).toBe("inspecting"); + }); + + it("keeps a drawer from being mistaken for a scope of the same name", function* () { + const parsed = parseRoute("xmd://repl/e1/transcript/project/+project"); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.value.scopes).toEqual(["project"]); + expect(parsed.value.drawers).toEqual(["project"]); + }); + + it("names a surface for every region focus can be in", function* () { + expect([...ROUTE_SURFACES]).toEqual(["sessions", "transcript", "bindings", "input", "history"]); + }); +}); + +describe("every frame of the approved focus study", () => { + it("rebuilds each frame's targets, numbering and focus from its URL", function* () { + for (const subject of FRAMES) { + const state = stateFor(subject); + const layout = layoutOf(state, WIDE); + const map = focusMap(state, layout); + const numbers = numbering(map); + // The study's overlay draws only the focused target when the map is off, + // and numbers every visible one when it is on. + const ordered = mapOrder(map); + const shown = subject.overlay + ? ordered + : ordered.filter((target) => target.id === subject.focus); + expect({ + frame: subject.id, + targets: shown.map((target) => ({ + n: numbers.get(target.id), + id: target.id, + kind: target.kind, + })), + }).toEqual({ + frame: subject.id, + targets: subject.targets.map((target) => ({ + n: target.n, + id: target.id, + kind: target.kind, + })), + }); + expect({ frame: subject.id, fixture: state.moment.shows }).toEqual({ + frame: subject.id, + fixture: subject.fixture, + }); + expect({ frame: subject.id, focus: focusIn(state, WIDE) }).toEqual({ + frame: subject.id, + focus: subject.focus, + }); + } + }); + + it("moves where the study says Tab and Shift+Tab move", function* () { + for (const subject of FRAMES) { + const live = targets(stateFor(subject), WIDE); + expect({ frame: subject.id, tab: step(subject.focus, live, 1) }).toEqual({ + frame: subject.id, + tab: subject.tab, + }); + expect({ frame: subject.id, shift: step(subject.focus, live, -1) }).toEqual({ + frame: subject.id, + shift: subject.shift, + }); + } + }); + + it("walks the whole ring in both directions and comes back to the start", function* () { + for (const subject of FRAMES) { + const live = targets(stateFor(subject), WIDE); + let forward = subject.focus; + const visited: string[] = []; + for (let at = 0; at < live.length; at += 1) { + forward = step(forward, live, 1); + visited.push(forward); + } + expect({ frame: subject.id, at: forward }).toEqual({ frame: subject.id, at: subject.focus }); + expect({ frame: subject.id, seen: new Set(visited).size }).toEqual({ + frame: subject.id, + seen: live.length, + }); + let back = subject.focus; + for (let at = 0; at < live.length; at += 1) { + back = step(back, live, -1); + } + expect({ frame: subject.id, at: back }).toEqual({ frame: subject.id, at: subject.focus }); + } + }); + + it("keeps the drawer's trap closed, with the footer inside it", function* () { + for (const subject of FRAMES.filter((one) => one.meta.trap)) { + const state = stateFor(subject); + const ids = ring(state); + expect({ frame: subject.id, last: ids[ids.length - 1] }).toEqual({ + frame: subject.id, + last: "region:history", + }); + expect({ frame: subject.id, panes: ids.filter((id) => id.startsWith("region:")) }).toEqual({ + frame: subject.id, + panes: ["region:history"], + }); + } + }); + + it("lets Tab escape the trap when the ring is rebuilt from the panes", function* () { + const state = stateFor(frame("07")!); + const leaked = ring(state, WIDE, "leak-drawer-trap"); + expect(leaked).toContain("region:transcript"); + expect(leaked).not.toContain("field:drawer.project.name"); + }); + + it("numbers a disabled control in the map and skips it in the ring", function* () { + const state = stateFor(frame("12")!); + const map = mapOf(state, WIDE).map((target) => target.id); + expect(map).toContain("control:transport.continue"); + expect(ring(state)).not.toContain("control:transport.continue"); + }); + + it("admits a disabled control into the ring when the two lists are conflated", function* () { + const state = stateFor(frame("12")!); + expect(ring(state, WIDE, "focus-hidden-target")).toContain("control:transport.continue"); + }); +}); + +describe("restoring focus when a target disappears", () => { + it("walks to the nearest surviving owner", function* () { + // Study frame 12: the reconstruction removed the drawer of frame 07, so its + // trapped controls left the sequence. + const suspended = stateFor(frame("07")!); + const reconstructed = hydrate( + "xmd://repl/e1/history/entry-1/document/plan?at=cp-04", + suspended.journal, + ); + expect(resolve("field:drawer.project.name", targets(reconstructed, WIDE))).toBe( + "region:transcript", + ); + }); + + it("prefers a transport control's live counterpart over its owner", function* () { + expect(counterpartOf("control:transport.continue")).toBe("control:transport.pause"); + const live = targets(stateFor(frame("13")!), WIDE); + expect(resolve("control:transport.continue", live)).toBe("control:transport.pause"); + }); + + it("reads ownership from the identity, so a target that is gone still has one", function* () { + expect(ownerOf("control:transport.fork")).toBe("region:history"); + expect(ownerOf("control:input.run")).toBe("region:input"); + expect(ownerOf("field:drawer.project.name")).toBe("region:transcript"); + expect(ownerOf("region:history")).toBeUndefined(); + }); + + it("falls back to the first target when the chain is exhausted", function* () { + const live = targets(stateFor(frame("02")!), WIDE); + expect(resolve("control:nothing.at.all", live)).toBe("region:sessions"); + }); +}); + +describe("drawers, their trap and what they restore", () => { + const opened = (): ReplState => { + const base = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")); + return openDrawer(base, "project", "region:transcript", WIDE); + }; + + it("puts focus on the drawer's first meaningful control", function* () { + expect(opened().focus).toBe("field:drawer.project.name"); + }); + + it("keeps only the top of a nested stack interactive", function* () { + const nested = openDrawer(opened(), "confirm", "field:drawer.project.name", WIDE); + expect(nested.route.drawers).toEqual(["project", "confirm"]); + const ids = ring(nested); + expect(ids).toEqual([ + "control:drawer.confirm.preview", + "control:drawer.confirm.approve", + "control:drawer.confirm.decline", + "region:history", + ]); + }); + + it("restores the identity that invoked it when Escape closes it", function* () { + const nested = openDrawer(opened(), "confirm", "field:drawer.project.name", WIDE); + const closed = press(nested, "Escape"); + expect(closed.route.drawers).toEqual(["project"]); + expect(closed.focus).toBe("field:drawer.project.name"); + const outer = press(closed, "Escape"); + expect(outer.route.drawers).toEqual([]); + expect(outer.focus).toBe("region:transcript"); + }); + + it("closes the drawer without answering it", function* () { + // The study's frame 09 gives the confirmation drawer `esc declines`. + // Navigation is what this experiment owns, so Escape closes and answers + // nothing: the suspension is still waiting afterwards. + const state = stateFor(frame("09")!); + const closed = press(state, "Escape"); + expect(closed.route.drawers).toEqual([]); + expect(closed.moment.suspension).toBe("confirm"); + }); + + it("leaves focus where it was when the invoker is forgotten", function* () { + const closed = press(opened(), "Escape", WIDE, "forget-drawer-invoker"); + expect(closed.focus).toBe("field:drawer.project.name"); + expect(closed.route.drawers).toEqual([]); + }); +}); + +describe("inspecting a recorded moment", () => { + const paused = (): ReplState => + hydrate("xmd://repl/e1/history/entry-1/document", journalThrough("cp-16")); + + const inspecting = (): ReplState => + hydrate("xmd://repl/e1/history/entry-1/document/plan?at=cp-04", journalThrough("cp-16")); + + it("refuses a mutation while a reconstruction is open", function* () { + const state = { ...inspecting(), focus: "region:input" }; + const typed = press(state, "x"); + expect(typed.route.draft).toBe(""); + expect(typed).toBe(state); + }); + + it("permits that mutation when the read-only rule is removed", function* () { + const state = { ...inspecting(), focus: "region:input" }; + expect(press(state, "x", WIDE, "mutate-while-inspecting").route.draft).toBe("x"); + }); + + it("keeps every recorded marker visible, including the ones after it", function* () { + const state = inspecting(); + const checkpoints = fixtureFor(state).history.checkpoints; + const later = checkpoints.filter((point) => point.at > state.moment.at); + expect(later.length).toBeGreaterThan(0); + }); + + it("withholds Continue until the paused head is regained", function* () { + expect(ring(inspecting())).not.toContain("control:transport.continue"); + const returned = press({ ...inspecting(), focus: "control:transport.return-head" }, "Enter"); + expect(returned.route.at).toBeUndefined(); + expect(ring({ ...returned, focus: "region:history" })).toContain("control:transport.continue"); + }); + + it("holds the transport slot across freezing and resuming", function* () { + // Study frame 13: leaving history with Continue focused lands on Pause. + const held = { ...paused(), focus: "control:transport.continue" }; + expect(focusIn(held, WIDE)).toBe("control:transport.continue"); + const resumed = press(held, "Enter"); + expect(resumed.moment.transport).toBe("live"); + expect(focusIn(resumed, WIDE)).toBe("control:transport.pause"); + }); +}); + +describe("push versus replace", () => { + const start = (): ReplState => + hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")); + + it("replaces the URL while a draft is typed", function* () { + let state: ReplState = { ...start(), focus: "region:input" }; + const before = state.history.length; + for (const glyph of ["a", "b", "c"]) { + state = press(state, glyph); + } + expect(state.route.draft).toBe("abc"); + expect(state.history.length).toBe(before); + expect(navigationFor("draft")).toBe("replace"); + }); + + it("pushes one entry for a drawer and one for entering inspection", function* () { + const drawer = openDrawer(start(), "project", "region:transcript", WIDE); + expect(drawer.history.length).toBe(1); + const scrubbed = press({ ...drawer, focus: "region:history" }, "ArrowLeft"); + const inspected = press(scrubbed, "Enter"); + expect(inspected.route.at).toBeDefined(); + expect(inspected.history.length).toBe(2); + expect(navigationFor("drawer")).toBe("push"); + expect(navigationFor("inspection")).toBe("push"); + }); + + it("returns Back to the head rather than through every scrubbed marker", function* () { + let state = press({ ...start(), focus: "region:history" }, "ArrowLeft"); + state = press(state, "Enter"); + const entered = state.history.length; + for (let at = 0; at < 6; at += 1) { + state = press(state, "ArrowLeft"); + } + expect(state.history.length).toBe(entered); + expect(navigationFor("scrub")).toBe("replace"); + const back = press(state, "Escape"); + expect(back.route.at).toBeUndefined(); + }); + + it("fills the navigation stack when every keystroke pushes", function* () { + let state: ReplState = { ...start(), focus: "region:input" }; + for (const glyph of ["a", "b", "c"]) { + state = press(state, glyph, WIDE, "push-draft-edits"); + } + expect(state.history.length).toBe(3); + }); +}); + +describe("background updates", () => { + const streaming = (): HarnessEvent => ({ + kind: "background", + record: { + marker: "cp-live", + at: 50, + kind: "session.started", + scope: "document", + detail: "review-b72e1d", + shows: "drawer", + }, + }); + + it("changes nothing about where the person is", function* () { + // Study frame 06. Reference equality, not deep equality: a reducer that + // rebuilt an equal route would pass a deep comparison having already lost + // the property this is about. + const before = stateFor(frame("06")!); + const after = reduce(before, streaming(), context(WIDE)); + expect(after.route).toBe(before.route); + expect(after.focus).toBe(before.focus); + expect(after.selection).toBe(before.selection); + expect(after.anchor).toBe(before.anchor); + expect(after.journal.length).toBe(before.journal.length + 1); + }); + + it("is rejected when the update moves focus", function* () { + const before = stateFor(frame("06")!); + const after = reduce(before, streaming(), context(WIDE, "steal-focus-on-background")); + expect(after.focus).not.toBe(before.focus); + }); +}); + +describe("rebuilding from the URL and the journal alone", () => { + /** A long interaction: typing, traversal, a drawer, inspection and back. */ + function journey(): ReplState { + let state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")); + state = press(state, "4"); + for (const glyph of ["<", "P", "l", "a", "n", ">"]) { + state = press(state, glyph); + } + state = press(state, "Tab"); + state = press(state, "Backtab"); + state = openDrawer(state, "project", "region:transcript", WIDE); + state = press(state, "Tab"); + state = press(state, "Escape"); + state = press(state, "5"); + state = press(state, "ArrowLeft"); + state = press(state, "ArrowLeft"); + state = press(state, "Enter"); + state = press(state, "ArrowLeft"); + return state; + } + + it("comes back to the same semantic state with nothing else", function* () { + const original = journey(); + expect(original.route.at).toBeDefined(); + expect(original.route.draft).toBe(""); + expect(original.history.length).toBeGreaterThan(0); + + const rebuilt = hydrate(formatRoute(original.route), original.journal); + expect(projection(rebuilt)).toEqual(projection(original)); + }); + + it("lands the rebuilt state on a legitimate target", function* () { + const rebuilt = hydrate(formatRoute(journey().route), journey().journal); + const live = targets(rebuilt, WIDE); + expect(live.map((target) => target.id)).toContain(focusIn(rebuilt, WIDE)); + }); + + it("throws away the disposable half rather than pretending to restore it", function* () { + const rebuilt = hydrate(formatRoute(journey().route), journey().journal); + expect(rebuilt.anchor).toBe(0); + expect(rebuilt.selection).toBe(-1); + expect(rebuilt.history).toEqual([]); + }); + + it("folds the journal rather than reading the fixtures", function* () { + // The journal is authored by hand from the study. A journal derived from + // `fixtures.ts` would make this comparison the fixtures against themselves. + const moment = fold(journalThrough("cp-08")); + expect(moment.scope).toBe("Plan scope"); + expect(moment.published).toEqual(["inputs", "draft"]); + expect(moment.suspension).toBe("review"); + expect(moment.sessions).toBe(2); + expect(markers(JOURNAL).length).toBe(JOURNAL.length); + }); +}); + +describe("the same route at two profiles", () => { + it("says the same thing wide and narrow", function* () { + for (const subject of FRAMES) { + const state = stateFor(subject); + const before = projection(state); + // The route is not where the profile is recorded, so composing it two + // ways cannot lose it — which is a claim about a state that has actually + // been through both compositions, not about one that was asked twice. + expect(layoutOf(state, WIDE).profile).toBe("wide"); + expect(layoutOf(state, NARROW).profile).toBe("narrow"); + let moved = reduce(state, { kind: "resize", ...NARROW }, context(NARROW)); + moved = reduce(moved, { kind: "resize", ...WIDE }, context(WIDE)); + expect({ frame: subject.id, after: projection(moved) }).toEqual({ + frame: subject.id, + after: before, + }); + expect({ frame: subject.id, focus: focusIn(state, NARROW) }).toEqual({ + frame: subject.id, + focus: focusIn(state, WIDE), + }); + } + }); + + it("composes every frame at both profiles", function* () { + for (const subject of FRAMES) { + const state = stateFor(subject); + for (const size of [WIDE, NARROW]) { + const rendered = yield* renderFrame({ + fixture: fixtureFor(state), + view: viewOf(state), + size, + focus: { here: focusIn(state, size), map: mapOf(state, size), overlay: true }, + }); + expect({ frame: subject.id, drew: rendered.text.trim().length > 0 }).toEqual({ + frame: subject.id, + drew: true, + }); + } + } + }); + + it("keeps the route across a resize", function* () { + const state = stateFor(frame("07")!); + const resized = reduce(state, { kind: "resize", cols: 90, rows: 28 }, context(NARROW)); + expect(resized.route).toBe(state.route); + expect(formatRoute(resized.route)).toBe(frame("07")!.url); + }); + + it("loses the route when a resize rebuilds it from the profile", function* () { + const state = stateFor(frame("07")!); + const resized = reduce( + state, + { kind: "resize", cols: 90, rows: 28 }, + context(NARROW, "drop-route-on-resize"), + ); + expect(formatRoute(resized.route)).not.toBe(frame("07")!.url); + }); + + it("has nothing to focus on a terminal too small to compose one", function* () { + const state = stateFor(frame("07")!); + expect(focusMap(state, layoutOf(state, PROFILE_SIZES["too-small"]))).toEqual([]); + }); +}); + +describe("through a real decoder", () => { + it("delivers a lone Escape only after the pending flush", function* () { + const input: Input = yield* until(createInput({})); + const immediate = input.scan(bytes(ESC)); + // The defect, stated as the library states it: the event list is empty and + // the caller is asked to come back. + expect(immediate.events).toEqual([]); + expect(immediate.pending?.delay).toBeGreaterThan(0); + + const flushed = yield* decoded(yield* until(createInput({})), bytes(ESC)); + expect(flushed.map((event) => event.type)).toEqual(["keydown"]); + expect(flushed.map((event) => ("code" in event ? event.code : ""))).toEqual(["Escape"]); + }); + + it("acts on the Escape those bytes produced", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC)); + let state = openDrawer( + hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")), + "project", + "region:transcript", + WIDE, + ); + for (const event of events) { + state = reduce(state, { kind: "key", event }, context(WIDE)); + } + expect(state.route.drawers).toEqual([]); + }); + + it("swallows every Escape when the pending flush is dropped", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC), "swallow-pending-escape"); + expect(events).toEqual([]); + }); + + it("reads a real Shift+Tab, which arrives as Backtab with no shift flag", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC, 0x5b, 0x5a)); + expect(events.length).toBe(1); + const [event] = events; + expect("code" in event ? event.code : "").toBe("Backtab"); + expect("shift" in event ? event.shift : undefined).toBeUndefined(); + + const state = stateFor(frame("03")!); + let moved = state; + for (const decodedEvent of events) { + moved = reduce(moved, { kind: "key", event: decodedEvent }, context(WIDE)); + } + expect(moved.focus).toBe(frame("03")!.shift); + }); + + it("traverses forward when only a synthetic Tab+shift counts as reverse", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC, 0x5b, 0x5a)); + let moved = stateFor(frame("03")!); + for (const event of events) { + moved = reduce(moved, { kind: "key", event }, context(WIDE, "ignore-backtab")); + } + expect(moved.focus).toBe(frame("03")!.tab); + }); + + it("decodes the modified arrows structural navigation is specified on", function* () { + const input: Input = yield* until(createInput({})); + const events = yield* decoded(input, bytes(ESC, 0x5b, 0x31, 0x3b, 0x35, 0x41)); + expect(events.length).toBe(1); + const [event] = events; + expect("code" in event ? event.code : "").toBe("ArrowUp"); + expect("ctrl" in event ? event.ctrl : undefined).toBe(true); + }); +}); + +describe("Ctrl+C, three ways", () => { + const running = (): ReplState => + hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-06")); + + it("interrupts the entry that is running, and stays open", function* () { + const state = running(); + const interrupted = press(state, "c", WIDE, undefined); + expect(interrupted).toBe(state); + const control = reduce(state, key("c", { ctrl: true }), context(WIDE)); + expect(control.quit).toBe(false); + expect(control.interrupts).toBe(1); + }); + + it("clears a draft when nothing is running", function* () { + const settled = hydrate("xmd://repl/e1/input?draft=%3CPlan%3E", journalThrough("cp-19")); + const cleared = reduce(settled, key("c", { ctrl: true }), context(WIDE)); + expect(cleared.route.draft).toBe(""); + expect(cleared.quit).toBe(false); + }); + + it("leaves when the draft is empty and nothing is running", function* () { + const settled = hydrate("xmd://repl/e1/input", journalThrough("cp-19")); + expect(reduce(settled, key("c", { ctrl: true }), context(WIDE)).quit).toBe(true); + }); +}); + +describe("the frames, as pictures", () => { + it("renders every committed focus capture exactly", function* () { + const captures = yield* captureFocus(); + expect(captures.length).toBeGreaterThan(0); + for (const capture of captures) { + const golden = yield* readTextFile(join(GOLDENS, `${capture.name}.txt`)); + expect(captureText(capture)).toBe(golden); + } + }); + + it("draws the focused region and the numbered map", function* () { + const subject = frame("12")!; + const state = stateFor(subject); + const rendered = yield* renderFrame({ + fixture: fixtureFor(state), + view: viewOf(state), + size: WIDE, + focus: { here: focusIn(state, WIDE), map: mapOf(state, WIDE), overlay: true }, + }); + expect(rendered.text).toContain("FOCUS MAP"); + expect(rendered.text).toContain("Fork from here"); + expect(rendered.text).toContain("Continue · disabled while"); + expect(rendered.text).toContain("▸ 8"); + }); + + it("says nothing about focus in a frame that was not asked about it", function* () { + const state = stateFor(frame("07")!); + const rendered = yield* renderFrame({ + fixture: fixtureFor(state), + view: viewOf(state), + size: WIDE, + }); + expect(rendered.text).not.toContain("FOCUS MAP"); + }); +}); + +describe("the documented command", () => { + it("opens at a route, a frame and with the map on", function* () { + for (const argument of [ + "--route xmd://repl/e1/transcript/entry-1/plan/+project", + "--frame 07", + "--frame 07 --focus-map", + ]) { + const result = yield* exec(`deno run --allow-all ${MAIN} ${argument}`, { cwd: ROOT }).join(); + // There is no terminal here, so the harness refuses interactive mode — + // which is the proof that the invocation was understood rather than + // rejected at the command line. + expect({ argument, code: result.code }).toEqual({ argument, code: 2 }); + expect(`${result.stdout}${result.stderr}`).toContain("--capture"); + } + }); + + it("refuses a route it cannot parse, and a frame that does not exist", function* () { + const bad = yield* exec(`deno run --allow-all ${MAIN} --route xmd://repl/e1/nowhere`, { + cwd: ROOT, + }).join(); + expect(bad.code).toBe(2); + expect(bad.stdout).toContain("is not a surface"); + + const missing = yield* exec(`deno run --allow-all ${MAIN} --frame 99`, { cwd: ROOT }).join(); + expect(missing.code).toBe(2); + expect(missing.stdout).toContain("--frame needs one of"); + }); +}); diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index 64ad5abf9..1ed3355c1 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -72,7 +72,7 @@ import { gridText, viewport, } from "../repl-study/screen.ts"; -import { initialView, scrollBy } from "../repl-study/view.ts"; +import { initialView, scrollBy } from "../repl-study/store.ts"; const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const GOLDENS = fileURLToPath(new URL("./fixtures/repl-study/", import.meta.url)); @@ -779,13 +779,18 @@ describe("the boundary this experiment keeps", () => { }); it("uses every control it declares", function* () { - // A control nobody passes is a claim nobody is checking, so the suite's own - // source has to mention each one. - const source = yield* readTextFile( - fileURLToPath(new URL("./repl-study.test.ts", import.meta.url)), - ); + // A control nobody passes is a claim nobody is checking, so the evidence's + // own source has to mention each one. #838's controls are exercised here + // and #839's next door; the declaration is one list, so the check reads + // both suites rather than letting either half go unclaimed. + const suites = ["./repl-study.test.ts", "./repl-focus.test.ts"]; + const sources: string[] = []; + for (const suite of suites) { + sources.push(yield* readTextFile(fileURLToPath(new URL(suite, import.meta.url)))); + } for (const mutation of MUTATIONS) { - expect({ mutation, used: source.includes(mutation) }).toEqual({ mutation, used: true }); + const used = sources.some((source) => source.includes(mutation)); + expect({ mutation, used }).toEqual({ mutation, used: true }); } }); From 2f4098b906b6169989ad978f5892c9aebd0514f0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 23 Sep 2026 07:39:30 -0400 Subject: [PATCH 06/57] =?UTF-8?q?=F0=9F=A9=B9=20Make=20the=20URL=20carry?= =?UTF-8?q?=20focus,=20the=20selection=20and=20the=20sibling=20locus=20(#8?= =?UTF-8?q?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture review of 0b8687b0 found three places where the URL was not actually the location it claims to be. **Focus moved without the route.** Tab changed only `focus`, so the surface segment went on naming the region somebody had already left: a cold start came back to the input after tabbing to the footer, and at narrow widths the route could render one full-screen surface while focus named another. Focus moves now carry the route in the same reducer transition, and the fourteen frames are driven *through* the reducer forward and in reverse rather than proved by constructing each destination independently — which is the check that missed it. **The selected marker was treated as disposable.** It rendered in the band and vanished on hydration, and `projection()` agreed because it never looked. `at` is now the selected marker and a valueless `inspect` says the reconstruction is open; the two were one field and could not be told apart. The projection carries the selected marker, its scope and its bindings. **A paused entry is still an entry.** Ctrl+C exited instead of interrupting one, which hands its lifecycle to whoever closed the terminal. Every running entry is interrupted now, paused or reconstructed. `Ctrl+←`/`Ctrl+→` are implemented against a sibling list derived from the journal, which gains `preview` and `write` beside `plan` under the document scope. Structural navigation now refuses to act inside an editable target — its own guard was missing, and the new case caught it. Four controls: keep-route-on-focus, drop-selection-on-hydrate, exit-on-paused-interrupt, inert-sibling-arrows. --- scripts/repl-study/README.md | 24 +-- scripts/repl-study/RESULT-focus.md | 69 +++++++-- scripts/repl-study/focus.ts | 4 +- scripts/repl-study/frames.ts | 32 ++-- scripts/repl-study/host.ts | 1 + scripts/repl-study/journal.ts | 117 ++++++++++---- scripts/repl-study/mutations.ts | 8 + scripts/repl-study/route.ts | 66 +++++++- scripts/repl-study/store.ts | 177 +++++++++++++++------ scripts/tests/repl-focus.test.ts | 241 ++++++++++++++++++++++++++--- 10 files changed, 593 insertions(+), 146 deletions(-) diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index 5fc584591..ec028d241 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -36,17 +36,18 @@ plus how far the execution had recorded when it was taken. `--route` takes any location at all: ```text -xmd://repl//[/]*[/+]*[?at=][&draft=] +xmd://repl//[/]*[/+]*[?at=][&inspect][&draft=] ``` -The surface is one of `sessions`, `transcript`, `bindings`, `input`, `history`. -A `+` marks a drawer, so a drawer is never mistaken for a scope of the same -name, and the last drawer in the path is the top — the only one that is visible -and interactive. `at` names the recorded marker under inspection, and leaving it -out is the one spelling of "following the live head". Everything else — where -the transcript is scrolled to, which marker the scrubber is on, whether the -overlay is drawn, which target is focused right now — is disposable and -deliberately not in the URL. +The surface is one of `sessions`, `transcript`, `bindings`, `input`, `history`, +and it says which region owns focus — so moving focus across a region boundary +moves the URL with it. A `+` marks a drawer, so a drawer is never mistaken for a +scope of the same name, and the last drawer in the path is the top, the only one +that is visible and interactive. `at` names the recorded marker the scrubber has +selected; `inspect` says the reconstruction at it is open, takes no value, and +is refused without a marker. Everything else — where the transcript is scrolled +to, whether the overlay is drawn, which target inside a region is focused right +now — is disposable and deliberately not in the URL. **`--play` is the demonstration.** It begins at the empty REPL and goes all the way to the settled entry — empty → nested → generated → drawer → paused → @@ -66,10 +67,11 @@ Keys, while it is running: | `↑` `↓` `PgUp` `PgDn` | move the transcript window | | `←` `→` | move the selected marker, one at a time | | `Ctrl+↑` / `Ctrl+↓` | move the locus out to the parent scope, or in to the first child | +| `Ctrl+←` / `Ctrl+→` | move to the previous or next sibling scope, wrapping | | `d` | open or close the suspension that is waiting | | `p` | play the transition out of this moment into the next | | `q` | leave, restoring the terminal | -| `Ctrl+C` | interrupt a running entry; else clear the draft; else leave | +| `Ctrl+C` | interrupt the entry if one is running, paused or reconstructed; else clear the draft; else leave | **The ring is five regions with each region's own controls inlined after it** — Sessions, Transcript, Bindings, REPL input, Execution History — and it wraps. @@ -171,7 +173,7 @@ makes a capture legible as evidence against the frame it reproduces. | `screen.ts` | a terminal's cells, reconstructed from the bytes, so a frame can be read back | | `host.ts` | the only module that touches the terminal: modes, raw input, signals, restoration | | `capture.ts` | one frame, away from a terminal, in bytes and in cells | -| `mutations.ts` | the nineteen ways the evidence breaks this on purpose | +| `mutations.ts` | the twenty-three ways the evidence breaks this on purpose | | `main.ts` | the documented command | `--replay` runs the same lifecycle with no terminal attached, writing its byte diff --git a/scripts/repl-study/RESULT-focus.md b/scripts/repl-study/RESULT-focus.md index 9f14b12f3..285a42945 100644 --- a/scripts/repl-study/RESULT-focus.md +++ b/scripts/repl-study/RESULT-focus.md @@ -42,7 +42,7 @@ scan has not finished reading the scan. ## What location turned out to be ```text -xmd://repl//[/]*[/+]*[?at=][&draft=] +xmd://repl//[/]*[/+]*[?at=][&inspect][&draft=] ``` The REPL has exactly three kinds of state, and telling them apart is what made @@ -53,10 +53,15 @@ every acceptance criterion reachable: none of that is location, and none of it is in the URL. This experiment folds a hand-authored journal fixture; #842 owns the real one. 2. **Location** is the URL, and nothing else is location. -3. **Everything else is disposable** — the scroll anchor, the scrubber's - position, whether the overlay is drawn, which target is focused right now. +3. **Everything else is disposable** — the scroll anchor, whether the overlay is + drawn, which target is focused within a region. -Three decisions inside the schema earned their keep: +The scrubber's selected marker is **not** in that third group, and putting it +there was this experiment's first real mistake. A selection that lived only in +memory rendered a state its own URL could not reopen: the band showed a marker, +and a cold start came back with none. It is location, so it is in the URL. + +Four decisions inside the schema earned their keep: - **A drawer segment wears `+`.** Drawers are nested routes, and a path is how nesting is said; the prefix is what stops `.../project/+project` being @@ -66,12 +71,27 @@ Three decisions inside the schema earned their keep: execution it names would be able to describe pausing a finished run. - **`at` absent is the live head.** There is no `at=head` sentinel, so two URLs cannot render the same screen and hydrate into different states. +- **Selecting a marker and reconstructing it are two parts, not one.** `at` is + the marker the scrubber has selected; `inspect` says the reconstruction at it + is open. They are genuinely different states — study frame 11 has a marker + selected with the run merely paused, and frame 12 has the reconstruction open + at another — and one field could not tell them apart. `inspect` is valueless + and refused without `at`, so each state has exactly one spelling. **Scrubbing replaces and entering inspection pushes**, and that has a visible consequence the study does not state: Back from an inspected marker returns to the head, not back through every marker the scrubber passed. Draft editing replaces for the same reason — both are continuous adjustments rather than -places somebody went. +places somebody went. Closing a reconstruction is not the same act as +deselecting a marker, so returning to the head leaves `at` where it was. + +**Moving focus across a region boundary is moving the route.** The surface +segment says which region owns focus, so the two cannot be updated in different +transitions — a reducer that changed only focus left the URL describing the +region somebody had already tabbed away from, and at narrow widths would have +gone on rendering one surface full-screen while focus named another. A control +belongs to the surface of the region that owns it, which is why focusing `Pause` +reads as `history` and focusing `Run` reads as `input`. ## What focus turned out to be @@ -158,6 +178,27 @@ because `layout.ts` already renders it inside the transcript. It is still somewhere focus can be, so it is a route surface and not a layout surface, and the one line of reconciliation lives in `store.ts`. No #838 golden moved. +## Structural navigation, and where the sibling list comes from + +`Ctrl+↑` moves the locus out to the parent scope, `Ctrl+↓` in to the first +child, and `Ctrl+←`/`Ctrl+→` along the siblings, wrapping at both ends. All four +push, because each is a place somebody went, and all four act only outside an +editable target so a modified arrow is never stolen out of a draft. + +**The sibling list is derived from the journal, never declared.** Siblings are a +fact about what the execution actually opened, which is why a scope that has not +been entered yet is not one. The fixture journal opens `plan`, `preview` and +`write` inside `document`, in that source order, so the arrows have something +real to walk. + +## Ctrl+C, and what counts as active + +An entry that is paused, or that is being read through a reconstruction, is +still running. Ctrl+C interrupts it and the REPL stays open; only an idle REPL +clears its draft or leaves. Treating a live transport as the test for "active" +exited from a paused entry instead of interrupting it, which hands that entry's +lifecycle to whoever closed the terminal. + ## Scoped limits - **Three regions expose no controls.** `sessions`, `transcript` and `bindings` @@ -172,9 +213,6 @@ the one line of reconciliation lives in `store.ts`. No #838 golden moved. not synthesise content the fixture set does not have: there is no "paused at the live head" transcript distinct from the reconstruction's, and a frame that wants three sessions borrows the fixture that has three. -- **Structural navigation is two of the four arrows.** `Ctrl+↑` and `Ctrl+↓` move - the locus out and in. Sibling movement needs a sibling list, which is a - question about the execution tree that the journal fixture does not answer. - **The journey is a projector.** While `--play` runs it supplies the moment on screen; the store still reduces every keystroke, and the two meet again the moment the journey ends. @@ -183,8 +221,13 @@ the one line of reconciliation lives in `store.ts`. No #838 golden moved. ## What the evidence rests on -Nineteen controls, nine of them new, each breaking exactly one claim and each -rejected by name by the same oracle that admits the honest run. The two that -matter most are the two that reproduce the defects above, because they are the -only reason to believe the byte-driven cases would notice if the repair were -undone. +Twenty-three controls, thirteen of them new, each breaking exactly one claim and +each rejected by name by the same oracle that admits the honest run. The two +that matter most are the two that reproduce the decoder defects, because they +are the only reason to believe the byte-driven cases would notice if the repair +were undone. + +**Transitions are driven through the reducer**, forward and in reverse, from +each of the fourteen frames. An earlier round proved them only by constructing +each destination from its own URL, which is a check a reducer that moved focus +and left the route behind passes without trouble — and did. diff --git a/scripts/repl-study/focus.ts b/scripts/repl-study/focus.ts index c58fd0bde..9541f99a1 100644 --- a/scripts/repl-study/focus.ts +++ b/scripts/repl-study/focus.ts @@ -120,11 +120,11 @@ function withinHistory(identity: string): boolean { function regionLabel(state: ReplState, region: (typeof REGIONS)[number]): string { if (region === "sessions") { - const journal = state.route.at !== undefined || state.selection >= 0; + const journal = state.selection >= 0; return journal ? "Journal · checkpoint list" : `Sessions · ${state.moment.sessions}`; } if (region === "transcript") { - return state.route.at === undefined ? "Transcript" : "Transcript · read-only"; + return state.route.inspect ? "Transcript · read-only" : "Transcript"; } if (region === "bindings") { return "Bindings"; diff --git a/scripts/repl-study/frames.ts b/scripts/repl-study/frames.ts index eba5eb1f9..09f528398 100644 --- a/scripts/repl-study/frames.ts +++ b/scripts/repl-study/frames.ts @@ -39,8 +39,6 @@ export interface StudyFrame { readonly fixture: FixtureName; /** Whether the study drew the numbered overlay in this frame. */ readonly overlay: boolean; - /** Where the scrubber was, for the one frame that had moved it. */ - readonly selection?: string; readonly targets: readonly StudyTarget[]; /** The identity Tab lands on, and the study's own wording for it. */ readonly tab: string; @@ -152,7 +150,7 @@ export const FRAMES: readonly StudyFrame[] = [ title: "Three Agent sessions · background activity does not steal focus", key: "no keypress — reviewer session starts streaming", url: "xmd://repl/e1/transcript/entry-1/document", - head: "cp-11", + head: "cp-13", focus: "region:transcript", fixture: "drawer", overlay: true, @@ -166,7 +164,7 @@ export const FRAMES: readonly StudyFrame[] = [ title: "Project-details Elicit · focus trapped in the drawer", key: 'execution suspends at ', url: "xmd://repl/e1/transcript/entry-1/document/+project", - head: "cp-12", + head: "cp-14", focus: "field:drawer.project.name", fixture: "drawer", overlay: true, @@ -226,7 +224,7 @@ export const FRAMES: readonly StudyFrame[] = [ title: "README-confirmation Elicit · Approve and Decline", key: "Tab ×1 from the preview region", url: "xmd://repl/e1/transcript/entry-1/document/+confirm", - head: "cp-14", + head: "cp-16", focus: "control:drawer.confirm.approve", fixture: "drawer", overlay: true, @@ -250,7 +248,7 @@ export const FRAMES: readonly StudyFrame[] = [ title: "Paused at the live head", key: "Enter on Pause, from frame 05", url: "xmd://repl/e1/history/entry-1/document", - head: "cp-16", + head: "cp-18", focus: "control:transport.continue", fixture: "paused", overlay: true, @@ -263,12 +261,11 @@ export const FRAMES: readonly StudyFrame[] = [ id: "11", title: "Execution History navigation · checkpoint selected", key: "← ← · step back two checkpoints", - url: "xmd://repl/e1/history/entry-1/document", - head: "cp-16", + url: "xmd://repl/e1/history/entry-1/document?at=cp-16", + head: "cp-18", focus: "region:history", fixture: "paused", overlay: true, - selection: "cp-14", targets: [...REGIONS, CONTINUE, RETURN_HEAD], tab: "control:transport.continue", shift: "region:input", @@ -278,8 +275,8 @@ export const FRAMES: readonly StudyFrame[] = [ id: "12", title: "Historical inspection · reconstructed, read-only", key: "Enter on the selected checkpoint", - url: "xmd://repl/e1/history/entry-1/document/plan?at=cp-04", - head: "cp-16", + url: "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", + head: "cp-18", focus: "control:transport.fork", fixture: "paused", overlay: true, @@ -303,7 +300,7 @@ export const FRAMES: readonly StudyFrame[] = [ title: "Return to live execution", key: "Enter on Return to paused head, then Continue", url: "xmd://repl/e1/history/entry-1/document", - head: "cp-17", + head: "cp-19", focus: "control:transport.pause", fixture: "drawer", overlay: true, @@ -317,7 +314,7 @@ export const FRAMES: readonly StudyFrame[] = [ title: "Settled entry · REPL input ready for Entry 2", key: "no keypress — Entry 1 completes", url: "xmd://repl/e1/input?draft=%3CPlan%3E", - head: "cp-19", + head: "cp-22", focus: "region:input", fixture: "settled", overlay: true, @@ -344,11 +341,6 @@ export function frame(id: string): StudyFrame | undefined { * still a live target in the state the URL rebuilt. */ export function stateFor(subject: StudyFrame): ReplState { - const journal = journalThrough(subject.head); - const state = hydrate(subject.url, journal); - const selection = - subject.selection === undefined - ? -1 - : journal.findIndex((record) => record.marker === subject.selection); - return { ...state, focus: subject.focus, selection, overlay: subject.overlay }; + const state = hydrate(subject.url, journalThrough(subject.head)); + return { ...state, focus: subject.focus, overlay: subject.overlay }; } diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 29840766b..f5d9a2fc4 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -286,6 +286,7 @@ export function openingState(options: { surface: "transcript" as const, scopes: [], drawers: [], + inspect: false, draft: "", }; const opened = hydrate(formatRoute(start), journal); diff --git a/scripts/repl-study/journal.ts b/scripts/repl-study/journal.ts index 20f354093..959b6a49f 100644 --- a/scripts/repl-study/journal.ts +++ b/scripts/repl-study/journal.ts @@ -33,7 +33,7 @@ export interface JournalRecord { /** Recorded seconds, which is what the Execution History band measures. */ readonly at: number; readonly kind: JournalKind; - /** The scope the record was made in, named as the study names it. */ + /** The scope the record was made in, by the name a route segment uses. */ readonly scope: string; /** The binding, session, drawer or entry the record is about. */ readonly detail: string; @@ -57,7 +57,7 @@ export const JOURNAL: JournalFixture = [ marker: "cp-01", at: 2, kind: "entry.submitted", - scope: "REPL", + scope: "repl", detail: "Entry 1", shows: "nested", }, @@ -81,8 +81,8 @@ export const JOURNAL: JournalFixture = [ marker: "cp-04", at: 12, kind: "scope.enter", - scope: "Plan", - detail: "Plan", + scope: "plan", + detail: "plan", shows: "nested", reconstructs: "paused", }, @@ -90,7 +90,7 @@ export const JOURNAL: JournalFixture = [ marker: "cp-05", at: 18, kind: "binding.published", - scope: "Plan", + scope: "plan", detail: "inputs", shows: "nested", }, @@ -98,7 +98,7 @@ export const JOURNAL: JournalFixture = [ marker: "cp-06", at: 29, kind: "binding.published", - scope: "Plan", + scope: "plan", detail: "draft", shows: "nested", }, @@ -106,7 +106,7 @@ export const JOURNAL: JournalFixture = [ marker: "cp-07", at: 30, kind: "session.started", - scope: "Plan", + scope: "plan", detail: "review-b72e1d", shows: "nested", }, @@ -114,7 +114,7 @@ export const JOURNAL: JournalFixture = [ marker: "cp-08", at: 35, kind: "suspension.opened", - scope: "Plan", + scope: "plan", detail: "review", shows: "nested", }, @@ -122,20 +122,36 @@ export const JOURNAL: JournalFixture = [ marker: "cp-09", at: 41, kind: "suspension.answered", - scope: "Plan", + scope: "plan", detail: "review", shows: "nested", }, { marker: "cp-10", - at: 47, + at: 45, kind: "scope.exit", - scope: "Plan", - detail: "Plan", + scope: "plan", + detail: "plan", shows: "generated", }, { marker: "cp-11", + at: 46, + kind: "scope.enter", + scope: "preview", + detail: "preview", + shows: "generated", + }, + { + marker: "cp-12", + at: 47, + kind: "scope.exit", + scope: "preview", + detail: "preview", + shows: "generated", + }, + { + marker: "cp-13", at: 48, kind: "session.started", scope: "document", @@ -143,7 +159,7 @@ export const JOURNAL: JournalFixture = [ shows: "drawer", }, { - marker: "cp-12", + marker: "cp-14", at: 49, kind: "suspension.opened", scope: "document", @@ -151,7 +167,7 @@ export const JOURNAL: JournalFixture = [ shows: "drawer", }, { - marker: "cp-13", + marker: "cp-15", at: 52, kind: "suspension.answered", scope: "document", @@ -159,7 +175,7 @@ export const JOURNAL: JournalFixture = [ shows: "drawer", }, { - marker: "cp-14", + marker: "cp-16", at: 53, kind: "suspension.opened", scope: "document", @@ -167,7 +183,7 @@ export const JOURNAL: JournalFixture = [ shows: "drawer", }, { - marker: "cp-15", + marker: "cp-17", at: 54, kind: "suspension.answered", scope: "document", @@ -175,7 +191,7 @@ export const JOURNAL: JournalFixture = [ shows: "drawer", }, { - marker: "cp-16", + marker: "cp-18", at: 55, kind: "paused", scope: "document", @@ -183,7 +199,7 @@ export const JOURNAL: JournalFixture = [ shows: "paused", }, { - marker: "cp-17", + marker: "cp-19", at: 57, kind: "resumed", scope: "document", @@ -191,18 +207,26 @@ export const JOURNAL: JournalFixture = [ shows: "drawer", }, { - marker: "cp-18", + marker: "cp-20", + at: 58, + kind: "scope.enter", + scope: "write", + detail: "write", + shows: "drawer", + }, + { + marker: "cp-21", at: 60, kind: "binding.published", - scope: "document", + scope: "write", detail: "readme", shows: "drawer", }, { - marker: "cp-19", + marker: "cp-22", at: 61, kind: "entry.settled", - scope: "REPL", + scope: "repl", detail: "Entry 1", shows: "settled", }, @@ -214,7 +238,7 @@ export interface Moment { readonly marker?: string; readonly at: number; readonly transport: TransportMode; - /** The innermost scope that was open. */ + /** The innermost scope that was open, by its route segment. */ readonly scope: string; /** The bindings that scope had published by then, in the order they arrived. */ readonly published: readonly string[]; @@ -225,6 +249,9 @@ export interface Moment { readonly shows: FixtureName; } +/** Every scope is opened inside this one, which the route spells as the entry. */ +export const ROOT_SCOPE = "repl"; + function isDrawerKind(value: string): value is DrawerKind { return value === "project" || value === "review" || value === "confirm"; } @@ -253,8 +280,8 @@ export function journalThrough( * journal it was handed, which is the head by definition. */ export function fold(journal: JournalFixture, upTo?: string): Moment { - const scopes: string[] = ["REPL"]; - const published = new Map([["REPL", []]]); + const scopes: string[] = [ROOT_SCOPE]; + const published = new Map([[ROOT_SCOPE, []]]); let sessions = 0; let suspension: DrawerKind | undefined; let entry: Moment["entry"] = "none"; @@ -301,6 +328,9 @@ export function fold(journal: JournalFixture, upTo?: string): Moment { if (record.kind === "entry.settled") { entry = "settled"; transport = "idle"; + // A settled entry closes everything it opened, so what is left is the + // REPL scope the next entry will be submitted into. + scopes.splice(1); } marker = record.marker; at = record.at; @@ -320,7 +350,7 @@ export function fold(journal: JournalFixture, upTo?: string): Moment { marker, at, transport: upTo === undefined ? transport : "inspecting", - scope: `${scope} scope`, + scope, published: published.get(scope) ?? [], suspension, sessions, @@ -341,3 +371,38 @@ export function markerShowing( ): string | undefined { return journal.find((record) => record.shows === name)?.marker; } + +/** + * The scopes opened directly inside one parent path, in the order the execution + * opened them. + * + * This is the sibling list structural navigation moves along. It is derived from + * the journal rather than declared, because siblings are a fact about what the + * execution did — which is why a scope that has not been entered yet is not one. + */ +export function siblingsOf(journal: JournalFixture, parents: readonly string[]): readonly string[] { + const stack: string[] = [ROOT_SCOPE]; + const found: string[] = []; + const inside = (): boolean => + stack.length === parents.length + 1 && parents.every((name, at) => stack[at + 1] === name); + for (const record of journal) { + if (record.kind === "scope.enter") { + if (inside() && !found.includes(record.detail)) { + found.push(record.detail); + } + stack.push(record.detail); + continue; + } + if (record.kind === "scope.exit") { + const left = stack.lastIndexOf(record.detail); + if (left > 0) { + stack.splice(left, 1); + } + continue; + } + if (record.kind === "entry.settled") { + stack.splice(1); + } + } + return found; +} diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index fe7759c72..46a55c1f6 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -46,6 +46,14 @@ export const MUTATIONS = [ "swallow-pending-escape", /** Accept only a synthetic Tab+shift as reverse traversal. */ "ignore-backtab", + /** Move focus across a region boundary without moving the URL's surface. */ + "keep-route-on-focus", + /** Forget the selected marker when a state is rebuilt from its URL. */ + "drop-selection-on-hydrate", + /** Exit on Ctrl+C while a paused entry is still active. */ + "exit-on-paused-interrupt", + /** Leave the sibling arrows inert, as if the locus had no siblings. */ + "inert-sibling-arrows", ] as const; export type Mutation = (typeof MUTATIONS)[number]; diff --git a/scripts/repl-study/route.ts b/scripts/repl-study/route.ts index 27ee9e678..78da80296 100644 --- a/scripts/repl-study/route.ts +++ b/scripts/repl-study/route.ts @@ -9,7 +9,13 @@ * is focused right now are deliberately not in it — they can be thrown away * without changing what the REPL means. * - * xmd://repl/e1/transcript/entry-1/document/+project?at=cp-07&draft=%3CPlan%3E + * xmd://repl/e1/transcript/entry-1/document/+project?at=cp-07&inspect&draft=%3CPlan%3E + * + * Selecting a recorded marker and opening the reconstruction at it are two + * different things, so they are two different parts of the URL. `at` is the + * marker the scrubber has selected; `inspect` says the reconstruction is open. + * A selection that lived only in memory could not be reopened, and a URL that + * could not tell the two apart would render one state and hydrate into another. * * Parsing refuses rather than guesses, because a URL that quietly lost a drawer * would reopen a suspended execution as if nothing were waiting. @@ -41,8 +47,10 @@ export interface Route { readonly scopes: readonly string[]; /** The drawer stack. The last one is the top, and only the top is interactive. */ readonly drawers: readonly string[]; - /** The recorded marker under inspection. Absent means following the live head. */ + /** The recorded marker the scrubber has selected. Absent means none is. */ readonly at?: string; + /** True while the reconstruction at `at` is open rather than merely selected. */ + readonly inspect: boolean; /** What has been typed and not run. Empty is the same as nothing typed. */ readonly draft: string; } @@ -64,6 +72,11 @@ export function formatRoute(route: Route): string { if (route.at !== undefined) { query.push(`at=${encodeURIComponent(route.at)}`); } + if (route.inspect) { + // Valueless, and the only spelling of it, so `inspect` cannot arrive in two + // forms that render the same screen. + query.push("inspect"); + } if (route.draft !== "") { query.push(`draft=${encodeURIComponent(route.draft)}`); } @@ -121,6 +134,7 @@ export function parseRoute(url: string): Result { } let at: string | undefined; + let inspect = false; let draft = ""; for (const pair of query === "" ? [] : query.split("&")) { const equals = pair.indexOf("="); @@ -128,19 +142,29 @@ export function parseRoute(url: string): Result { const value = equals === -1 ? "" : decodeURIComponent(pair.slice(equals + 1)); if (key === "at") { if (value === "") { - return Err(new Error("at= names no marker; leave it out to follow the live head")); + return Err(new Error("at= names no marker; leave it out to select none")); } at = value; continue; } + if (key === "inspect") { + if (equals !== -1) { + return Err(new Error("inspect takes no value; it is present or it is not")); + } + inspect = true; + continue; + } if (key === "draft") { draft = value; continue; } return Err(new Error(`${JSON.stringify(key)} is not part of a REPL route`)); } + if (inspect && at === undefined) { + return Err(new Error("inspect needs the marker it reconstructs; add at=")); + } - return Ok({ execution, surface, scopes, drawers, at, draft }); + return Ok({ execution, surface, scopes, drawers, at, inspect, draft }); } /** The top drawer, which is the only one that is visible and interactive. */ @@ -148,9 +172,9 @@ export function topDrawer(route: Route): string | undefined { return route.drawers[route.drawers.length - 1]; } -/** True while a recorded moment is being inspected rather than the head followed. */ +/** True while a recorded moment is reconstructed rather than merely selected. */ export function inspecting(route: Route): boolean { - return route.at !== undefined; + return route.inspect; } /** @@ -158,7 +182,14 @@ export function inspecting(route: Route): boolean { * * Naming them is what lets push and replace be a decision rather than a habit. */ -export type RouteChange = "surface" | "locus" | "drawer" | "inspection" | "scrub" | "draft"; +export type RouteChange = + | "surface" + | "focus" + | "locus" + | "drawer" + | "inspection" + | "scrub" + | "draft"; export type Navigation = "push" | "replace"; @@ -172,3 +203,24 @@ export type Navigation = "push" | "replace"; export function navigationFor(change: RouteChange): Navigation { return change === "scrub" || change === "draft" ? "replace" : "push"; } + +/** + * The surface a focus identity belongs to. + * + * The surface segment says which region owns focus, so moving focus across a + * region boundary *is* moving the route. A control belongs to the surface of + * the region that owns it, which is why focusing `Pause` reads as `history` + * and focusing `Run` reads as `input`. + */ +export function surfaceFor(identity: string): RouteSurface | undefined { + const region = identity.startsWith("region:") + ? identity.slice("region:".length) + : identity.startsWith("control:transport.") + ? "history" + : identity.startsWith("control:input.") + ? "input" + : identity.startsWith("control:drawer.") || identity.startsWith("field:drawer.") + ? "transcript" + : undefined; + return region !== undefined && isRouteSurface(region) ? region : undefined; +} diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts index 6c11866f9..8897e221a 100644 --- a/scripts/repl-study/store.ts +++ b/scripts/repl-study/store.ts @@ -17,7 +17,7 @@ import { fixture, drawerOf, isDrawerKind } from "./fixtures.ts"; import type { DrawerKind } from "./fixtures.ts"; -import { fold, JOURNAL, journalThrough } from "./journal.ts"; +import { fold, JOURNAL, journalThrough, siblingsOf } from "./journal.ts"; import type { JournalFixture, JournalRecord, Moment } from "./journal.ts"; import { layoutFor, SURFACES } from "./layout.ts"; import type { Layout, SurfaceName } from "./layout.ts"; @@ -25,7 +25,7 @@ import { focusMap, registry, resolve, step } from "./focus.ts"; import type { FocusTarget } from "./focus.ts"; import type { FixtureName, Fixture, TransportMode } from "./model.ts"; import type { Mutation } from "./mutations.ts"; -import { formatRoute, navigationFor, parseRoute, topDrawer } from "./route.ts"; +import { formatRoute, navigationFor, parseRoute, surfaceFor, topDrawer } from "./route.ts"; import type { Route, RouteChange, RouteSurface } from "./route.ts"; /** @@ -95,7 +95,12 @@ export interface ReplState { readonly focus: string; /** Disposable: the transcript window. */ readonly anchor: number; - /** Disposable: which recorded marker the scrubber is on, or -1 for none. */ + /** + * Which recorded marker the scrubber is on, or -1 for none. + * + * Derived from `route.at`, never set on its own. A selection that lived only + * in memory would render a state the URL could not reopen. + */ readonly selection: number; /** Disposable: whether the F1 focus map is drawn. */ readonly overlay: boolean; @@ -111,31 +116,46 @@ export interface ReplState { function mint( route: Route, journal: JournalFixture, - rest: Omit, + rest: Omit, ): ReplState { - return { route, journal, moment: fold(journal, route.at), ...rest }; + return { + route, + journal, + // Selecting a marker and reconstructing it are different things: the fold + // follows the head until `inspect` says the reconstruction is open. + moment: fold(journal, route.inspect ? route.at : undefined), + selection: + route.at === undefined ? -1 : journal.findIndex((record) => record.marker === route.at), + ...rest, + }; } /** Rebuild everything durable from a URL and a journal, with nothing else. */ -export function hydrate(url: string, journal: JournalFixture): ReplState { +export function hydrate(url: string, journal: JournalFixture, mutation?: Mutation): ReplState { const parsed = parseRoute(url); if (!parsed.ok) { throw parsed.error; } - return hydrateRoute(parsed.value, journal); + return hydrateRoute(parsed.value, journal, mutation); } -export function hydrateRoute(route: Route, journal: JournalFixture): ReplState { - return mint(route, journal, { +export function hydrateRoute( + route: Route, + journal: JournalFixture, + mutation?: Mutation, +): ReplState { + const rebuilt = mint(route, journal, { focus: `region:${route.surface}`, anchor: 0, - selection: -1, overlay: false, invokers: {}, history: [], interrupts: 0, quit: false, }); + // The control throws the selection away on the way back in, which is what a + // selection kept outside the URL would have done on every cold start. + return mutation === "drop-selection-on-hydrate" ? { ...rebuilt, selection: -1 } : rebuilt; } /** The semantic projection two states are compared by. Nothing disposable is in it. */ @@ -145,6 +165,7 @@ export interface Projection { readonly scopes: readonly string[]; readonly drawers: readonly string[]; readonly at?: string; + readonly inspect: boolean; readonly draft: string; readonly head?: string; readonly transport: TransportMode; @@ -152,15 +173,25 @@ export interface Projection { readonly published: readonly string[]; readonly suspension?: DrawerKind; readonly entry: Moment["entry"]; + /** The marker the scrubber has selected, and what was true there. */ + readonly selected?: string; + readonly selectedScope?: string; + readonly selectedPublished?: readonly string[]; } export function projection(state: ReplState): Projection { + // The selected marker is folded for itself, so the projection carries the + // scope and the bindings a cold start has to come back with — not just the + // marker's name. + const selected = + state.selection < 0 ? undefined : fold(state.journal, state.journal[state.selection].marker); return { url: formatRoute(state.route), surface: state.route.surface, scopes: state.route.scopes, drawers: state.route.drawers, at: state.route.at, + inspect: state.route.inspect, draft: state.route.draft, head: state.journal[state.journal.length - 1]?.marker, transport: state.moment.transport, @@ -168,6 +199,9 @@ export function projection(state: ReplState): Projection { published: state.moment.published, suspension: state.moment.suspension, entry: state.moment.entry, + selected: selected?.marker, + selectedScope: selected?.scope, + selectedPublished: selected?.published, }; } @@ -197,7 +231,7 @@ export function fixtureFor(state: ReplState): Fixture { const base = fixture(state.moment.shows); const top = topDrawer(state.route); const drawer = top !== undefined && isDrawerKind(top) ? drawerOf(top) : undefined; - const inspecting = state.route.at !== undefined; + const inspecting = state.route.inspect; const selected = state.journal[state.selection]?.at; return { ...base, @@ -207,7 +241,9 @@ export function fixtureFor(state: ReplState): Fixture { history: { ...base.history, transport: state.moment.transport, - selectedAt: inspecting ? state.moment.at : selected, + // Selecting a marker marks the band; reconstructing it also reads + // read-only and carries the badge. Both show the same marker. + selectedAt: selected, }, }; } @@ -255,7 +291,6 @@ function go(state: ReplState, route: Route, change: RouteChange, mutation?: Muta return mint(route, state.journal, { focus: state.focus, anchor: state.anchor, - selection: state.selection, overlay: state.overlay, invokers: state.invokers, history: navigation === "push" ? [...state.history, formatRoute(state.route)] : state.history, @@ -268,7 +303,6 @@ function withJournal(state: ReplState, journal: JournalFixture): ReplState { return mint(state.route, journal, { focus: state.focus, anchor: state.anchor, - selection: state.selection, overlay: state.overlay, invokers: state.invokers, history: state.history, @@ -287,6 +321,32 @@ function extendTo(state: ReplState, kind: JournalRecord["kind"]): ReplState { return withJournal(state, journalThrough(next.marker)); } +/** + * Put focus on one identity, and take the route with it. + * + * The surface segment says which region owns focus, so a focus move across a + * region boundary *is* a move. Leaving the URL behind would let a cold start + * come back to the region somebody had already tabbed away from, and would let + * a narrow terminal go on rendering one surface full-screen while focus named + * another. + */ +function focusTo( + state: ReplState, + identity: string, + change: RouteChange, + mutation?: Mutation, +): ReplState { + const surface = surfaceFor(identity); + if ( + surface === undefined || + surface === state.route.surface || + mutation === "keep-route-on-focus" + ) { + return { ...state, focus: identity }; + } + return { ...go(state, { ...state.route, surface }, change, mutation), focus: identity }; +} + export type HarnessEvent = /** Whatever the decoder produced. It is parsed here, never assumed. */ | { readonly kind: "key"; readonly event: unknown } @@ -350,7 +410,7 @@ function reverseTab(key: Key, mutation?: Mutation): boolean { /** A recorded moment is read-only, so nothing that changes the run may happen in one. */ function frozen(state: ReplState, mutation?: Mutation): boolean { - return state.route.at !== undefined && mutation !== "mutate-while-inspecting"; + return state.route.inspect && mutation !== "mutate-while-inspecting"; } /** @@ -377,6 +437,7 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon surface: "transcript", scopes: [], drawers: [], + inspect: false, draft: "", }, state.journal, @@ -404,7 +465,11 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon return { ...state, quit: true }; } if (key.ctrl === true && key.code === "c") { - if (state.moment.entry === "running" && state.moment.transport === "live") { + // An entry that is paused, or being looked at through a reconstruction, is + // still running. Exiting instead of interrupting it would hand its + // lifecycle to whoever closed the terminal. + const active = state.moment.entry === "running" && mutation !== "exit-on-paused-interrupt"; + if (active) { return { ...state, interrupts: state.interrupts + 1 }; } if (state.route.draft !== "") { @@ -416,7 +481,7 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon return { ...state, overlay: !state.overlay }; } if (key.code === "Tab" || key.code === "Backtab") { - return { ...state, focus: step(here, live, reverseTab(key, mutation) ? -1 : 1) }; + return focusTo(state, step(here, live, reverseTab(key, mutation) ? -1 : 1), "focus", mutation); } if (key.code === "Escape") { return back(state, here, live, context.size, mutation); @@ -430,13 +495,17 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon const surface = (["sessions", "transcript", "bindings", "input", "history"] as const)[ digit - 1 ]; - return { - ...go(state, { ...state.route, surface }, "surface", mutation), - focus: `region:${surface}`, - }; + return focusTo(state, `region:${surface}`, "surface", mutation); } - if (key.ctrl === true && key.code !== undefined && key.code.startsWith("Arrow")) { + if ( + key.ctrl === true && + key.code !== undefined && + key.code.startsWith("Arrow") && + // Structural navigation acts only outside an editable target, so a + // modified arrow is never stolen out of a draft somebody is typing. + !editable(here) + ) { return structural(state, key.code, mutation); } @@ -488,13 +557,13 @@ function back( return closed; } const invoker = state.invokers[top] ?? "region:transcript"; - return { ...closed, focus: resolve(invoker, targets(closed, size, mutation)) }; + return focusTo(closed, resolve(invoker, targets(closed, size, mutation)), "focus", mutation); } - if (state.route.at !== undefined) { - return go(state, { ...state.route, at: undefined }, "inspection", mutation); + if (state.route.inspect) { + return go(state, { ...state.route, inspect: false }, "inspection", mutation); } if (!here.startsWith("region:")) { - return { ...state, focus: resolve(ownerRegion(here), live) }; + return focusTo(state, resolve(ownerRegion(here), live), "focus", mutation); } const previous = state.history[state.history.length - 1]; if (previous === undefined) { @@ -507,7 +576,6 @@ function back( return mint(parsed.value, state.journal, { focus: state.focus, anchor: state.anchor, - selection: state.selection, overlay: state.overlay, invokers: state.invokers, history: state.history.slice(0, -1), @@ -535,17 +603,16 @@ function activate(state: ReplState, here: string, mutation?: Mutation): ReplStat return frozen(state, mutation) ? state : extendTo(state, "resumed"); } if (here === "control:transport.return-head") { - return go(state, { ...state.route, at: undefined }, "inspection", mutation); - } - if (here === "region:history" && state.selection >= 0) { - const marker = state.journal[state.selection]?.marker; - if (marker !== undefined) { - // A reconstruction has no live suspension, so the drawer stack does not - // survive into one. That is what makes study frame 12's focus walk real: - // the trapped controls leave the sequence and focus has to resolve to the - // nearest owner that did survive. - return go(state, { ...state.route, at: marker, drawers: [] }, "inspection", mutation); - } + // Closing the reconstruction leaves the selection where it was: returning + // to the head is not the same act as deselecting a marker. + return go(state, { ...state.route, inspect: false }, "inspection", mutation); + } + if (here === "region:history" && state.selection >= 0 && !state.route.inspect) { + // A reconstruction has no live suspension, so the drawer stack does not + // survive into one. That is what makes study frame 12's focus walk real: + // the trapped controls leave the sequence and focus has to resolve to the + // nearest owner that did survive. + return go(state, { ...state.route, inspect: true, drawers: [] }, "inspection", mutation); } return state; } @@ -565,12 +632,10 @@ function scrub(state: ReplState, delta: number, mutation?: Mutation): ReplState } const from = state.selection === -1 ? count : state.selection; const selection = Math.max(0, Math.min(count - 1, from + delta)); - const moved = { ...state, selection }; - if (state.route.at === undefined) { - return moved; - } - const marker = state.journal[selection].marker; - return { ...go(moved, { ...state.route, at: marker }, "scrub", mutation), selection }; + // The selection is canonical, so it moves in the URL whether or not the + // reconstruction is open — and it replaces, so Back from a marker returns to + // where you came from rather than walking every marker the scrubber passed. + return go(state, { ...state.route, at: state.journal[selection].marker }, "scrub", mutation); } /** @@ -588,10 +653,28 @@ function structural(state: ReplState, code: string, mutation?: Mutation): ReplSt : go(state, { ...state.route, scopes: scopes.slice(0, -1) }, "locus", mutation); } if (code === "ArrowDown") { - const child = state.moment.scope.replace(/ scope$/, ""); - return scopes[scopes.length - 1] === child + // The entry is the first segment; the journal's scope stack starts below it. + const children = siblingsOf(state.journal, scopes.slice(1)); + const first = children[0]; + return first === undefined + ? state + : go(state, { ...state.route, scopes: [...scopes, first] }, "locus", mutation); + } + if ((code === "ArrowLeft" || code === "ArrowRight") && mutation !== "inert-sibling-arrows") { + const current = scopes[scopes.length - 1]; + if (scopes.length < 2 || current === undefined) { + return state; + } + const siblings = siblingsOf(state.journal, scopes.slice(1, -1)); + const at = siblings.indexOf(current); + if (at === -1 || siblings.length === 0) { + return state; + } + const delta = code === "ArrowLeft" ? -1 : 1; + const next = siblings[(at + delta + siblings.length) % siblings.length]; + return next === current ? state - : go(state, { ...state.route, scopes: [...scopes, child] }, "locus", mutation); + : go(state, { ...state.route, scopes: [...scopes.slice(0, -1), next] }, "locus", mutation); } return state; } diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index b99a74ff0..2dc3fc7fa 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -38,8 +38,14 @@ import { step, } from "../repl-study/focus.ts"; import { scanKeys } from "../repl-study/host.ts"; -import { fold, JOURNAL, journalThrough, markers } from "../repl-study/journal.ts"; -import { formatRoute, navigationFor, parseRoute, ROUTE_SURFACES } from "../repl-study/route.ts"; +import { fold, JOURNAL, journalThrough, markers, siblingsOf } from "../repl-study/journal.ts"; +import { + formatRoute, + navigationFor, + parseRoute, + ROUTE_SURFACES, + surfaceFor, +} from "../repl-study/route.ts"; import type { Route } from "../repl-study/route.ts"; import { fixtureFor, @@ -114,7 +120,7 @@ describe("the URL that says where you are", () => { it("parses every part of the schema, and refuses what is not in it", function* () { const parsed = parseRoute( - "xmd://repl/e1/transcript/entry-1/plan/+project?at=cp-07&draft=%3CPlan%3E", + "xmd://repl/e1/transcript/entry-1/plan/+project?at=cp-07&inspect&draft=%3CPlan%3E", ); expect(parsed.ok).toBe(true); if (!parsed.ok) { @@ -126,6 +132,7 @@ describe("the URL that says where you are", () => { scopes: ["entry-1", "plan"], drawers: ["project"], at: "cp-07", + inspect: true, draft: "", }); @@ -136,6 +143,10 @@ describe("the URL that says where you are", () => { "xmd://repl/e1/transcript/+project/plan", "xmd://repl/e1/transcript?zoom=2", "xmd://repl/e1/transcript?at=", + // `inspect` reconstructs a marker, so it cannot arrive without one, and + // it has exactly one spelling. + "xmd://repl/e1/transcript?inspect", + "xmd://repl/e1/transcript?at=cp-04&inspect=yes", ]; for (const url of refusals) { const result = parseRoute(url); @@ -146,11 +157,24 @@ describe("the URL that says where you are", () => { it("spells the live head exactly one way", function* () { // There is no `at=head` sentinel, so two URLs cannot render the same state // and hydrate differently. - const following = hydrate("xmd://repl/e1/history", journalThrough("cp-16")); + const following = hydrate("xmd://repl/e1/history", journalThrough("cp-18")); expect(following.route.at).toBeUndefined(); expect(following.moment.transport).toBe("paused"); - const inspecting = hydrate("xmd://repl/e1/history?at=cp-04", journalThrough("cp-16")); - expect(inspecting.moment.transport).toBe("inspecting"); + }); + + it("says selecting a marker and reconstructing it separately", function* () { + // The scrubber's selection is canonical location; whether the + // reconstruction is open is a different question about the same marker. + const selected = hydrate("xmd://repl/e1/history?at=cp-04", journalThrough("cp-18")); + expect(selected.selection).toBeGreaterThanOrEqual(0); + expect(selected.moment.transport).toBe("paused"); + + const reconstructed = hydrate( + "xmd://repl/e1/history?at=cp-04&inspect", + journalThrough("cp-18"), + ); + expect(reconstructed.selection).toBe(selected.selection); + expect(reconstructed.moment.transport).toBe("inspecting"); }); it("keeps a drawer from being mistaken for a scope of the same name", function* () { @@ -221,6 +245,52 @@ describe("every frame of the approved focus study", () => { } }); + it("drives the real reducer to where the study says it goes", function* () { + // The transition, not two destinations built independently. A reducer that + // only changed focus would still satisfy a check that constructed each + // frame from its own URL. + for (const subject of FRAMES) { + const state = stateFor(subject); + const forward = press(state, "Tab"); + expect({ frame: subject.id, tab: focusIn(forward, WIDE) }).toEqual({ + frame: subject.id, + tab: subject.tab, + }); + const reverse = press(state, "Backtab"); + expect({ frame: subject.id, shift: focusIn(reverse, WIDE) }).toEqual({ + frame: subject.id, + shift: subject.shift, + }); + } + }); + + it("takes the URL with it whenever focus changes region", function* () { + for (const subject of FRAMES) { + const state = stateFor(subject); + for (const [name, moved] of [ + ["tab", press(state, "Tab")], + ["shift", press(state, "Backtab")], + ] as const) { + const landed = focusIn(moved, WIDE); + const expected = surfaceFor(landed); + expect({ frame: subject.id, key: name, surface: moved.route.surface }).toEqual({ + frame: subject.id, + key: name, + surface: expected ?? moved.route.surface, + }); + } + } + }); + + it("leaves the URL behind when focus is allowed to move without it", function* () { + const state = stateFor(frame("02")!); + const moved = press(state, "Tab", WIDE, "keep-route-on-focus"); + expect(focusIn(moved, WIDE)).toBe("region:history"); + expect(moved.route.surface).toBe("input"); + // Which is exactly the divergence: a cold start comes back somewhere else. + expect(hydrate(formatRoute(moved.route), moved.journal).focus).toBe("region:input"); + }); + it("walks the whole ring in both directions and comes back to the start", function* () { for (const subject of FRAMES) { const live = targets(stateFor(subject), WIDE); @@ -284,7 +354,7 @@ describe("restoring focus when a target disappears", () => { // trapped controls left the sequence. const suspended = stateFor(frame("07")!); const reconstructed = hydrate( - "xmd://repl/e1/history/entry-1/document/plan?at=cp-04", + "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", suspended.journal, ); expect(resolve("field:drawer.project.name", targets(reconstructed, WIDE))).toBe( @@ -313,7 +383,7 @@ describe("restoring focus when a target disappears", () => { describe("drawers, their trap and what they restore", () => { const opened = (): ReplState => { - const base = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")); + const base = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); return openDrawer(base, "project", "region:transcript", WIDE); }; @@ -362,10 +432,13 @@ describe("drawers, their trap and what they restore", () => { describe("inspecting a recorded moment", () => { const paused = (): ReplState => - hydrate("xmd://repl/e1/history/entry-1/document", journalThrough("cp-16")); + hydrate("xmd://repl/e1/history/entry-1/document", journalThrough("cp-18")); const inspecting = (): ReplState => - hydrate("xmd://repl/e1/history/entry-1/document/plan?at=cp-04", journalThrough("cp-16")); + hydrate( + "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", + journalThrough("cp-18"), + ); it("refuses a mutation while a reconstruction is open", function* () { const state = { ...inspecting(), focus: "region:input" }; @@ -389,7 +462,9 @@ describe("inspecting a recorded moment", () => { it("withholds Continue until the paused head is regained", function* () { expect(ring(inspecting())).not.toContain("control:transport.continue"); const returned = press({ ...inspecting(), focus: "control:transport.return-head" }, "Enter"); - expect(returned.route.at).toBeUndefined(); + expect(returned.route.inspect).toBe(false); + // Closing the reconstruction is not deselecting the marker. + expect(returned.route.at).toBe("cp-04"); expect(ring({ ...returned, focus: "region:history" })).toContain("control:transport.continue"); }); @@ -403,9 +478,106 @@ describe("inspecting a recorded moment", () => { }); }); +describe("the selected marker is location", () => { + const paused = (): ReplState => + hydrate("xmd://repl/e1/history/entry-1/document", journalThrough("cp-18")); + + it("writes the scrubber's selection into the URL, by replacing", function* () { + const state = { ...paused(), focus: "region:history" }; + const scrubbed = press(state, "ArrowLeft"); + expect(scrubbed.route.at).toBeDefined(); + expect(scrubbed.route.inspect).toBe(false); + expect(scrubbed.history.length).toBe(state.history.length); + }); + + it("comes back to the same marker, scope and bindings from the URL alone", function* () { + // Study frame 11: a marker is selected and the reconstruction is not open. + const selected = stateFor(frame("11")!); + expect(selected.selection).toBeGreaterThanOrEqual(0); + const rebuilt = hydrate(formatRoute(selected.route), selected.journal); + expect(rebuilt.selection).toBe(selected.selection); + const rebuiltProjection = projection(rebuilt); + expect(rebuiltProjection.selected).toBe("cp-16"); + expect(rebuiltProjection.selectedScope).toBe(projection(selected).selectedScope); + expect(rebuiltProjection.selectedPublished).toEqual(projection(selected).selectedPublished); + expect(rebuiltProjection).toEqual(projection(selected)); + }); + + it("loses the selection when it is kept outside the URL", function* () { + const selected = stateFor(frame("11")!); + const rebuilt = hydrate( + formatRoute(selected.route), + selected.journal, + "drop-selection-on-hydrate", + ); + expect(rebuilt.selection).toBe(-1); + expect(rebuilt.selection).not.toBe(selected.selection); + }); + + it("selects without reconstructing, and reconstructs on Enter", function* () { + const scrubbed = press({ ...paused(), focus: "region:history" }, "ArrowLeft"); + expect(scrubbed.moment.transport).toBe("paused"); + const inspected = press(scrubbed, "Enter"); + expect(inspected.route.inspect).toBe(true); + expect(inspected.route.at).toBe(scrubbed.route.at); + expect(inspected.moment.transport).toBe("inspecting"); + }); +}); + +describe("structural navigation across siblings", () => { + const settled = (): ReplState => + hydrate("xmd://repl/e1/transcript/entry-1/document/plan", journalThrough("cp-22")); + + it("reads the sibling list out of the journal, in source order", function* () { + expect(siblingsOf(JOURNAL, ["document"])).toEqual(["plan", "preview", "write"]); + expect(siblingsOf(JOURNAL, [])).toEqual(["document"]); + }); + + it("moves to the next and previous sibling, and takes the URL with it", function* () { + const state = settled(); + const next = reduce(state, key("ArrowRight", { ctrl: true }), context(WIDE)); + expect(next.route.scopes).toEqual(["entry-1", "document", "preview"]); + const after = reduce(next, key("ArrowRight", { ctrl: true }), context(WIDE)); + expect(after.route.scopes).toEqual(["entry-1", "document", "write"]); + const back = reduce(after, key("ArrowLeft", { ctrl: true }), context(WIDE)); + expect(back.route.scopes).toEqual(["entry-1", "document", "preview"]); + // Each is a place you went, so each is a place Back returns from. + expect(after.history.length).toBe(state.history.length + 2); + }); + + it("wraps at both ends of the sibling list", function* () { + const first = settled(); + const wrapped = reduce(first, key("ArrowLeft", { ctrl: true }), context(WIDE)); + expect(wrapped.route.scopes).toEqual(["entry-1", "document", "write"]); + }); + + it("moves out to the parent and in to the first child", function* () { + const out = reduce(settled(), key("ArrowUp", { ctrl: true }), context(WIDE)); + expect(out.route.scopes).toEqual(["entry-1", "document"]); + const back = reduce(out, key("ArrowDown", { ctrl: true }), context(WIDE)); + expect(back.route.scopes).toEqual(["entry-1", "document", "plan"]); + }); + + it("never intercepts a modified arrow out of a draft somebody is typing", function* () { + const typing = { ...settled(), focus: "region:input" }; + const moved = reduce(typing, key("ArrowRight", { ctrl: true }), context(WIDE)); + expect(moved.route.scopes).toEqual(typing.route.scopes); + }); + + it("leaves the arrows inert when the sibling list is ignored", function* () { + const state = settled(); + const moved = reduce( + state, + key("ArrowRight", { ctrl: true }), + context(WIDE, "inert-sibling-arrows"), + ); + expect(moved.route.scopes).toEqual(state.route.scopes); + }); +}); + describe("push versus replace", () => { const start = (): ReplState => - hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")); + hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); it("replaces the URL while a draft is typed", function* () { let state: ReplState = { ...start(), focus: "region:input" }; @@ -439,7 +611,7 @@ describe("push versus replace", () => { expect(state.history.length).toBe(entered); expect(navigationFor("scrub")).toBe("replace"); const back = press(state, "Escape"); - expect(back.route.at).toBeUndefined(); + expect(back.route.inspect).toBe(false); }); it("fills the navigation stack when every keystroke pushes", function* () { @@ -487,7 +659,7 @@ describe("background updates", () => { describe("rebuilding from the URL and the journal alone", () => { /** A long interaction: typing, traversal, a drawer, inspection and back. */ function journey(): ReplState { - let state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")); + let state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); state = press(state, "4"); for (const glyph of ["<", "P", "l", "a", "n", ">"]) { state = press(state, glyph); @@ -522,17 +694,21 @@ describe("rebuilding from the URL and the journal alone", () => { }); it("throws away the disposable half rather than pretending to restore it", function* () { - const rebuilt = hydrate(formatRoute(journey().route), journey().journal); + const original = journey(); + const rebuilt = hydrate(formatRoute(original.route), original.journal); expect(rebuilt.anchor).toBe(0); - expect(rebuilt.selection).toBe(-1); expect(rebuilt.history).toEqual([]); + expect(rebuilt.overlay).toBe(false); + // The selection is not in that half. It is location, so it comes back. + expect(rebuilt.selection).toBe(original.selection); + expect(rebuilt.selection).toBeGreaterThanOrEqual(0); }); it("folds the journal rather than reading the fixtures", function* () { // The journal is authored by hand from the study. A journal derived from // `fixtures.ts` would make this comparison the fixtures against themselves. const moment = fold(journalThrough("cp-08")); - expect(moment.scope).toBe("Plan scope"); + expect(moment.scope).toBe("plan"); expect(moment.published).toEqual(["inputs", "draft"]); expect(moment.suspension).toBe("review"); expect(moment.sessions).toBe(2); @@ -622,7 +798,7 @@ describe("through a real decoder", () => { const input: Input = yield* until(createInput({})); const events = yield* decoded(input, bytes(ESC)); let state = openDrawer( - hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-12")), + hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")), "project", "region:transcript", WIDE, @@ -688,15 +864,40 @@ describe("Ctrl+C, three ways", () => { expect(control.interrupts).toBe(1); }); + it("interrupts a paused entry, and one being read through a reconstruction", function* () { + // A paused entry is still an entry. Exiting instead of interrupting it + // hands its lifecycle to whoever closed the terminal. + for (const id of ["10", "12"]) { + const state = stateFor(frame(id)!); + const control = reduce(state, key("c", { ctrl: true }), context(WIDE)); + expect({ frame: id, quit: control.quit, interrupts: control.interrupts }).toEqual({ + frame: id, + quit: false, + interrupts: 1, + }); + } + }); + + it("exits from a paused entry when only a live one counts as active", function* () { + const state = stateFor(frame("10")!); + const control = reduce( + state, + key("c", { ctrl: true }), + context(WIDE, "exit-on-paused-interrupt"), + ); + expect(control.quit).toBe(true); + expect(control.interrupts).toBe(0); + }); + it("clears a draft when nothing is running", function* () { - const settled = hydrate("xmd://repl/e1/input?draft=%3CPlan%3E", journalThrough("cp-19")); + const settled = hydrate("xmd://repl/e1/input?draft=%3CPlan%3E", journalThrough("cp-22")); const cleared = reduce(settled, key("c", { ctrl: true }), context(WIDE)); expect(cleared.route.draft).toBe(""); expect(cleared.quit).toBe(false); }); it("leaves when the draft is empty and nothing is running", function* () { - const settled = hydrate("xmd://repl/e1/input", journalThrough("cp-19")); + const settled = hydrate("xmd://repl/e1/input", journalThrough("cp-22")); expect(reduce(settled, key("c", { ctrl: true }), context(WIDE)).quit).toBe(true); }); }); From c2923dae58d30edb737d8fbd620afc55421035d4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 07:17:33 -0400 Subject: [PATCH 07/57] =?UTF-8?q?=F0=9F=8C=B2=20Let=20Freedom's=20node=20t?= =?UTF-8?q?ree=20own=20focus=20(#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat `FocusTarget[]` registry is withdrawn. It kept traversal order, an owner chain and restoration beside the interface, by hand — the manual-focus problem Freedom exists to remove. Every case written against it passed, because a list compared with itself always agrees; what it could not answer was a question about where a control actually is. The interface is now a Freedom node tree. A surface is a node, a scope panel a branch inside it, a drawer a branch pushed as the active focus root, a control a leaf. Traversal order is tree order, computed on demand. A key is invoked on the focused node's scope, so every branch between the root and it runs its middleware — `drawer:project → panel:project.body` is read, not inferred. Closing a drawer removes its branch, and its controls and middleware go with it. `ReplState` has no focus field: the store decides what an event means and names what should happen to focus, and the tree carries it out. `@bomb.sh/freedom` is private and unpublished, so it is vendored from the public playground at 8be97e72 with a manifest, provenance and a drift test. It runs unmodified on this repository's effection 4.1.0 despite declaring alpha.9, so there is no second scope tree and no lockfile moves. Two patches are recorded against it. `useRoot()` acquires the tree as a resource owned by the acquiring scope, because `createRoot()` parents the root to Effection `global`. And `useFocus()`'s remove middleware now asks whether a branch *contains* the focused node rather than whether it *is* it — a drawer closes by removing the branch above the focused control, so the common case left focus on a node that had just been destroyed. A third fix is this harness's own: a reconciler must add before it removes, or the focused control vanishes with no survivor in its region and focus lands outside it. That is how a resumed run first lost its transport slot. Four controls the tree makes possible: rebuild-tree-each-sync, keep-closed-branch, flat-overlay, focus-hidden-target. Twenty-six in total. The vendor is excluded from oxfmt and oxlint through their own configs rather than the lint task's command line: editing `package.json` invalidates `deno.lock` and fails every suite that clones the repository and installs. --- .oxfmtrc.json | 5 +- .oxlintrc.json | 2 + scripts/repl-study/README.md | 27 +- scripts/repl-study/RESULT-focus.md | 145 ++- scripts/repl-study/capture.ts | 13 +- scripts/repl-study/drive.ts | 78 ++ scripts/repl-study/focus.ts | 368 ------ scripts/repl-study/frames.ts | 41 +- scripts/repl-study/host.ts | 18 +- scripts/repl-study/keys.ts | 79 ++ scripts/repl-study/mutations.ts | 8 +- scripts/repl-study/render.ts | 14 +- scripts/repl-study/store.ts | 226 ++-- scripts/repl-study/surfaces.ts | 73 ++ scripts/repl-study/tree.ts | 407 +++++++ scripts/repl-study/vendor/freedom/LICENSE | 21 + .../repl-study/vendor/freedom/MANIFEST.json | 45 + .../repl-study/vendor/freedom/PROVENANCE.md | 74 ++ .../vendor/freedom/upstream/index.ts | 1 + .../vendor/freedom/upstream/lib/dispatch.ts | 24 + .../vendor/freedom/upstream/lib/focus.ts | 228 ++++ .../vendor/freedom/upstream/lib/mod.ts | 25 + .../vendor/freedom/upstream/lib/node.ts | 198 ++++ .../vendor/freedom/upstream/lib/root.ts | 84 ++ .../vendor/freedom/upstream/lib/state.ts | 12 + .../vendor/freedom/upstream/lib/types.ts | 54 + .../vendor/freedom/upstream/lib/validate.ts | 56 + .../fixtures/repl-focus/frame-01.narrow.txt | 2 +- .../fixtures/repl-focus/frame-01.wide.txt | 2 +- .../fixtures/repl-focus/frame-02.wide.txt | 2 +- .../fixtures/repl-focus/frame-03.wide.txt | 2 +- .../fixtures/repl-focus/frame-04.wide.txt | 2 +- .../fixtures/repl-focus/frame-05.narrow.txt | 4 +- .../fixtures/repl-focus/frame-05.wide.txt | 4 +- .../fixtures/repl-focus/frame-06.wide.txt | 4 +- .../fixtures/repl-focus/frame-07.wide.txt | 2 +- .../fixtures/repl-focus/frame-08.wide.txt | 2 +- .../fixtures/repl-focus/frame-09.wide.txt | 2 +- .../fixtures/repl-focus/frame-10.wide.txt | 4 +- .../fixtures/repl-focus/frame-11.wide.txt | 4 +- .../fixtures/repl-focus/frame-12.narrow.txt | 8 +- .../fixtures/repl-focus/frame-12.wide.txt | 8 +- .../fixtures/repl-focus/frame-13.wide.txt | 4 +- .../fixtures/repl-focus/frame-14.narrow.txt | 2 +- .../fixtures/repl-focus/frame-14.wide.txt | 2 +- scripts/tests/repl-focus.test.ts | 1020 ++++++++--------- 46 files changed, 2292 insertions(+), 1114 deletions(-) create mode 100644 scripts/repl-study/drive.ts delete mode 100644 scripts/repl-study/focus.ts create mode 100644 scripts/repl-study/keys.ts create mode 100644 scripts/repl-study/surfaces.ts create mode 100644 scripts/repl-study/tree.ts create mode 100644 scripts/repl-study/vendor/freedom/LICENSE create mode 100644 scripts/repl-study/vendor/freedom/MANIFEST.json create mode 100644 scripts/repl-study/vendor/freedom/PROVENANCE.md create mode 100644 scripts/repl-study/vendor/freedom/upstream/index.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/dispatch.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/focus.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/mod.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/node.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/root.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/state.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/types.ts create mode 100644 scripts/repl-study/vendor/freedom/upstream/lib/validate.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 5dd0b48a6..ff15febc9 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -4,11 +4,14 @@ // the exact indentation the rule tests assert on. // `packages/*/npm` is generated dnt output, written by a test while the // battery runs; format-checking it fails on a file nobody wrote by hand. + // The vendored snapshots are upstream's bytes: a drift verifier holds each to + // the manifest that records it, and reformatting one would break that. "ignorePatterns": [ "**/*.md", "scripts/tests/fixtures/**", "**/npm/**", "packages/workflow/vendor/cloudflare-computer-dofs/**", - "packages/acp/vendor/acpx/**" + "packages/acp/vendor/acpx/**", + "scripts/repl-study/vendor/freedom/**" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index e92294a55..400d1a50d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -3,6 +3,8 @@ "extends": ["./oxlint.shared.json"], + "ignorePatterns": ["scripts/repl-study/vendor/freedom/**"], + "plugins": ["typescript", "unicorn", "import"], "jsPlugins": [ diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index ec028d241..e63129c15 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -73,13 +73,20 @@ Keys, while it is running: | `q` | leave, restoring the terminal | | `Ctrl+C` | interrupt the entry if one is running, paused or reconstructed; else clear the draft; else leave | -**The ring is five regions with each region's own controls inlined after it** — -Sessions, Transcript, Bindings, REPL input, Execution History — and it wraps. -The numbers the overlay draws are assigned separately: regions take 1–5 and -controls take 6 upward, which is why `Run` is numbered after the footer and -traversed before it. While a drawer is open the ring is the drawer's own +**Focus belongs to the tree.** The interface is a Freedom node tree — a surface +is a node, a scope panel is a branch inside it, a drawer is a branch pushed as +the active focus root, a control is a leaf — and traversal order is tree order, +worked out on demand. A key is delivered to the focused node, so every branch +between the root and it runs its middleware. Closing a drawer removes its +branch, and its controls and their middleware go with it. + +The numbers the overlay draws are assigned separately from traversal: regions +take 1–5 and controls 6 upward, which is why `Run` is numbered after the footer +and traversed before it. While a drawer is open the ring is the drawer's own controls and the Execution History region, and nothing else: the footer is -inside the trap deliberately, because it is the one way out of it. +mounted inside the pushed branch deliberately, because it is the one way out of +it. A control that is visible but disabled is a node that was never made +focusable — drawn, numbered, and unreachable by Tab. **`Esc` is Back.** It closes the top drawer, restoring whatever opened it; then leaves a reconstruction for the paused head; then returns from a control to the @@ -164,7 +171,11 @@ makes a capture legible as evidence against the frame it reproduces. | `playback.ts` | the journey, the path between two fixtures, and the motion at one instant of it | | `fixtures.ts` | the six moments and the three drawers, from the study's own content | | `route.ts` | the URL schema, parsing, formatting, and push versus replace | -| `focus.ts` | targets, the map, the registry, traversal, resolution and counterparts | +| `vendor/freedom/` | `@bomb.sh/freedom`, vendored and pinned — the node tree that owns focus | +| `tree.ts` | the interface as Freedom nodes: surfaces, panels, drawers, controls | +| `keys.ts` | a key delivered to the focused node, through its ancestors' middleware | +| `drive.ts` | one event, carried through the tree and the store — the harness and the evidence share it | +| `surfaces.ts` | which controls a drawer carries, and in what order | | `journal.ts` | the hand-authored journal fixture, and the fold that reconstructs a moment from it | | `store.ts` | `ReplState`, its reducer, `hydrate()` and `projection()` | | `frames.ts` | the focus study's fourteen frames, as addressable states | @@ -173,7 +184,7 @@ makes a capture legible as evidence against the frame it reproduces. | `screen.ts` | a terminal's cells, reconstructed from the bytes, so a frame can be read back | | `host.ts` | the only module that touches the terminal: modes, raw input, signals, restoration | | `capture.ts` | one frame, away from a terminal, in bytes and in cells | -| `mutations.ts` | the twenty-three ways the evidence breaks this on purpose | +| `mutations.ts` | the twenty-six ways the evidence breaks this on purpose | | `main.ts` | the documented command | `--replay` runs the same lifecycle with no terminal attached, writing its byte diff --git a/scripts/repl-study/RESULT-focus.md b/scripts/repl-study/RESULT-focus.md index 285a42945..b80cfbfde 100644 --- a/scripts/repl-study/RESULT-focus.md +++ b/scripts/repl-study/RESULT-focus.md @@ -6,8 +6,8 @@ historical inspection and the loss of its in-memory store — and whether focus can be derived rather than remembered, so that background work never moves somebody somewhere else. -**Decision: retain the model, and adopt three things it settled.** One URL -carries location. One registry, rebuilt every frame, carries focus. Between them +**Decision: retain the model.** One URL carries location. Freedom's node tree +carries focus, traversal, input targeting and branch lifetime. Between them they answer all fourteen frames of the Product Owner's approved focus study, forward and in reverse, at the wide and the narrow profile, and a state built by a long interaction rebuilds from its URL and a journal alone. Two defects were @@ -95,47 +95,88 @@ reads as `history` and focusing `Run` reads as `input`. ## What focus turned out to be -Focus is never a coordinate and never an index. It is a semantic identity — -`region:transcript`, `control:transport.pause`, `field:drawer.project.name` — and -every frame a registry is derived from the route, the journal and the layout. -Asking where focus is means resolving one identity against the registry that -exists *now*. - -That single mechanism answers two of the issue's criteria at once. A background -update cannot steal focus, because nothing writes focus when one arrives — the -suite asserts **reference** equality of the route, the focus, the selection and -the anchor across a background event, since a reducer that rebuilt an equal -route would pass a deep comparison having already lost the property. And a route -transition restores a stable identity or the nearest surviving owner, because a -vanished identity is resolved by walking its owner chain rather than by anybody -remembering to move anything. - -Four things the fourteen frames settled: - -- **Two lists, not one.** The **registry** is visible ∧ enabled and is what Tab - walks. The **map** is visible whether enabled or not and is what the overlay - numbers. Frame 12 numbers a dimmed `Continue` as target 6 and says Tab skips - it, so a disabled control is in one list and never in the other. Conflating - them is the trap, and `focus-hidden-target` is the control that does. -- **Traversal order is not numbering order.** The ring is the five regions with - each region's own controls inlined immediately after it; numbering assigns 1–5 - to the regions and 6 upward to the controls. Frames 05, 11 and 14 only agree - with each other under that reading — frame 14 numbers `Run` as 6 and traverses - it *before* region 5, because `Run` belongs to the input. -- **Ownership is read from the identity.** Resolution has to answer "who owns - this?" for a target that is already gone, so it cannot be a lookup in the - registry that no longer contains it. The naming scheme is the ownership. -- **A transport control declares a counterpart.** Frame 13 requires that leaving - history with `Continue` focused lands on `Pause`, so a declared counterpart is - preferred over the owner walk. Nothing else needed one. - -**The drawer's trap is the drawer's controls and the Execution History region, -and nothing else.** The footer is inside the trap deliberately — the study calls -it "the one way out" — which is how #827's "keeps the fixed history footer -reachable" survives a suspension. Opening a drawer records the identity that -invoked it; closing it restores that identity through the same resolution walk, -so a drawer whose invoking scope no longer exists falls back rather than -dangling. +**Freedom's node tree owns it.** The tree replaces the DOM in the terminal: a +surface is a node, a scope panel mounted inside it is a branch, a drawer is a +branch pushed as the active focus root, and a control is a leaf. Traversal order +is tree order, computed on demand from the active subtree. There is no registry, +no ordered list, no owner strings and no identifier parsing. + +This experiment's first attempt did keep such a registry — a flat +`FocusTarget[]` with hand-written traversal order, a hand-written owner chain +and hand-written restoration. It passed every case written against it, because a +list compared with itself always agrees. What it could not do was answer a +question about where a control actually *is*, and three of this slice's cases +are exactly those questions. + +**Input goes to the focused node, not to the application.** A key is invoked on +`current(root).scope`, so Effection walks that scope's ancestors and every +branch between the root and the control runs its middleware in order. The +evidence reads the path rather than inferring it: + +```text +target field:drawer.project.name +path drawer:project → panel:project.body +``` + +A flat registry has no way to produce that: the path is the tree's. + +**Closing a branch destroys it.** A drawer closes by removing its node; its +controls, its body panel and their middleware go with it through structured +teardown. Afterwards nothing in the tree can be focused, and no dispatch reaches +what used to be there. There is no second list to update, because there is no +second list. + +**A drawer is a pushed focus root.** `focusPush()` traps cycling inside the +branch and remembers what to restore; nested drawers nest, and popping restores +first to the outer drawer and finally to the invoking control. The footer is +mounted *inside* the pushed branch deliberately — the study calls it "the one +way out" — which is how #827's "keeps the fixed history footer reachable" +survives a suspension. + +**A disabled control is a node that was never made focusable.** It is mounted, +the renderer draws it and the `F1` map numbers it; it simply carries no +`focused` prop, so it cannot enter the chain. That is Freedom's own distinction +rather than one this harness invents, and it is what study frame 12 means by +numbering a dimmed `Continue` and saying Tab skips it. + +**The overlay is the tree, walked.** Numbering is assigned as the study assigns +it — regions first, then controls — but the list it numbers is the live tree, +which is why the overlay follows focus into a drawer instead of going on +numbering the panes behind it. + +**Focus is not in the application model at all.** `ReplState` has no focus +field. The store decides what an event *means* and names what should happen to +focus; the tree carries it out, because the tree is the thing that knows what +exists. That is the strongest form the "background updates never steal focus" +claim can take: the honest path does not write focus, and the control that +breaks it has to reach past the store into the tree. + +The URL still records the **surface**, because the surface segment is what says +which region owns focus — so a focus move that crosses a region boundary is a +move the route makes in the same transition. What the URL never records is the +focus identity. + +## Two gaps found in Freedom, and what was done about them + +`@bomb.sh/freedom` is private and unpublished, so its source is vendored here +from the public playground repository, pinned and manifested. Two patches are +recorded against it; `vendor/freedom/PROVENANCE.md` has the detail. + +1. **A root was parented to Effection `global`.** `createRoot()` alone means + host context does not reach the tree and a failure in node work raises into + a boundary nobody observes. `useRoot()` acquires the tree as a resource owned + by the acquiring scope. +2. **Removal asked about identity, not containment.** `useFocus()`'s middleware + moved focus to a successor when the *removed node* was the focused one — but + a drawer or panel is closed by removing the branch *above* the focused + control, so the common case left focus on a node that had just been + destroyed while a perfectly good sibling survived. + +A third thing is this harness's own, and worth stating because it is the same +mistake in miniature: **a reconciler must add before it removes.** Removing the +focused control before its replacement exists leaves the region with nothing to +move focus to, and focus lands outside it — which is how a resumed run first +lost its transport slot. ## Divergences from the study, named rather than hidden @@ -221,13 +262,19 @@ lifecycle to whoever closed the terminal. ## What the evidence rests on -Twenty-three controls, thirteen of them new, each breaking exactly one claim and -each rejected by name by the same oracle that admits the honest run. The two +Twenty-six controls, each breaking exactly one claim and each rejected by name +by the same oracle that admits the honest run. Four of them exist only because +the tree does: `rebuild-tree-each-sync` destroys focus by rebuilding rather than +reconciling, `keep-closed-branch` closes a drawer without removing it, +`flat-overlay` numbers a list kept beside the interface instead of the tree, and +`focus-hidden-target` makes a disabled control focusable. The two that matter most are the two that reproduce the decoder defects, because they are the only reason to believe the byte-driven cases would notice if the repair were undone. -**Transitions are driven through the reducer**, forward and in reverse, from -each of the fourteen frames. An earlier round proved them only by constructing -each destination from its own URL, which is a check a reducer that moved focus -and left the route behind passes without trouble — and did. +**Transitions are driven through the real path**, forward and in reverse, from +each of the fourteen frames — the same `drive()` the interactive harness uses, +so a case cannot prove a path the running harness does not take. An earlier +round proved them by constructing each destination from its own URL, which is a +check a reducer that moved focus and left the route behind passes without +trouble — and did. diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 3f04d91a9..29dd3fb9b 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -25,8 +25,9 @@ import type { FocusView } from "./render.ts"; import { applyAnsi, createGrid, gridText } from "./screen.ts"; import { initialView } from "./store.ts"; import type { View } from "./store.ts"; -import { focusIn, mapOf, fixtureFor, viewOf } from "./store.ts"; -import { FRAMES, stateFor } from "./frames.ts"; +import { fixtureFor, viewOf } from "./store.ts"; +import { FRAMES, useFrame } from "./frames.ts"; +import { overlayOf } from "./tree.ts"; import type { Mutation } from "./mutations.ts"; export interface Size { @@ -377,7 +378,7 @@ const NARROW_FRAMES = ["01", "05", "07", "12", "14"]; export function* captureFocus(): Operation { const captures: Capture[] = []; for (const subject of FRAMES) { - const state = stateFor(subject); + const { state, tree } = yield* useFrame(subject); const profiles: Profile[] = NARROW_FRAMES.includes(subject.id) ? ["wide", "narrow"] : ["wide"]; for (const profile of profiles) { const size = PROFILE_SIZES[profile]; @@ -385,11 +386,7 @@ export function* captureFocus(): Operation { fixture: fixtureFor(state), view: viewOf(state), size, - focus: { - here: focusIn(state, size), - map: mapOf(state, size), - overlay: true, - }, + focus: { here: tree.focused().name, map: overlayOf(tree), overlay: true }, }); captures.push({ name: `frame-${subject.id}.${profile}`, profile, size, frame }); } diff --git a/scripts/repl-study/drive.ts b/scripts/repl-study/drive.ts new file mode 100644 index 000000000..02bbbe98f --- /dev/null +++ b/scripts/repl-study/drive.ts @@ -0,0 +1,78 @@ +/** + * One event, carried through the tree and the store. + * + * The harness and the evidence both go through here, so a case cannot prove a + * path the interactive run does not take. The order is the whole of the + * architecture, in five lines: + * + * 1. the key is delivered to the focused node, so every branch between the root + * and it runs its middleware; + * 2. the store decides what the event means, told where focus is rather than + * keeping its own answer; + * 3. the tree carries out whatever the store decided about focus; + * 4. the tree is brought into line with the new state, mounting and removing + * branches; + * 5. the route follows focus, because the surface segment is what says which + * region owns it. + */ + +import type { Operation } from "effection"; + +import { asKey, followFocus, reduce } from "./store.ts"; +import type { HarnessEvent, ReduceContext, ReplState } from "./store.ts"; +import type { FocusIntent } from "./store.ts"; +import { focus as focusNode } from "./tree.ts"; +import type { ReplTree } from "./tree.ts"; +import { sendKey } from "./keys.ts"; +import type { Delivery } from "./keys.ts"; +import type { Mutation } from "./mutations.ts"; + +/** Carry out what the store decided about focus. The tree performs it. */ +export function applyFocus(tree: ReplTree, intent: FocusIntent | undefined): void { + if (intent === undefined) { + return; + } + if (intent.kind === "advance") { + tree.advance(); + return; + } + if (intent.kind === "retreat") { + tree.retreat(); + return; + } + const wanted = intent.kind === "to" ? intent.identity : undefined; + const target = tree.chain().find((node) => node.name === wanted); + if (target) { + focusNode(target); + } +} + +export interface Driven { + readonly state: ReplState; + /** Where the key went, and through what. Absent for a non-key event. */ + readonly delivery?: Delivery; +} + +export function drive( + tree: ReplTree, + state: ReplState, + event: HarnessEvent, + context: Omit, +): Operation { + return { + *[Symbol.iterator]() { + const delivery = + event.kind === "key" + ? sendKey(tree.root.node, tree.focused(), asKey(event.event)) + : undefined; + const reduced = reduce(state, event, { ...context, focused: tree.focused().name }); + applyFocus(tree, reduced.focus); + yield* tree.sync(reduced.state, context.mutation); + const followed = followFocus(reduced.state, tree.focused().name, "focus", context.mutation); + yield* tree.sync(followed, context.mutation); + return { state: followed, delivery }; + }, + }; +} + +export type { Mutation }; diff --git a/scripts/repl-study/focus.ts b/scripts/repl-study/focus.ts deleted file mode 100644 index 9541f99a1..000000000 --- a/scripts/repl-study/focus.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * Where focus is, derived rather than remembered. - * - * Focus is never a coordinate and never an index into a list that was true last - * frame. It is a semantic identity — `region:transcript`, `control:transport.pause`, - * `field:drawer.project.name` — and every frame a registry of the identities - * that exist *now* is built from the route, the journal and the layout. Asking - * where focus is means resolving one identity against that registry. - * - * That is the whole answer to two of #839's acceptance criteria at once. A - * background update cannot steal focus, because nothing ever writes focus when - * one arrives. And a route transition restores a stable identity or the nearest - * surviving owner, because a vanished identity is resolved by walking its owner - * chain rather than by remembering to move anything. - * - * Two lists, and conflating them is the trap. The **registry** is visible and - * enabled, and is what Tab moves through. The **map** is visible whether enabled - * or not, and is what the `F1` overlay numbers — study frame 12 numbers a dimmed - * `Continue` and says Tab skips it. - */ - -import type { Layout } from "./layout.ts"; -import type { Mutation } from "./mutations.ts"; -import type { ReplState } from "./store.ts"; -import { DRAWER_KINDS } from "./fixtures.ts"; -import type { DrawerKind } from "./fixtures.ts"; - -export interface FocusTarget { - readonly id: string; - readonly kind: "region" | "control" | "field"; - readonly label: string; - /** The identity resolution walks to when this one disappears. */ - readonly owner?: string; - readonly enabled: boolean; -} - -/** The five regions, in the study's forward order. */ -export const REGIONS = ["sessions", "transcript", "bindings", "input", "history"] as const; - -/** - * Who owns an identity, read from the identity itself. - * - * Resolution has to answer this for a target that is already gone, so it cannot - * be a lookup in the registry that no longer contains it. The naming scheme is - * the ownership, which is why identities are structured rather than opaque. - */ -export function ownerOf(identity: string): string | undefined { - if (identity.startsWith("region:")) { - return undefined; - } - if (identity.startsWith("control:input.")) { - return "region:input"; - } - if (identity.startsWith("control:transport.")) { - return "region:history"; - } - if (identity.startsWith("control:drawer.") || identity.startsWith("field:drawer.")) { - return "region:transcript"; - } - return undefined; -} - -/** - * The live counterpart of a transport control. - * - * Study frame 13 requires that leaving history with `Continue` focused lands on - * `Pause` — the control that undoes what the focused one did — rather than on - * the region that owned it. A counterpart is preferred over the owner walk. - */ -export function counterpartOf(identity: string): string | undefined { - if (identity === "control:transport.continue") { - return "control:transport.pause"; - } - if (identity === "control:transport.pause") { - return "control:transport.continue"; - } - return undefined; -} - -interface DrawerTarget { - readonly id: string; - readonly kind: "control" | "field"; - readonly label: string; -} - -/** Each drawer's own sequence, taken from study frames 07, 08 and 09. */ -const DRAWER_TARGETS: Record = { - project: [ - { id: "field:drawer.project.name", kind: "field", label: "Project name" }, - { id: "field:drawer.project.description", kind: "field", label: "Description" }, - { id: "control:drawer.project.schema", kind: "control", label: "Schema disclosure · ⌥S" }, - { id: "control:drawer.project.submit", kind: "control", label: "Submit" }, - ], - review: [ - { id: "control:drawer.review.scroll", kind: "control", label: "Plan review · scroll region" }, - { id: "control:drawer.review.approve", kind: "control", label: "Approve" }, - { id: "control:drawer.review.request", kind: "control", label: "Request changes" }, - { id: "control:drawer.review.stop", kind: "control", label: "Stop" }, - { id: "control:drawer.review.submit", kind: "control", label: "Submit" }, - ], - confirm: [ - { - id: "control:drawer.confirm.preview", - kind: "control", - label: "README preview · scroll region", - }, - { id: "control:drawer.confirm.approve", kind: "control", label: "Approve" }, - { id: "control:drawer.confirm.decline", kind: "control", label: "Decline" }, - ], -}; - -export function drawerTargets(kind: DrawerKind): readonly DrawerTarget[] { - return DRAWER_TARGETS[kind]; -} - -/** True while the identity is the history region itself or one of its controls. */ -function withinHistory(identity: string): boolean { - return identity === "region:history" || identity.startsWith("control:transport."); -} - -function regionLabel(state: ReplState, region: (typeof REGIONS)[number]): string { - if (region === "sessions") { - const journal = state.selection >= 0; - return journal ? "Journal · checkpoint list" : `Sessions · ${state.moment.sessions}`; - } - if (region === "transcript") { - return state.route.inspect ? "Transcript · read-only" : "Transcript"; - } - if (region === "bindings") { - return "Bindings"; - } - if (region === "input") { - return state.moment.entry === "running" ? "REPL input · Run disabled" : "REPL input"; - } - return "Execution History"; -} - -/** - * The transport controls the footer is exposing. - * - * `history` is an **explicit** region: its controls join the sequence only once - * the region has been entered, which is what study frame 03 shows by focusing - * region 5 and listing no controls at all. Being entered is being focused - * within it. There is nothing to expose before an execution has been recorded, - * so an empty REPL has no transport however focus got there. - */ -function transportTargets(state: ReplState): FocusTarget[] { - if (state.moment.entry === "none" || !withinHistory(state.focus)) { - return []; - } - const owner = "region:history"; - if (state.moment.transport === "live") { - return [ - { id: "control:transport.pause", kind: "control", label: "Pause", owner, enabled: true }, - ]; - } - if (state.moment.transport === "paused") { - return [ - { - id: "control:transport.continue", - kind: "control", - label: "Continue", - owner, - enabled: true, - }, - { - id: "control:transport.return-head", - kind: "control", - label: "Return to paused head", - owner, - enabled: true, - }, - ]; - } - if (state.moment.transport === "inspecting") { - return [ - // Visible, dimmed, and numbered — but skipped by Tab. Resuming from a - // reconstruction is not a thing this state can do. - { - id: "control:transport.continue", - kind: "control", - label: "Continue · disabled while inspecting", - owner, - enabled: false, - }, - { - id: "control:transport.return-head", - kind: "control", - label: "Return to paused head", - owner, - enabled: true, - }, - { - id: "control:transport.fork", - kind: "control", - label: "Fork from here", - owner, - enabled: true, - }, - ]; - } - return []; -} - -/** - * Every visible target, in traversal order. - * - * The ring is the five regions with **each region's own controls inlined - * immediately after it**, which is why the study's numbers are not the Tab - * order: numbering assigns 1–5 to the regions and 6 upward to the controls, - * while traversal visits `Run` between the input and the footer. Study frames - * 05, 11 and 14 only agree with each other under that reading. - * - * While a drawer is open the sequence is the top drawer's own controls and the - * Execution History region, and nothing else. The footer is inside the trap - * deliberately: the study calls it "the one way out", and it is how the fixed - * history footer stays reachable through a suspension. - */ -export function focusMap( - state: ReplState, - layout: Layout, - mutation?: Mutation, -): readonly FocusTarget[] { - if (layout.profile === "too-small") { - return []; - } - const top = state.route.drawers[state.route.drawers.length - 1]; - const trapped = top !== undefined && (DRAWER_KINDS as readonly string[]).includes(top); - if (trapped && mutation !== "leak-drawer-trap") { - const kind = DRAWER_KINDS.find((one) => one === top)!; - return [ - ...DRAWER_TARGETS[kind].map((target) => ({ - ...target, - owner: ownerOf(target.id), - enabled: true, - })), - { - id: "region:history", - kind: "region" as const, - label: "Execution History · still reachable", - enabled: true, - }, - ]; - } - - const targets: FocusTarget[] = []; - for (const region of REGIONS) { - targets.push({ - id: `region:${region}`, - kind: "region", - label: regionLabel(state, region), - enabled: true, - }); - if (region === "input") { - // `input` is an **adjacent** region: Run is listed whenever it is enabled, - // with no Enter required. It is enabled when there is something to run and - // nothing already running, which is why the empty REPL of frames 01–04 - // numbers five targets and the settled entry of frame 14 numbers six. - const runnable = state.moment.entry !== "running" && state.route.draft !== ""; - if (runnable) { - targets.push({ - id: "control:input.run", - kind: "control", - label: "Run", - owner: "region:input", - enabled: true, - }); - } - } - if (region === "history") { - targets.push(...transportTargets(state)); - } - } - return targets; -} - -/** The map, less every target Tab is not allowed to land on. */ -export function registry( - state: ReplState, - layout: Layout, - mutation?: Mutation, -): readonly FocusTarget[] { - const map = focusMap(state, layout, mutation); - // The control admits a target the overlay shows but the ring excludes, which - // is the one distinction between the two lists. - return mutation === "focus-hidden-target" ? map : map.filter((target) => target.enabled); -} - -/** - * The number the `F1` overlay writes beside a target. - * - * Regions take 1–5 and controls take 6 upward, which is assigned separately - * from traversal order. Inside a drawer the trap is numbered straight through, - * as frames 07, 08 and 09 do. - */ -export function numbering(targets: readonly FocusTarget[]): Map { - const numbers = new Map(); - const regions = targets.filter((target) => target.kind === "region"); - const rest = targets.filter((target) => target.kind !== "region"); - if (regions.length !== REGIONS.length) { - targets.forEach((target, index) => numbers.set(target.id, index + 1)); - return numbers; - } - regions.forEach((target, index) => numbers.set(target.id, index + 1)); - rest.forEach((target, index) => numbers.set(target.id, regions.length + index + 1)); - return numbers; -} - -/** - * The identity focus actually lands on. - * - * An identity that is present and enabled is returned unchanged. Otherwise a - * declared counterpart is preferred, then the owner chain is walked upward to - * the nearest surviving enabled target, and an exhausted chain falls back to the - * first target in the ring. Nothing has to remember to move focus, because this - * is asked fresh every frame. - */ -export function resolve(identity: string, targets: readonly FocusTarget[]): string { - const alive = (id: string): boolean => - targets.some((target) => target.id === id && target.enabled); - if (alive(identity)) { - return identity; - } - const counterpart = counterpartOf(identity); - if (counterpart !== undefined && alive(counterpart)) { - return counterpart; - } - let owner = ownerOf(identity); - const seen = new Set([identity]); - while (owner !== undefined && !seen.has(owner)) { - if (alive(owner)) { - return owner; - } - seen.add(owner); - owner = ownerOf(owner); - } - return targets.find((target) => target.enabled)?.id ?? ""; -} - -/** One step around the ring, forward or in reverse, wrapping at both ends. */ -export function step(identity: string, targets: readonly FocusTarget[], delta: 1 | -1): string { - const enabled = targets.filter((target) => target.enabled); - if (enabled.length === 0) { - return ""; - } - const from = resolve(identity, targets); - const at = Math.max( - 0, - enabled.findIndex((target) => target.id === from), - ); - const next = (at + delta + enabled.length) % enabled.length; - return enabled[next].id; -} - -/** - * The map in the order the overlay numbers it. - * - * Traversal order and numbering order are not the same list, which is the whole - * reason the study's numbers look out of sequence: `Run` is numbered after the - * Execution History region and traversed before it, because it belongs to the - * input. The ring is what `focusMap` returns; this is what `F1` draws. - */ -export function mapOrder(targets: readonly FocusTarget[]): readonly FocusTarget[] { - const numbers = numbering(targets); - return [...targets].toSorted( - (one, other) => (numbers.get(one.id) ?? 0) - (numbers.get(other.id) ?? 0), - ); -} diff --git a/scripts/repl-study/frames.ts b/scripts/repl-study/frames.ts index 09f528398..0c9ae92ca 100644 --- a/scripts/repl-study/frames.ts +++ b/scripts/repl-study/frames.ts @@ -17,6 +17,9 @@ import { journalThrough } from "./journal.ts"; import type { FixtureName } from "./model.ts"; import { hydrate } from "./store.ts"; import type { ReplState } from "./store.ts"; +import { focus as focusNode, useReplTree } from "./tree.ts"; +import type { ReplTree } from "./tree.ts"; +import type { Operation } from "effection"; export interface StudyTarget { /** The number the study's overlay writes beside this target. */ @@ -342,5 +345,41 @@ export function frame(id: string): StudyFrame | undefined { */ export function stateFor(subject: StudyFrame): ReplState { const state = hydrate(subject.url, journalThrough(subject.head)); - return { ...state, focus: subject.focus, overlay: subject.overlay }; + return { ...state, overlay: subject.overlay }; +} + +export interface Frame { + readonly state: ReplState; + readonly tree: ReplTree; +} + +/** + * One frame, as a state and the tree that renders it. + * + * The URL and the journal rebuild the state; the tree is then built from it and + * the frame's declared focus placed on the node that carries it. Focus is not + * in the URL and never was — placing it here is what the person did before the + * frame was taken, and the evidence's job is to check that the node the study + * names is one the tree actually offers. + */ +export function useFrame(subject: StudyFrame): Operation { + return { + *[Symbol.iterator]() { + const state = stateFor(subject); + const tree = yield* useReplTree(state); + // Entering the region comes first, because the footer is explicit: its + // controls exist only once focus is inside it. The route's surface is + // what says which region that is — the same invariant the URL records. + const region = tree.chain().find((node) => node.name === `region:${state.route.surface}`); + if (region) { + focusNode(region); + } + yield* tree.sync(state); + const target = tree.chain().find((node) => node.name === subject.focus); + if (target) { + focusNode(target); + } + return { state, tree }; + }, + }; } diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index f5d9a2fc4..e7cb6afb6 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -24,7 +24,9 @@ import { SURFACES } from "./layout.ts"; import { transcriptLines } from "./render.ts"; import type { FocusView } from "./render.ts"; import { initialView } from "./store.ts"; -import { asKey, fixtureFor, focusIn, hydrate, mapOf, reduce, viewOf } from "./store.ts"; +import { asKey, fixtureFor, hydrate, reduce, viewOf } from "./store.ts"; +import { overlayOf, useReplTree } from "./tree.ts"; +import { drive } from "./drive.ts"; import type { HarnessEvent, ReplState, View } from "./store.ts"; import { journalThrough, markerShowing } from "./journal.ts"; import { formatRoute } from "./route.ts"; @@ -348,6 +350,10 @@ export function* runInteractive(options: InteractiveOptions): Operation { quit: false, }; + // The tree is acquired before the terminal is touched, so its teardown runs + // after the terminal has been given back rather than into a restored one. + const tree = yield* useReplTree(repl); + let term = yield* useTerm({ cols: state.cols, rows: state.rows }); const input: Input = yield* until(createInput({})); @@ -491,8 +497,8 @@ export function* runInteractive(options: InteractiveOptions): Operation { if (!repeated) { const measured = { cols: state.cols, rows: state.rows }; const focus: FocusView = { - here: focusIn(repl, measured, options.mutation), - map: mapOf(repl, measured, options.mutation), + here: tree.focused().name, + map: overlayOf(tree), overlay: repl.overlay, }; let painted: Painted; @@ -610,11 +616,12 @@ export function* runInteractive(options: InteractiveOptions): Operation { if (options.mutation !== "skip-resize-update") { const measured = { cols: next.value.cols, rows: next.value.rows }; state = { ...state, cols: measured.cols, rows: measured.rows }; - repl = reduce(repl, next.value, { + const resized = yield* drive(tree, repl, next.value, { size: measured, mutation: options.mutation, scrollLimit: 0, }); + repl = resized.state; if (journey === undefined && playback === undefined) { follow(); } @@ -623,11 +630,12 @@ export function* runInteractive(options: InteractiveOptions): Operation { const lines = state.fixture.entry ? transcriptLines(state.fixture.entry, Math.max(1, state.cols - 2)).length : 0; - repl = reduce(repl, next.value, { + const driven = yield* drive(tree, repl, next.value, { size: { cols: state.cols, rows: state.rows }, mutation: options.mutation, scrollLimit: Math.max(0, lines - Math.max(1, state.rows - 8)), }); + repl = driven.state; if (journey === undefined && playback === undefined) { follow(); } else { diff --git a/scripts/repl-study/keys.ts b/scripts/repl-study/keys.ts new file mode 100644 index 000000000..be2b828e2 --- /dev/null +++ b/scripts/repl-study/keys.ts @@ -0,0 +1,79 @@ +/** + * A keystroke goes to the node that has focus, not to the application. + * + * The first attempt reduced every key globally: one `reduce()` read the key, + * looked focus up in a flat list, and decided. Nothing about where the focused + * control actually *was* could take part in that decision, so a drawer could + * not intercept a key for its own controls without the global reducer being + * taught about drawers. + * + * Here the key is invoked on the focused node's scope. Effection walks that + * scope's ancestors, so every branch between the root and the control — the + * drawer, the panel, the surface — gets its middleware run in order, and any of + * them can handle the key or pass it on. The path is the tree's, and it is + * recorded so the evidence can read it rather than infer it. + * + * `packages/input/src/lib/input.ts` at the pinned Bombshell commit is the + * reference this follows. + */ + +import { createContext } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; + +import type { Key } from "./store.ts"; + +/** The branches a dispatch passed through, innermost last. */ +const PathContext = createContext("xmd:repl:key-path"); + +/** + * One keystroke, delivered to a node. + * + * Middleware installed on a branch's scope sees every key bound for anything + * inside it, which is what makes a drawer able to answer for its own controls. + */ +export const KeyboardApi = createApi("xmd:repl:keyboard", { + keydown(node: Node, key: Key): void { + // The default: nothing between the root and the node claimed it. + void node; + void key; + }, +}); + +/** + * Record this branch on the path of every key that passes through it. + * + * Installed by `tree.ts` when a branch is mounted, and gone when the branch is + * removed — which is the whole of why a closed panel cannot receive input. + */ +export function recordPath(node: Node, name: string): void { + node.scope.around(KeyboardApi, { + keydown([target, key], next): void { + node.scope.get(PathContext)?.push(name); + return next(target, key); + }, + }); +} + +export interface Delivery { + /** The node the key was delivered to. */ + readonly target: string; + /** The branches it passed through, outermost first. */ + readonly path: readonly string[]; +} + +/** + * Send one key to whichever node has focus. + * + * The path is collected on the root's scope rather than returned by the + * middleware, because a middleware that had to return it could not also pass + * the key on unchanged. + */ +export function sendKey(root: Node, focused: Node, key: Key): Delivery { + const path: string[] = []; + root.scope.set(PathContext, path); + KeyboardApi.invoke(focused.scope, "keydown", [focused, key]); + // Ancestors run outermost first, so the recorded order is already the path + // from the root down to the node. + return { target: focused.name, path }; +} diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index 46a55c1f6..cbacfa244 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -30,9 +30,15 @@ export const MUTATIONS = [ "restore-mid-animation", /** Move focus when a background update arrives. */ "steal-focus-on-background", + /** Rebuild the whole tree on every sync instead of reconciling it. */ + "rebuild-tree-each-sync", + /** Close a drawer without removing its branch, so its controls survive. */ + "keep-closed-branch", + /** Number the overlay from a static list instead of walking the tree. */ + "flat-overlay", /** Let Tab escape an open drawer into the panes behind it. */ "leak-drawer-trap", - /** Admit a target the map shows but the registry excludes. */ + /** Make a visible-but-disabled control focusable. */ "focus-hidden-target", /** Push a navigation entry for every keystroke in the draft. */ "push-draft-edits", diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 32a2deca1..31b6b652c 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -20,8 +20,7 @@ import type { Layout, Rect } from "./layout.ts"; import { MINIMUM } from "./layout.ts"; import type { View } from "./store.ts"; import type { Mutation } from "./mutations.ts"; -import type { FocusTarget } from "./focus.ts"; -import { mapOrder, numbering } from "./focus.ts"; +import type { OverlayEntry } from "./tree.ts"; import type { Motion } from "./playback.ts"; const C = { @@ -253,10 +252,10 @@ const DRAWER_TRANSITION = { * second opinion about where focus is may be formed. */ export interface FocusView { - /** The identity focus resolved to. */ + /** The name of the node the tree reports as focused. */ readonly here: string; - /** Every visible target, enabled or not, which is what the overlay numbers. */ - readonly map: readonly FocusTarget[]; + /** The live tree, walked and numbered. Nothing here is a second registry. */ + readonly map: readonly OverlayEntry[]; readonly overlay: boolean; } @@ -322,8 +321,7 @@ const TRANSPORT_WORDS: Record = { * has to show what the ring does not. */ function focusMapRegion(layout: Layout, focus: FocusView): Op[] { - const ordered = mapOrder(focus.map); - const numbers = numbering(focus.map); + const ordered = focus.map; const width = Math.min(34, Math.max(18, Math.round(layout.cols * 0.24))); const height = Math.min(layout.rows, ordered.length + 2); const rect = { x: Math.max(0, layout.cols - width - 1), y: 1, width, height }; @@ -333,7 +331,7 @@ function focusMapRegion(layout: Layout, focus: FocusView): Op[] { lines.push({ segments: [ { text: on ? `${FOCUS_MARK} ` : " ", color: C.focus, width: 2 }, - { text: `${numbers.get(target.id) ?? 0}`, color: target.enabled ? C.out : C.dim, width: 3 }, + { text: `${target.number}`, color: target.enabled ? C.out : C.dim, width: 3 }, { text: target.label, color: target.enabled ? C.src : C.dim }, ], }); diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts index 8897e221a..639970469 100644 --- a/scripts/repl-study/store.ts +++ b/scripts/repl-study/store.ts @@ -21,8 +21,6 @@ import { fold, JOURNAL, journalThrough, siblingsOf } from "./journal.ts"; import type { JournalFixture, JournalRecord, Moment } from "./journal.ts"; import { layoutFor, SURFACES } from "./layout.ts"; import type { Layout, SurfaceName } from "./layout.ts"; -import { focusMap, registry, resolve, step } from "./focus.ts"; -import type { FocusTarget } from "./focus.ts"; import type { FixtureName, Fixture, TransportMode } from "./model.ts"; import type { Mutation } from "./mutations.ts"; import { formatRoute, navigationFor, parseRoute, surfaceFor, topDrawer } from "./route.ts"; @@ -91,8 +89,6 @@ export interface ReplState { readonly journal: JournalFixture; /** The fold of that journal at this route, minted with them and never alone. */ readonly moment: Moment; - /** A semantic identity, resolved against the registry that exists now. */ - readonly focus: string; /** Disposable: the transcript window. */ readonly anchor: number; /** @@ -145,7 +141,6 @@ export function hydrateRoute( mutation?: Mutation, ): ReplState { const rebuilt = mint(route, journal, { - focus: `region:${route.surface}`, anchor: 0, overlay: false, invokers: {}, @@ -263,21 +258,6 @@ export function viewOf(state: ReplState): View { }; } -/** The ring: every target Tab may land on, in traversal order. */ -export function targets(state: ReplState, size: Size, mutation?: Mutation): readonly FocusTarget[] { - return registry(state, layoutOf(state, size, mutation), mutation); -} - -/** The map: every visible target, enabled or not, which is what F1 numbers. */ -export function mapOf(state: ReplState, size: Size, mutation?: Mutation): readonly FocusTarget[] { - return focusMap(state, layoutOf(state, size, mutation), mutation); -} - -/** Where focus actually is, asked fresh rather than remembered. */ -export function focusIn(state: ReplState, size: Size, mutation?: Mutation): string { - return resolve(state.focus, targets(state, size, mutation)); -} - /** * Go somewhere, and decide whether that is a place you can come Back from. * @@ -289,7 +269,6 @@ function go(state: ReplState, route: Route, change: RouteChange, mutation?: Muta const navigation = mutation === "push-draft-edits" && change === "draft" ? "push" : navigationFor(change); return mint(route, state.journal, { - focus: state.focus, anchor: state.anchor, overlay: state.overlay, invokers: state.invokers, @@ -301,7 +280,6 @@ function go(state: ReplState, route: Route, change: RouteChange, mutation?: Muta function withJournal(state: ReplState, journal: JournalFixture): ReplState { return mint(state.route, journal, { - focus: state.focus, anchor: state.anchor, overlay: state.overlay, invokers: state.invokers, @@ -322,18 +300,17 @@ function extendTo(state: ReplState, kind: JournalRecord["kind"]): ReplState { } /** - * Put focus on one identity, and take the route with it. + * Take the route to wherever focus now is. * - * The surface segment says which region owns focus, so a focus move across a - * region boundary *is* a move. Leaving the URL behind would let a cold start - * come back to the region somebody had already tabbed away from, and would let - * a narrow terminal go on rendering one surface full-screen while focus named - * another. + * Focus itself is the tree's and never appears here. What the route records is + * the *surface* focus landed in, because the surface segment is what says which + * region owns focus — so a focus move that crosses a region boundary is a move + * the URL has to make too, in the same transition. */ -function focusTo( +export function followFocus( state: ReplState, identity: string, - change: RouteChange, + change: RouteChange = "focus", mutation?: Mutation, ): ReplState { const surface = surfaceFor(identity); @@ -342,9 +319,9 @@ function focusTo( surface === state.route.surface || mutation === "keep-route-on-focus" ) { - return { ...state, focus: identity }; + return state; } - return { ...go(state, { ...state.route, surface }, change, mutation), focus: identity }; + return go(state, { ...state.route, surface }, change, mutation); } export type HarnessEvent = @@ -360,6 +337,31 @@ export interface ReduceContext { readonly mutation?: Mutation; /** How many transcript lines the window may scroll past. */ readonly scrollLimit: number; + /** + * The identity the tree reports as focused. + * + * Supplied rather than stored: the tree owns focus, and a reducer that kept + * its own copy would be the second focus model this rework removes. + */ + readonly focused: string; +} + +/** + * What one event did, and what the tree should do about focus. + * + * Traversal and restoration are the tree's to perform — it is the thing that + * knows what exists. The reducer decides *what should happen*, names it, and + * lets the tree carry it out, so neither side keeps a second answer. + */ +export type FocusIntent = + | { readonly kind: "advance" } + | { readonly kind: "retreat" } + | { readonly kind: "to"; readonly identity: string } + | { readonly kind: "owner" }; + +export interface Reduction { + readonly state: ReplState; + readonly focus?: FocusIntent; } export interface Key { @@ -421,48 +423,49 @@ function frozen(state: ReplState, mutation?: Mutation): boolean { * return state whose route, focus, selection and anchor are the *same * references* it was handed. */ -export function reduce(state: ReplState, event: HarnessEvent, context: ReduceContext): ReplState { +export function reduce(state: ReplState, event: HarnessEvent, context: ReduceContext): Reduction { const { mutation } = context; + const only = (next: ReplState): Reduction => ({ state: next }); + if (event.kind === "quit") { - return { ...state, quit: true }; + return only({ ...state, quit: true }); } if (event.kind === "tick" || event.kind === "resize") { // A frame passing and a terminal resizing change what is drawn, never where // you are. The route survives a resize because the profile was never // recorded in it. if (event.kind === "resize" && mutation === "drop-route-on-resize") { - return hydrateRoute( - { - execution: state.route.execution, - surface: "transcript", - scopes: [], - drawers: [], - inspect: false, - draft: "", - }, - state.journal, + return only( + hydrateRoute( + { + execution: state.route.execution, + surface: "transcript", + scopes: [], + drawers: [], + inspect: false, + draft: "", + }, + state.journal, + ), ); } - return state; + return only(state); } if (event.kind === "background") { - // Nothing here writes focus, the route, the selection or the anchor, which - // is the whole of why a background update cannot steal any of them. - const extended = withJournal(state, [...state.journal, event.record]); - return mutation === "steal-focus-on-background" - ? { ...extended, focus: "region:sessions" } - : extended; + // Nothing here touches focus at all — it is not in this model to touch, + // which is the strongest form the "no stealing" claim can take. The control + // has to reach past the store, into the tree, to break it. + return only(withJournal(state, [...state.journal, event.record])); } const key = asKey(event.event); if (key.type !== "keydown") { - return state; + return only(state); } - const live = targets(state, context.size, mutation); - const here = resolve(state.focus, live); + const here = context.focused; if (key.code === "q" && !editable(here)) { - return { ...state, quit: true }; + return only({ ...state, quit: true }); } if (key.ctrl === true && key.code === "c") { // An entry that is paused, or being looked at through a reconstruction, is @@ -470,24 +473,25 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon // lifecycle to whoever closed the terminal. const active = state.moment.entry === "running" && mutation !== "exit-on-paused-interrupt"; if (active) { - return { ...state, interrupts: state.interrupts + 1 }; + return only({ ...state, interrupts: state.interrupts + 1 }); } if (state.route.draft !== "") { - return go(state, { ...state.route, draft: "" }, "draft", mutation); + return only(go(state, { ...state.route, draft: "" }, "draft", mutation)); } - return { ...state, quit: true }; + return only({ ...state, quit: true }); } if (key.code === "F1") { - return { ...state, overlay: !state.overlay }; + return only({ ...state, overlay: !state.overlay }); } if (key.code === "Tab" || key.code === "Backtab") { - return focusTo(state, step(here, live, reverseTab(key, mutation) ? -1 : 1), "focus", mutation); + // Traversal is the tree's: it is the thing that knows what exists now. + return { state, focus: reverseTab(key, mutation) ? { kind: "retreat" } : { kind: "advance" } }; } if (key.code === "Escape") { - return back(state, here, live, context.size, mutation); + return back(state, here, context.size, mutation); } if (key.code === "Enter") { - return activate(state, here, mutation); + return only(activate(state, here, mutation)); } const digit = Number(key.code); @@ -495,7 +499,11 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon const surface = (["sessions", "transcript", "bindings", "input", "history"] as const)[ digit - 1 ]; - return focusTo(state, `region:${surface}`, "surface", mutation); + const identity = `region:${surface}`; + return { + state: followFocus(state, identity, "surface", mutation), + focus: { kind: "to", identity }, + }; } if ( @@ -506,29 +514,29 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon // modified arrow is never stolen out of a draft somebody is typing. !editable(here) ) { - return structural(state, key.code, mutation); + return only(structural(state, key.code, mutation)); } if (key.code === "ArrowUp") { - return { ...state, anchor: Math.max(0, state.anchor - 1) }; + return only({ ...state, anchor: Math.max(0, state.anchor - 1) }); } if (key.code === "ArrowDown") { - return { ...state, anchor: Math.min(context.scrollLimit, state.anchor + 1) }; + return only({ ...state, anchor: Math.min(context.scrollLimit, state.anchor + 1) }); } if (key.code === "PageUp") { - return { ...state, anchor: Math.max(0, state.anchor - 10) }; + return only({ ...state, anchor: Math.max(0, state.anchor - 10) }); } if (key.code === "PageDown") { - return { ...state, anchor: Math.min(context.scrollLimit, state.anchor + 10) }; + return only({ ...state, anchor: Math.min(context.scrollLimit, state.anchor + 10) }); } if (key.code === "ArrowLeft" || key.code === "ArrowRight") { - return scrub(state, key.code === "ArrowLeft" ? -1 : 1, mutation); + return only(scrub(state, key.code === "ArrowLeft" ? -1 : 1, mutation)); } if (key.code === "d" && !editable(here)) { - return toggleDrawer(state, here, context.size, mutation); + return toggleDrawer(state, here, mutation); } - return type(state, key, here, mutation); + return only(type(state, key, here, mutation)); } /** @@ -538,50 +546,48 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon * control, then the navigation stack. Every step of it is non-destructive, * which is what lets Escape be the one key a person can always press. */ -function back( - state: ReplState, - here: string, - live: readonly FocusTarget[], - size: Size, - mutation?: Mutation, -): ReplState { +function back(state: ReplState, here: string, size: Size, mutation?: Mutation): Reduction { + void size; const top = topDrawer(state.route); if (top !== undefined) { + // The route loses the drawer; the tree removes the branch and restores the + // focus its push remembered. Neither side keeps the other's answer. const closed = go( state, { ...state.route, drawers: state.route.drawers.slice(0, -1) }, "drawer", mutation, ); - if (mutation === "forget-drawer-invoker") { - return closed; - } - const invoker = state.invokers[top] ?? "region:transcript"; - return focusTo(closed, resolve(invoker, targets(closed, size, mutation)), "focus", mutation); + return { state: closed }; } if (state.route.inspect) { - return go(state, { ...state.route, inspect: false }, "inspection", mutation); + return { state: go(state, { ...state.route, inspect: false }, "inspection", mutation) }; } if (!here.startsWith("region:")) { - return focusTo(state, resolve(ownerRegion(here), live), "focus", mutation); + const owner = ownerRegion(here); + return { + state: followFocus(state, owner, "focus", mutation), + focus: { kind: "to", identity: owner }, + }; } const previous = state.history[state.history.length - 1]; if (previous === undefined) { - return state; + return { state }; } const parsed = parseRoute(previous); if (!parsed.ok) { - return state; + return { state }; } - return mint(parsed.value, state.journal, { - focus: state.focus, - anchor: state.anchor, - overlay: state.overlay, - invokers: state.invokers, - history: state.history.slice(0, -1), - interrupts: state.interrupts, - quit: state.quit, - }); + return { + state: mint(parsed.value, state.journal, { + anchor: state.anchor, + overlay: state.overlay, + invokers: state.invokers, + history: state.history.slice(0, -1), + interrupts: state.interrupts, + quit: state.quit, + }), + }; } function ownerRegion(identity: string): string { @@ -680,24 +686,28 @@ function structural(state: ReplState, code: string, mutation?: Mutation): ReplSt } /** `d` opens the suspension that is waiting, or closes the one that is open. */ -function toggleDrawer(state: ReplState, here: string, size: Size, mutation?: Mutation): ReplState { - const top = topDrawer(state.route); - if (top !== undefined) { - return back(state, here, targets(state, size, mutation), size, mutation); +function toggleDrawer(state: ReplState, here: string, mutation?: Mutation): Reduction { + if (topDrawer(state.route) !== undefined) { + return back(state, here, { cols: 0, rows: 0 }, mutation); } const waiting = state.moment.suspension; if (waiting === undefined || frozen(state, mutation)) { - return state; + return { state }; } - return openDrawer(state, waiting, here, size, mutation); + return { state: openDrawer(state, waiting, here, mutation) }; } -/** Opening records the identity that invoked it, so closing can restore it. */ +/** + * Open a suspension's drawer. + * + * The route gains it and the invoking identity is remembered. Mounting the + * branch, pushing it as the focus root and seeding focus inside it are the + * tree's — this does not reach across and place focus itself. + */ export function openDrawer( state: ReplState, kind: DrawerKind, invoker: string, - size: Size, mutation?: Mutation, ): ReplState { const opened = go( @@ -706,13 +716,7 @@ export function openDrawer( "drawer", mutation, ); - // A suspension puts focus on the first meaningful control in the drawer - // rather than on the drawer itself, which is what study frame 07 shows. - return { - ...opened, - invokers: { ...state.invokers, [kind]: invoker }, - focus: targets(opened, size, mutation)[0]?.id ?? state.focus, - }; + return { ...opened, invokers: { ...state.invokers, [kind]: invoker } }; } /** Typing edits the draft, which replaces the current URL rather than adding to it. */ diff --git a/scripts/repl-study/surfaces.ts b/scripts/repl-study/surfaces.ts new file mode 100644 index 000000000..420595215 --- /dev/null +++ b/scripts/repl-study/surfaces.ts @@ -0,0 +1,73 @@ +/** + * The vocabulary the interface is built from. + * + * Names only: which controls a drawer carries, and in what order. The tree in + * `tree.ts` turns these into nodes, and the renderer draws them. Nothing here + * knows about focus — that is the tree's, and having it in one place is the + * point of this file being this small. + */ + +import type { DrawerKind } from "./fixtures.ts"; + +export interface SurfaceControl { + readonly id: string; + readonly kind: "control" | "field"; + readonly label: string; +} + +/** Each drawer's own sequence, taken from study frames 07, 08 and 09. */ +const DRAWER_TARGETS: Record = { + project: [ + { id: "field:drawer.project.name", kind: "field", label: "Project name" }, + { id: "field:drawer.project.description", kind: "field", label: "Description" }, + { id: "control:drawer.project.schema", kind: "control", label: "Schema disclosure · ⌥S" }, + { id: "control:drawer.project.submit", kind: "control", label: "Submit" }, + ], + review: [ + { id: "control:drawer.review.scroll", kind: "control", label: "Plan review · scroll region" }, + { id: "control:drawer.review.approve", kind: "control", label: "Approve" }, + { id: "control:drawer.review.request", kind: "control", label: "Request changes" }, + { id: "control:drawer.review.stop", kind: "control", label: "Stop" }, + { id: "control:drawer.review.submit", kind: "control", label: "Submit" }, + ], + confirm: [ + { + id: "control:drawer.confirm.preview", + kind: "control", + label: "README preview · scroll region", + }, + { id: "control:drawer.confirm.approve", kind: "control", label: "Approve" }, + { id: "control:drawer.confirm.decline", kind: "control", label: "Decline" }, + ], +}; + +export function drawerTargets(kind: DrawerKind): readonly SurfaceControl[] { + return DRAWER_TARGETS[kind]; +} + +/** What the overlay writes beside a node, keyed by the node's own name. */ +export function labelFor(name: string): string { + for (const targets of Object.values(DRAWER_TARGETS)) { + const found = targets.find((target) => target.id === name); + if (found !== undefined) { + return found.label; + } + } + return REGION_LABELS[name] ?? CONTROL_LABELS[name] ?? name; +} + +const REGION_LABELS: Record = { + "region:sessions": "Sessions", + "region:transcript": "Transcript", + "region:bindings": "Bindings", + "region:input": "REPL input", + "region:history": "Execution History", +}; + +const CONTROL_LABELS: Record = { + "control:input.run": "Run", + "control:transport.pause": "Pause", + "control:transport.continue": "Continue", + "control:transport.return-head": "Return to paused head", + "control:transport.fork": "Fork from here", +}; diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts new file mode 100644 index 000000000..0dc54ab61 --- /dev/null +++ b/scripts/repl-study/tree.ts @@ -0,0 +1,407 @@ +/** + * The REPL's interface, as the tree that owns focus. + * + * #839's first attempt kept a flat `FocusTarget[]` beside the interface and + * rebuilt traversal order, ownership and restoration by hand. That is the + * manual-focus problem Freedom exists to remove: a parallel list has to be kept + * in step with the interface by whoever changes the interface, and every + * opening and closing of a panel is another chance to forget. + * + * Here the hierarchy *is* the interface. A surface is a node, a scope panel + * mounted inside it is a branch, a drawer is a branch pushed as the active + * focus root, and a control is a leaf. Traversal order is tree order, computed + * on demand. Closing a panel removes its branch, and its controls and their + * middleware go with it through structured teardown — there is no second list + * to update, because there is no second list. + * + * A control that is visible but disabled is still a node: the renderer draws it + * and the `F1` map numbers it. It is simply never made focusable, so it cannot + * enter the focus chain. That is Freedom's own distinction, not one this + * harness invents. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; + +import { + advance, + current, + focus, + focusable, + focusPush, + retreat, + useFocus, + useRoot, +} from "./vendor/freedom/upstream/index.ts"; +import type { Node, PopFocus, Root } from "./vendor/freedom/upstream/index.ts"; + +import { drawerTargets, labelFor } from "./surfaces.ts"; +import { recordPath } from "./keys.ts"; +import { isDrawerKind } from "./fixtures.ts"; +import type { ReplState } from "./store.ts"; +import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; +import type { RouteSurface } from "./route.ts"; +import type { Mutation } from "./mutations.ts"; + +/** A node's semantic identity is its name, so the tree is readable as evidence. */ +export function identity(node: Node): string { + return node.name; +} + +/** Every node of the tree, in tree order. */ +export function walk(node: Node): Node[] { + const found: Node[] = [node]; + for (const child of node.children) { + found.push(...walk(child)); + } + return found; +} + +/** True where a node may take focus: Freedom marks exactly those. */ +export function isFocusable(node: Node): boolean { + return "focused" in node.props; +} + +export function find(root: Node, name: string): Node | undefined { + return walk(root).find((node) => node.name === name); +} + +/** + * The surface a node belongs to, read by walking up the tree. + * + * The first attempt parsed this out of the identity string. The tree already + * knows, and asking it cannot disagree with where the node actually is. + */ +export function surfaceOwning(node: Node): RouteSurface | undefined { + for (let at: Node | undefined = node; at; at = at.parent) { + const name = at.name; + if (name.startsWith("region:")) { + const region = name.slice("region:".length); + if (isRouteSurface(region)) { + return region; + } + } + } + return undefined; +} + +export interface ReplTree { + readonly root: Root; + /** Bring the interface into line with a state, mounting and removing branches. */ + sync(state: ReplState, mutation?: Mutation): Operation; + /** Where focus is, asked of the tree. */ + focused(): Node; + advance(): void; + retreat(): void; + /** Every node the `F1` overlay numbers, in tree order. */ + map(): Node[]; + /** The focus chain: visible, enabled, and in tree order. */ + chain(): Node[]; +} + +interface Mounted { + readonly node: Node; + readonly pop?: PopFocus; +} + +/** + * Build the interface once, then keep it in step. + * + * Nothing here rebuilds the tree from scratch. A rebuild would destroy every + * node each frame and take focus with it, which is the defect a live tree + * exists to avoid. + */ +export function useReplTree(state: ReplState): Operation { + return { + *[Symbol.iterator]() { + const root = yield* useRoot(); + const regions = new Map(); + for (const region of ROUTE_SURFACES) { + const node = root.node.createChild(`region:${region}`); + focusable(node); + recordPath(node, node.name); + regions.set(region, node); + } + useFocus(root.node); + + let drawers: Mounted[] = []; + let scopes: Node[] = []; + + /** + * Bring one region's controls into line, by name. + * + * Reconciled rather than rebuilt: removing and recreating every control + * on each sync would destroy the node focus is on and take focus with + * it, which is the defect a live tree exists to avoid. + */ + const reconcile = function* ( + parent: Node, + wanted: readonly Control[], + mutation?: Mutation, + ): Operation { + const childrenByName = () => + new Map([...parent.children].map((child) => [child.name, child] as const)); + const shouldFocus = (control: Control): boolean => + control.enabled || mutation === "focus-hidden-target"; + + // Additions come first. Removing the focused control before its + // replacement exists would leave the tree with nothing in that region + // to move focus to, and focus would land outside it — which is how a + // resumed run lost its transport slot. + let present = childrenByName(); + for (const control of wanted) { + if (!present.has(control.name)) { + const node = parent.createChild(control.name); + // A disabled control is a node the renderer draws and the map + // numbers; not making it focusable is the whole of what disables it. + if (shouldFocus(control)) { + focusable(node); + } + } + } + + present = childrenByName(); + for (const [name, child] of present) { + if (!wanted.some((control) => control.name === name)) { + yield* until(child.remove()); + } + } + + // A control whose enabled-ness changed is a different node: making a + // node focusable is one-way, so the honest way to disable one is for it + // to stop being that node and start being another. + present = childrenByName(); + for (const control of wanted) { + const child = present.get(control.name); + if (child === undefined || shouldFocus(control) === isFocusable(child)) { + continue; + } + yield* until(child.remove()); + const replacement = parent.createChild(control.name); + if (shouldFocus(control)) { + focusable(replacement); + } + } + }; + + const mountControls = function* (next: ReplState, mutation?: Mutation): Operation { + // The footer is an *explicit* region: its controls join the interface + // only once focus is inside it. The tree asks itself where focus is + // rather than being told, so the two can never disagree. + const within = withinHistory(current(root.node).name); + if (mutation === "rebuild-tree-each-sync") { + // Rebuilding destroys the node focus is on, and takes focus with it. + for (const region of [regions.get("input")!, regions.get("history")!]) { + for (const child of [...region.children]) { + yield* until(child.remove()); + } + } + } + yield* reconcile(regions.get("input")!, runControls(next), mutation); + yield* reconcile(regions.get("history")!, transportControls(next, within), mutation); + }; + + /** + * The locus, as nested panels inside the transcript. + * + * Only the part that actually diverged is removed. Navigating deeper + * keeps the panels already open, and with them whatever focus is inside. + */ + const mountScopes = function* (next: ReplState): Operation { + const wanted = next.route.scopes; + let diverged = 0; + while ( + diverged < Math.min(scopes.length, wanted.length) && + scopes[diverged].name === `panel:${wanted[diverged]}` + ) { + diverged += 1; + } + while (scopes.length > diverged) { + const leaf = scopes.pop()!; + yield* until(leaf.remove()); + } + let parent = scopes[scopes.length - 1] ?? regions.get("transcript")!; + for (const scope of wanted.slice(scopes.length)) { + const node = parent.createChild(`panel:${scope}`); + // A scope panel is a container, not a target: it owns the middleware + // and the lifetime of what is inside it, and the study never focuses + // one. Not making it focusable is the whole of that distinction. + node.set("container", true); + recordPath(node, node.name); + scopes.push(node); + parent = node; + } + }; + + const syncDrawers = function* (next: ReplState, mutation?: Mutation): Operation { + const wanted = next.route.drawers; + let kept = 0; + while ( + kept < Math.min(drawers.length, wanted.length) && + drawers[kept].node.name === `drawer:${wanted[kept]}` + ) { + kept += 1; + } + while (drawers.length > kept) { + const top = drawers.pop()!; + // Pop the focus root first, so the invoking focus is restored while + // the branch still exists, then remove the branch: its controls and + // their middleware go with it. + if (mutation !== "forget-drawer-invoker") { + top.pop?.(); + } + if (mutation !== "keep-closed-branch") { + yield* until(top.node.remove()); + } + } + for (let at = drawers.length; at < wanted.length; at += 1) { + const kind = wanted[at]; + if (!isDrawerKind(kind)) { + continue; + } + const node = root.node.createChild(`drawer:${kind}`); + node.set("container", true); + recordPath(node, node.name); + // The drawer's controls live in a body panel, so a key bound for one + // of them passes through the drawer *and* the panel — which is the + // ancestor path a flat registry has no way to produce. + const body = node.createChild(`panel:${kind}.body`); + body.set("container", true); + recordPath(body, body.name); + for (const target of drawerTargets(kind)) { + const child = body.createChild(target.id); + focusable(child); + } + // The footer stays reachable through a suspension, so it is inside + // the pushed root rather than outside it. + const footer = node.createChild("region:history"); + focusable(footer); + const pop = mutation === "leak-drawer-trap" ? undefined : focusPush(node); + drawers.push({ node, pop }); + } + }; + + yield* mountScopes(state); + yield* mountControls(state); + yield* syncDrawers(state); + + const tree: ReplTree = { + root, + *sync(next: ReplState, mutation?: Mutation) { + yield* mountScopes(next); + yield* mountControls(next, mutation); + yield* syncDrawers(next, mutation); + if (mutation === "steal-focus-on-background") { + // Nothing in the honest path writes focus when the world changes + // underneath; this one does. + advance(root.node); + } + }, + focused: () => current(root.node), + advance: () => advance(root.node), + retreat: () => retreat(root.node), + // The overlay numbers targets, not the containers that hold them. + map: () => + walk(activeRoot(root, drawers)).filter( + (node) => node.name !== "" && node.props.container !== true, + ), + chain: () => walk(activeRoot(root, drawers)).filter(isFocusable), + }; + return tree; + }, + }; +} + +/** The subtree traversal is trapped in: the top drawer, or the whole tree. */ +function activeRoot(root: Root, drawers: readonly Mounted[]): Node { + const top = drawers[drawers.length - 1]; + return top?.pop === undefined ? root.node : top.node; +} + +interface Control { + readonly name: string; + readonly enabled: boolean; +} + +/** True while the identity is the footer itself or one of its controls. */ +function withinHistory(identity: string): boolean { + return identity === "region:history" || identity.startsWith("control:transport."); +} + +/** `Run` is listed whenever there is something to run and nothing running. */ +function runControls(state: ReplState): readonly Control[] { + const runnable = state.moment.entry !== "running" && state.route.draft !== ""; + return runnable ? [{ name: "control:input.run", enabled: true }] : []; +} + +/** + * The transport controls the footer exposes. + * + * There is nothing to expose before an execution has been recorded, and + * `Continue` is visible but disabled while a reconstruction is open — the study + * numbers it and says Tab skips it. + */ +function transportControls(state: ReplState, within: boolean): readonly Control[] { + if (state.moment.entry === "none" || !within) { + return []; + } + if (state.moment.transport === "live") { + return [{ name: "control:transport.pause", enabled: true }]; + } + if (state.moment.transport === "paused") { + return [ + { name: "control:transport.continue", enabled: true }, + { name: "control:transport.return-head", enabled: true }, + ]; + } + if (state.moment.transport === "inspecting") { + return [ + { name: "control:transport.continue", enabled: false }, + { name: "control:transport.return-head", enabled: true }, + { name: "control:transport.fork", enabled: true }, + ]; + } + return []; +} + +export { focus, topDrawer }; + +export interface OverlayEntry { + readonly id: string; + readonly label: string; + /** False where the node is drawn and numbered but cannot take focus. */ + readonly enabled: boolean; + readonly number: number; +} + +/** + * The `F1` map, read off the live tree. + * + * There is no ordered registry to consult: the nodes are walked in tree order + * and numbered as the study numbers them — regions first, then the controls. + * Numbering and traversal are deliberately different orders, which is why the + * study's numbers look out of sequence: `Run` belongs to the input and is + * traversed there, but numbered after the last region. + */ +export function overlayOf(tree: ReplTree, mutation?: Mutation): readonly OverlayEntry[] { + if (mutation === "flat-overlay") { + // A list kept beside the interface rather than read off it: it goes on + // numbering the five regions whatever the tree currently holds. + return ROUTE_SURFACES.map((region, at) => ({ + id: `region:${region}`, + label: labelFor(`region:${region}`), + enabled: true, + number: at + 1, + })); + } + const nodes = tree.map(); + const regions = nodes.filter((node) => node.name.startsWith("region:")); + const rest = nodes.filter((node) => !node.name.startsWith("region:")); + const ordered = regions.length === ROUTE_SURFACES.length ? [...regions, ...rest] : nodes; + return ordered.map((node, at) => ({ + id: node.name, + label: labelFor(node.name), + enabled: isFocusable(node), + number: at + 1, + })); +} diff --git a/scripts/repl-study/vendor/freedom/LICENSE b/scripts/repl-study/vendor/freedom/LICENSE new file mode 100644 index 000000000..e72db0ec8 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/LICENSE @@ -0,0 +1,21 @@ +MIT License Copyright (c) 2026-Present [Bombshell contributors](https://bomb.sh/team) + +Permission is hereby granted, free of +charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice +(including the next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/scripts/repl-study/vendor/freedom/MANIFEST.json b/scripts/repl-study/vendor/freedom/MANIFEST.json new file mode 100644 index 000000000..91d85ca26 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/MANIFEST.json @@ -0,0 +1,45 @@ +{ + "upstream": { + "repository": "https://github.com/bombshell-dev/playground", + "commit": "8be97e7201cd6effddb2f8b240b4b5166641e7f0", + "branch": "focus-stack", + "directory": "packages/freedom", + "package": "@bomb.sh/freedom", + "version": "0.0.0", + "license": "MIT" + }, + "patches": [ + { + "file": "upstream/lib/root.ts", + "name": "owned-root", + "reason": "createRoot() parents the root scope to Effection global. useRoot() acquires the tree as a resource owned by the calling scope, which is the surface this repository uses; createRoot(owner) is the escape hatch behind it." + }, + { + "file": "upstream/lib/node.ts", + "name": "owned-root", + "reason": "NodeImpl takes the owning scope a parentless root is created under." + }, + { + "file": "upstream/lib/mod.ts", + "name": "owned-root", + "reason": "useRoot() joins the public surface." + }, + { + "file": "upstream/lib/focus.ts", + "name": "containment-removal", + "reason": "useFocus()'s remove middleware compared the removed node with the focused one. Closing a drawer or panel removes the branch above the focused control, which left focus on a node that no longer existed; it now asks whether the branch contains the focused node." + } + ], + "files": { + "LICENSE": "ff5318b9f1cbe44dec39dd306a042a50900ecfaf0d99489beb11b6a567ad21bf", + "upstream/index.ts": "ffae461c16d4a1bf24c2179582ab8d5c81ad0df61e4ae2fba51ef5e5bdf90345", + "upstream/lib/dispatch.ts": "c75fbd34bda9786aae58e5539ebc05bd0cf88766771cef55dfb23db55d72e5ec", + "upstream/lib/focus.ts": "efe3da62c51e6d8c204e0eee31a13f7fcce22ace39de2a30512e298162f92f6e", + "upstream/lib/mod.ts": "48ef87d71e22dbf16d480d38d359d79071259c1f476d8c39b92678a13fee7f17", + "upstream/lib/node.ts": "e45e22c0426e8b593ae6cb78be99c78ff6f693ec8ca1102342c6a3ed0687b9a9", + "upstream/lib/root.ts": "38f0891afa7deb87f01c65bf63cbeef73c9482e98007b54d0f7d5a332c3b46fd", + "upstream/lib/state.ts": "6eb73c1f8daa2d560948a52eb7e87ec91b8cd8c47cacdb4ca57e6ef70f1c4e1f", + "upstream/lib/types.ts": "6f1654f4f7855e21167fd62349bf56e461ce61470ba51099382068a296b89e62", + "upstream/lib/validate.ts": "561f3b52c1ffdc91ca1cf976aae09319d66ea982758abb6e79e2c1ad351b9c6b" + } +} diff --git a/scripts/repl-study/vendor/freedom/PROVENANCE.md b/scripts/repl-study/vendor/freedom/PROVENANCE.md new file mode 100644 index 000000000..a29551d0e --- /dev/null +++ b/scripts/repl-study/vendor/freedom/PROVENANCE.md @@ -0,0 +1,74 @@ +# @bomb.sh/freedom, vendored + +`@bomb.sh/freedom` is `private: true`, version `0.0.0`, and unpublished, so it +cannot be installed. Its source is vendored here from the public playground +repository at the commit `MANIFEST.json` pins. + +```text +https://github.com/bombshell-dev/playground +8be97e7201cd6effddb2f8b240b4b5166641e7f0 (branch focus-stack) +packages/freedom +``` + +## It runs on this repository's Effection + +Freedom's own manifest asks for `effection@4.1.0-alpha.9`; this repository pins +`4.1.0`. That mattered more than a version number usually does — two Effection +copies would mean two scope trees and two context systems, and Freedom's +central claim, that its node tree and the Effection scope tree correspond, +would have been false against *our* tree. + +The sources run on `4.1.0` unmodified. The surface they use is +`createContext`, `createScope`, `createSignal`, `createQueue`, `createApi` from +`effection/experimental`, and `scope.set/get/expect/around/run` — all present +and unchanged. Nothing about the dependency layout moves for this experiment: +no lockfile entry, no second Effection. + +## Two patches, and why they are here rather than upstream + +Both are recorded in `MANIFEST.json` and reported upstream. They are behaviour +changes to a dependency, taken deliberately. + +**`owned-root`.** `createRoot()` parents the root scope to Effection `global`. +A globally-parented root means host context does not reach the tree, a failure +in node work raises into a boundary nobody observes, and the caller owns the +tree only by remembering to destroy it. `useRoot()` acquires the tree as a +resource owned by the acquiring scope, which makes all three structural. This +is the fix this repository's earlier Freedom evaluation identified and settled +on; the pinned branch carries the focus work but not that fix. + +**`containment-removal`.** `useFocus()` installs a `remove` middleware that +moves focus to a successor first, but it asked whether the *removed node* was +the focused one. A drawer or a panel is closed by removing the branch above the +focused control, so the common case fell through: focus was left on a node that +had just been destroyed, while a perfectly good sibling survived. + +```text +before removing the focused node → focus moves to the survivor +before removing its branch → focus left on nothing +after removing its branch → focus moves to the survivor +``` + +The predicate now asks whether the branch contains the focused node. + +## How it is held still + +`MANIFEST.json` records the upstream commit, both patches with their reasons, +and a SHA-256 for every vendored file; `scripts/tests/repl-focus.test.ts` +checks the bytes against it, so an unrecorded edit fails. + +The snapshot is excluded from `oxfmt` (`.oxfmtrc.json`) and from `oxlint` +(`.oxlintrc.json`) for the same reason the acpx and Cloudflare DOFS snapshots +are: reformatting upstream's bytes would break the identity the manifest holds +them to. The exclusion is deliberately **not** on the lint task's command line +in `package.json` — editing that manifest invalidates `deno.lock`, and the +build and publication suites that run `deno install --frozen` fail on it. + +## What is not vendored + +`@bomb.sh/input` pins `@bomb.sh/tty ^0.8.0`, and this repository pins `0.9.0` +exactly under a repository-wide frozen lockfile. The part this experiment needs +is its targeting rule — dispatch a key to the focused node's scope, so the +node's ancestors form the middleware path — which is implemented directly in +`scripts/repl-study/keys.ts`. `packages/input/src/lib/input.ts` at the pinned +commit is the reference it follows. diff --git a/scripts/repl-study/vendor/freedom/upstream/index.ts b/scripts/repl-study/vendor/freedom/upstream/index.ts new file mode 100644 index 000000000..945209dd1 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/index.ts @@ -0,0 +1 @@ +export * from "./lib/mod.ts"; diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/dispatch.ts b/scripts/repl-study/vendor/freedom/upstream/lib/dispatch.ts new file mode 100644 index 000000000..d14286df6 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/dispatch.ts @@ -0,0 +1,24 @@ +// oxlint-disable require-yield +import type { Api, Operation, Result } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "./types.ts"; +import { TreeContext } from "./state.ts"; + +export interface Dispatch { + dispatch(event: unknown): Operation>; + getNodeById(id: string): Operation; +} + +export const DispatchApi: Api = createApi( + "freedom:dispatch", + { + *dispatch(_event: unknown): Operation> { + return { ok: false, error: new Error("unhandled") }; + }, + + *getNodeById(id: string): Operation { + const tree = yield* TreeContext.expect(); + return tree.nodes.get(id); + }, + }, +); diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/focus.ts b/scripts/repl-study/vendor/freedom/upstream/lib/focus.ts new file mode 100644 index 000000000..3dad1a15c --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/focus.ts @@ -0,0 +1,228 @@ +// oxlint-disable bombshell-dev/no-generic-error + +//TODO: export as freedom/focus +import { createContext } from "effection"; +import type { Node } from "./types.ts"; +import { NodeApi } from "./node.ts"; + +// A pushed focus root: the boundary cycling is trapped within, the focus to +// restore, and the callback to notify when it is popped (§12). +interface FocusEntry { + node: Node; + restore: Node | undefined; + callback?: (value: unknown) => void; +} + +export type PopFocus = (value?: unknown) => void; + +// The focus stack lives on the tree root's scope, not module state (§12, FS2). +const FocusStackContext = createContext("freedom:focus-stack"); + +function findRoot(node: Node): Node { + let n = node; + while (n.parent) { + n = n.parent; + } + return n; +} + +// The tree's focus stack, lazily created on the root scope on first use. +function focusStack(node: Node): FocusEntry[] { + const root = findRoot(node); + let stack = root.scope.get(FocusStackContext); + if (!stack) { + stack = []; + root.scope.set(FocusStackContext, stack); + } + return stack; +} + +// The active focus root: the top of the stack, or the tree root (§12.3). +function focusRoot(node: Node): Node { + const stack = focusStack(node); + return stack.length > 0 ? stack[stack.length - 1].node : findRoot(node); +} + +function focusChain(node: Node): Node[] { + const result: Node[] = []; + if ("focused" in node.props) { + result.push(node); + } + for (const child of node.children) { + result.push(...focusChain(child)); + } + return result; +} + +function successorOf(node: Node): Node | undefined { + const nodes = focusChain(focusRoot(node)); + if (nodes.length <= 1) { + return undefined; + } + const idx = nodes.indexOf(node); + if (idx === -1) { + return undefined; + } + return nodes[(idx + 1) % nodes.length]; +} + +// TODO nename -> setFocusable +export function focusable(node: Node): void { + if (!("focused" in node.props)) { + node.set("focused", false); + } +} + +// TODO: rename -> getCurrentFocus() +export function current(node: Node): Node { + const root = focusRoot(node); + return focusChain(root).find((n) => n.props.focused === true) ?? root; +} + +export function advance(node: Node): void { + const nodes = focusChain(focusRoot(node)); + if (nodes.length <= 1) { + return; + } + const idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) { + return; + } + nodes[idx].set("focused", false); + nodes[(idx + 1) % nodes.length].set("focused", true); +} + +export function retreat(node: Node): void { + const nodes = focusChain(focusRoot(node)); + if (nodes.length <= 1) { + return; + } + const idx = nodes.findIndex((n) => n.props.focused === true); + if (idx === -1) { + return; + } + nodes[idx].set("focused", false); + nodes[(idx - 1 + nodes.length) % nodes.length].set("focused", true); +} + +export function focus(target: Node): void { + if (!("focused" in target.props)) { + throw new Error("Cannot focus a non-focusable node"); + } + if (!focusChain(focusRoot(target)).includes(target)) { + throw new Error("Cannot focus a node outside the active focus root"); + } + if (target.props.focused === true) { + return; + } + const old = focusChain(findRoot(target)).find((n) => n.props.focused === true); + if (old) { + old.set("focused", false); + } + target.set("focused", true); +} + +// XMD patch: does this subtree hold the node that currently has focus? +// Removal has to ask about containment, not identity — a drawer or panel is +// closed by removing the branch *above* the focused control, and a check that +// compared the removed node with the focused one left focus on a node that no +// longer exists. +function holdsFocus(branch: Node, focused: Node | undefined): boolean { + if (focused === undefined) { + return false; + } + for (let at: Node | undefined = focused; at; at = at.parent) { + if (at === branch) { + return true; + } + } + return false; +} + +// The next focusable outside the branch being removed, in tree order. +function successorOutside(branch: Node): Node | undefined { + const nodes = focusChain(focusRoot(branch)); + return nodes.find((candidate) => !holdsFocus(branch, candidate)); +} + +export function useFocus(node: Node): void { + const first = focusChain(node).find((n) => n !== node); + if (first) { + focus(first); + } + node.scope.around(NodeApi, { + remove([target], next) { + const focused = focusChain(findRoot(target)).find( + (n) => n.props.focused === true, + ); + if (target.props.focused === true) { + const successor = successorOf(target); + if (successor && successor !== target) { + focus(successor); + } + } else if (holdsFocus(target, focused)) { + const successor = successorOutside(target); + if (successor) { + focus(successor); + } else if (focused) { + focused.set("focused", false); + } + } + return next(target); + }, + }); +} + +// Push `node` as the active focus root: cycling is trapped within its focusable +// descendants (§12.4). Returns the bound pop (§12.5). +export function focusPush( + node: Node, + callback?: (value: unknown) => void, +): PopFocus { + const stack = focusStack(node); + const restore = focusChain(focusRoot(node)).find( + (n) => n.props.focused === true, + ); + const entry: FocusEntry = { node, restore, callback }; + stack.push(entry); + + const first = focusChain(node).find((n) => n !== node); + if (first) { + focus(first); // seed inside; clears the pre-push focus (FS6) + } else if (restore) { + restore.set("focused", false); // empty container: nothing focused (FS6) + } + + return (value?: unknown) => { + if (stack[stack.length - 1] !== entry) { + throw new Error("focus pop out of order (unbalanced push/pop)"); + } + stack.pop(); + restoreFocus(node, restore); + if (callback) { + callback(value); + } + }; +} + +// Restore focus after a pop: the remembered node if still valid, else the first +// focusable descendant of the now-active root, else clear any residual focus. +function restoreFocus(node: Node, restore: Node | undefined): void { + const root = focusRoot(node); + const chain = focusChain(root); + if (restore && chain.includes(restore)) { + focus(restore); + } else { + const first = chain.find((n) => n !== root); + if (first) { + focus(first); + } else { + const residual = focusChain(findRoot(node)).find( + (n) => n.props.focused === true, + ); + if (residual) { + residual.set("focused", false); + } + } + } +} diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/mod.ts b/scripts/repl-study/vendor/freedom/upstream/lib/mod.ts new file mode 100644 index 000000000..c9ac90e05 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/mod.ts @@ -0,0 +1,25 @@ +export type { + JsonValue, + Node, + NodeData, + NodeDataKey, + Root, +} from "./types.ts"; + +export { createNodeData } from "./types.ts"; + +export { createRoot, useRoot } from "./root.ts"; +export { NodeApi } from "./node.ts"; + +export { type Dispatch, DispatchApi } from "./dispatch.ts"; + +export { + advance, + current, + focus, + focusable, + focusPush, + type PopFocus, + retreat, + useFocus, +} from "./focus.ts"; diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/node.ts b/scripts/repl-study/vendor/freedom/upstream/lib/node.ts new file mode 100644 index 000000000..9df396882 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/node.ts @@ -0,0 +1,198 @@ +// oxlint-disable bombshell-dev/no-generic-error +// oxlint-disable max-params +import { + type Context, + createContext, + createScope, + type Scope, +} from "effection"; +import { createApi } from "effection/experimental"; +import type { + CreateChildOptions, + JsonValue, + Node, + NodeData, + NodeDataKey, +} from "./types.ts"; +import { TreeContext } from "./state.ts"; +import { validateJsonValue } from "./validate.ts"; + +class NodeDataImpl implements NodeData { + _map: Map = new Map(); + + get(key: NodeDataKey): T | undefined { + return this._map.get(key.symbol) as T | undefined; + } + + set(key: NodeDataKey, value: T): void { + this._map.set(key.symbol, value); + } + + expect(key: NodeDataKey): T { + const val = this._map.get(key.symbol); + if (val !== undefined) { + return val as T; + } else if (key.defaultValue !== undefined) { + return key.defaultValue; + } else { + throw new Error(`NodeData '${key.symbol.description}' not found`); + } + } +} + +export class NodeImpl implements Node { + _props: Record = {}; + _children: Set = new Set(); + _sortFn: ((a: Node, b: Node) => number) | undefined = undefined; + data: NodeData = new NodeDataImpl(); + scope: Scope; + #dispose: () => Promise; + + constructor( + readonly id: string, + readonly name: string, + readonly _parent: NodeImpl | undefined, + // XMD patch: the scope a parentless root is owned by. Without it a root is + // parented to Effection `global`, where host context does not reach the + // tree and a node-work failure raises into a no-op boundary. + owner?: Scope, + ) { + const [scope, dispose] = createScope(_parent?.scope ?? owner); + this.scope = scope; + this.#dispose = dispose; + scope.set(NodeContext, this); + } + + get props(): Record { + return Object.freeze({ ...this._props }); + } + + get children(): Iterable { + if (this._sortFn) { + const fn = this._sortFn; + const indexed = [...this._children].map((c, i) => [c, i] as const); + indexed.sort(([a, ai], [b, bi]) => { + const result = fn(a, b); + if (result !== 0) { + return result; + } else { + return ai - bi; + } + }); + return indexed.map(([c]) => c); + } else { + return this._children; + } + } + + get parent(): Node | undefined { + return this._parent; + } + + get(key: string): JsonValue | undefined { + return NodeApi.invoke(this.scope, "get", [this, key]); + } + + set(key: string, value: JsonValue): void { + NodeApi.invoke(this.scope, "set", [this, key, value]); + } + + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void { + NodeApi.invoke(this.scope, "update", [this, key, fn]); + } + + unset(key: string): void { + NodeApi.invoke(this.scope, "unset", [this, key]); + } + + createChild(name = "", options?: CreateChildOptions): Node { + return NodeApi.invoke(this.scope, "createChild", [this, name, options]); + } + + sort(fn?: (a: Node, b: Node) => number): void { + NodeApi.invoke(this.scope, "sort", [this, fn]); + } + + // Internal scope teardown — not on the public `Node` interface. Used by + // `remove` and by `root.destroy()`; disposing the scope cascades to all + // descendants. Removing a non-root node should go through `remove`, which also + // detaches and notifies. + destroy(): Promise { + return this.#dispose(); + } + + remove(): Promise { + return NodeApi.invoke(this.scope, "remove", [this]); + } +} + +// Synchronous node mutation API. Core methods take the node first; interceptors +// are installed per scope via `node.scope.around(NodeApi, ...)`. +export const NodeApi = createApi("freedom:node", { + get(node: NodeImpl, key: string): JsonValue | undefined { + return node._props[key]; + }, + set(node: NodeImpl, key: string, value: JsonValue): void { + validateJsonValue(value); + node._props[key] = value; + node.scope.expect(TreeContext).markDirty(); + }, + update( + node: NodeImpl, + key: string, + fn: (prev: JsonValue | undefined) => JsonValue, + ): void { + const value = fn(node._props[key]); + validateJsonValue(value); + node._props[key] = value; + node.scope.expect(TreeContext).markDirty(); + }, + unset(node: NodeImpl, key: string): void { + if (key in node._props) { + delete node._props[key]; + node.scope.expect(TreeContext).markDirty(); + } + }, + createChild(node: NodeImpl, name: string, options?: CreateChildOptions): Node { + const state = node.scope.expect(TreeContext); + const child = new NodeImpl(state.nextId(), name, node); + const before = options?.before; + if (before) { + if (!node._children.has(before as NodeImpl)) { + throw new Error("createChild: `before` is not a child of this node"); + } + // Set has no positional insert, so rebuild it with `child` spliced in. + const reordered = new Set(); + for (const existing of node._children) { + if (existing === before) { + reordered.add(child); + } + reordered.add(existing); + } + node._children = reordered; + } else { + node._children.add(child); + } + state.nodes.set(child.id, child); + state.markDirty(); + return child; + }, + sort(node: NodeImpl, fn: ((a: Node, b: Node) => number) | undefined): void { + node._sortFn = fn; + node.scope.expect(TreeContext).markDirty(); + }, + remove(node: NodeImpl): Promise { + if (!node._parent) { + throw new Error("Cannot remove root node"); + } + const state = node.scope.expect(TreeContext); + node._parent._children.delete(node); + state.nodes.delete(node.id); + state.markDirty(); + return node.destroy(); + }, +}); + +export const NodeContext: Context = createContext( + "freedom:current-node", +); diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/root.ts b/scripts/repl-study/vendor/freedom/upstream/lib/root.ts new file mode 100644 index 000000000..1f0574a3c --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/root.ts @@ -0,0 +1,84 @@ +import { createQueue, createSignal, ensure, resource, until, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import type { Root } from "./types.ts"; +import { NodeImpl } from "./node.ts"; +import { TreeContext, type TreeState } from "./state.ts"; +import { DispatchApi } from "./dispatch.ts"; + +export function createRoot(owner?: Scope): Root { + const output = createSignal(); + // Internal, always-drained buffer: createRoot is synchronous, so the drain + // loop subscribes asynchronously — a Queue keeps events dispatched before the + // loop runs from being lost (bounded, since the loop drains immediately). + const events = createQueue(); + + let counter = 0; + const state: TreeState = { + dirty: false, + output, + nodes: new Map(), + nextId() { + return `node-${++counter}`; + }, + markDirty() { + state.dirty = true; + }, + }; + + const node = new NodeImpl(state.nextId(), "", undefined, owner); + node.scope.set(TreeContext, state); + state.nodes.set(node.id, node); + + // Dispatch loop: drain events through the demux middleware chain. + node.scope.run(function* () { + while (true) { + const next = yield* events.next(); + if (next.done) { + break; + } + state.dirty = false; + yield* DispatchApi.operations.dispatch(next.value); + if (state.dirty) { + output.send(); + } + } + }); + + return { + node, + dispatch(event) { + events.add(event); + }, + [Symbol.iterator]: output[Symbol.iterator], + destroy() { + return node.destroy(); + }, + }; +} + +/** + * A tree owned by the scope that acquires it. + * + * XMD patch. `createRoot()` alone parents the root scope to Effection `global`, + * so host context does not reach the tree, a failure in node work raises into a + * boundary nobody observes, and the caller owns the tree only by remembering to + * destroy it. Acquiring the root as a resource makes all three structural: the + * root scope is a child of the calling scope, it inherits that scope's + * contexts, and teardown joins it. + * + * The cleanup is registered before the root exists, because a run halted while + * acquiring has nothing registered to unwind. + */ +export function useRoot(): Operation { + return resource(function* (provide) { + const owner = yield* useScope(); + let root: Root | undefined; + yield* ensure(function* () { + if (root) { + yield* until(root.destroy()); + } + }); + root = createRoot(owner); + yield* provide(root); + }); +} diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/state.ts b/scripts/repl-study/vendor/freedom/upstream/lib/state.ts new file mode 100644 index 000000000..455d1d992 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/state.ts @@ -0,0 +1,12 @@ +import { createContext, type Signal } from "effection"; +import type { NodeImpl } from "./node.ts"; + +export interface TreeState { + dirty: boolean; + output: Signal; + nodes: Map; + nextId(): string; + markDirty(): void; +} + +export const TreeContext = createContext("freedom:tree"); diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/types.ts b/scripts/repl-study/vendor/freedom/upstream/lib/types.ts new file mode 100644 index 000000000..f7216392b --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/types.ts @@ -0,0 +1,54 @@ +import type { Scope, Stream } from "effection"; + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface NodeDataKey { + readonly symbol: symbol; + readonly defaultValue?: T; +} + +export function createNodeData( + name: string, + defaultValue?: T, +): NodeDataKey { + return { symbol: Symbol(name), defaultValue }; +} + +export interface NodeData { + get(key: NodeDataKey): T | undefined; + set(key: NodeDataKey, value: T): void; + expect(key: NodeDataKey): T; +} + +export interface CreateChildOptions { + before?: Node; +} + +export interface Node { + readonly id: string; + readonly name: string; + readonly props: Record; + readonly children: Iterable; + readonly parent: Node | undefined; + readonly data: NodeData; + readonly scope: Scope; + get(key: string): JsonValue | undefined; + set(key: string, value: JsonValue): void; + update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; + unset(key: string): void; + createChild(name?: string, options?: CreateChildOptions): Node; + sort(fn?: (a: Node, b: Node) => number): void; + remove(): Promise; +} + +export interface Root extends Stream { + node: Node; + dispatch(event: unknown): void; + destroy(): Promise; +} diff --git a/scripts/repl-study/vendor/freedom/upstream/lib/validate.ts b/scripts/repl-study/vendor/freedom/upstream/lib/validate.ts new file mode 100644 index 000000000..d14c52463 --- /dev/null +++ b/scripts/repl-study/vendor/freedom/upstream/lib/validate.ts @@ -0,0 +1,56 @@ +// oxlint-disable bombshell-dev/no-generic-error +import type { JsonValue } from "./types.ts"; + +export function validateJsonValue(value: unknown): asserts value is JsonValue { + if (value === undefined) { + throw new Error("undefined is not a valid JsonValue"); + } + if (typeof value === "number") { + if (Number.isNaN(value)) { + throw new Error("NaN is not a valid JsonValue"); + } + if (!Number.isFinite(value)) { + throw new Error(`${value} is not a valid JsonValue`); + } + return; + } + if ( + typeof value === "string" || typeof value === "boolean" || value === null + ) { + return; + } + if (typeof value === "function") { + throw new Error("functions are not valid JsonValues"); + } + if (typeof value === "symbol") { + throw new Error("symbols are not valid JsonValues"); + } + if (typeof value === "bigint") { + throw new Error("bigints are not valid JsonValues"); + } + if (value instanceof Date) { + throw new Error("Date instances are not valid JsonValues"); + } + if (value instanceof Map) { + throw new Error("Map instances are not valid JsonValues"); + } + if (value instanceof Set) { + throw new Error("Set instances are not valid JsonValues"); + } + if (value instanceof RegExp) { + throw new Error("RegExp instances are not valid JsonValues"); + } + if (Array.isArray(value)) { + for (const item of value) { + validateJsonValue(item); + } + return; + } + if (typeof value === "object" && value !== null) { + for (const key of Object.keys(value)) { + validateJsonValue((value as Record)[key]); + } + return; + } + throw new Error(`${String(value)} is not a valid JsonValue`); +} diff --git a/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt index 6847007f6..03d3d046f 100644 --- a/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt +++ b/scripts/tests/fixtures/repl-focus/frame-01.narrow.txt @@ -1,7 +1,7 @@ frame-01.narrow · 90 × 28 TRANSCRIPT · 2 / 4 REPL Tab ▸ TRANSCRIPT FOCUS MAP · F1 - 1 Sessions · 0 + 1 Sessions No executions yet. 2 Transcript 3 Bindings Submitted blocks append here as immutable entries. Each entry keep ▸ 4 REPL input diff --git a/scripts/tests/fixtures/repl-focus/frame-01.wide.txt b/scripts/tests/fixtures/repl-focus/frame-01.wide.txt index 1b82cefca..5b0e66620 100644 --- a/scripts/tests/fixtures/repl-focus/frame-01.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-01.wide.txt @@ -1,7 +1,7 @@ frame-01.wide · 200 × 50 XMD REPL │ REPL │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ TRANSCRIPT 2 Transcript No sessions yet │ 3 Bindings │ No executions yet. ▸ 4 REPL input diff --git a/scripts/tests/fixtures/repl-focus/frame-02.wide.txt b/scripts/tests/fixtures/repl-focus/frame-02.wide.txt index b97a2b24f..8329dd7af 100644 --- a/scripts/tests/fixtures/repl-focus/frame-02.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-02.wide.txt @@ -1,7 +1,7 @@ frame-02.wide · 200 × 50 XMD REPL │ REPL │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ TRANSCRIPT 2 Transcript No sessions yet │ 3 Bindings │ No executions yet. ▸ 4 REPL input diff --git a/scripts/tests/fixtures/repl-focus/frame-03.wide.txt b/scripts/tests/fixtures/repl-focus/frame-03.wide.txt index 3c8ff86fa..bb35b617c 100644 --- a/scripts/tests/fixtures/repl-focus/frame-03.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-03.wide.txt @@ -1,7 +1,7 @@ frame-03.wide · 200 × 50 XMD REPL │ REPL │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ TRANSCRIPT 2 Transcript No sessions yet │ 3 Bindings │ No executions yet. 4 REPL input diff --git a/scripts/tests/fixtures/repl-focus/frame-04.wide.txt b/scripts/tests/fixtures/repl-focus/frame-04.wide.txt index 90019177b..3d6e2d491 100644 --- a/scripts/tests/fixtures/repl-focus/frame-04.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-04.wide.txt @@ -1,7 +1,7 @@ frame-04.wide · 200 × 50 XMD REPL │ REPL │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 0 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ TRANSCRIPT 2 Transcript No sessions yet │ ▸ 3 Bindings │ No executions yet. 4 REPL input diff --git a/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt index fbe57ae71..2c764bcc9 100644 --- a/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt +++ b/scripts/tests/fixtures/repl-focus/frame-05.narrow.txt @@ -1,10 +1,10 @@ frame-05.narrow · 90 × 28 EXECUTION HISTORY · 4 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ HISTORY │ ┃ LI FOCUS MAP · F1 - 00:31 │ │ ┃ 00 1 Sessions · 1 + 00:31 │ │ ┃ 00 1 Sessions Entry 1 │ │ │ ┃ 2 Transcript ───◆────●───────────●─────────●──────────────────●·─┃ 3 Bindings - notch height is scope depth · digits mark coalesced chec 4 REPL input · R… + notch height is scope depth · digits mark coalesced chec 4 REPL input 5 Execution Hist… CHECKPOINTS ▸ 6 Pause 00:02 ◆ Entry 1 submitted REPL diff --git a/scripts/tests/fixtures/repl-focus/frame-05.wide.txt b/scripts/tests/fixtures/repl-focus/frame-05.wide.txt index 42eebae39..00845fa01 100644 --- a/scripts/tests/fixtures/repl-focus/frame-05.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-05.wide.txt @@ -1,10 +1,10 @@ frame-05.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document › Plan · active │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 1 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ Entry 1 ● running · 31.4s ↳ Plan scope open 2 Transcript SESSIONS · 1 │ 3 Bindings - chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input · Run disabled + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input │ document 5 Execution History │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running ▸ 6 Pause ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER diff --git a/scripts/tests/fixtures/repl-focus/frame-06.wide.txt b/scripts/tests/fixtures/repl-focus/frame-06.wide.txt index 128a3f173..0683673ea 100644 --- a/scripts/tests/fixtures/repl-focus/frame-06.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-06.wide.txt @@ -1,10 +1,10 @@ frame-06.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document · suspended │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 3 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │▌Entry 1 ● running · 48.9s ↳ document scope suspended ▸ 2 Transcript SESSIONS · 3 │ 3 Bindings - chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input · Run disabled + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input │ document 5 Execution History │ plan-a91f7c │ ▶ ENTER ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar diff --git a/scripts/tests/fixtures/repl-focus/frame-07.wide.txt b/scripts/tests/fixtures/repl-focus/frame-07.wide.txt index 9a1950720..c004c9cdf 100644 --- a/scripts/tests/fixtures/repl-focus/frame-07.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-07.wide.txt @@ -5,7 +5,7 @@ frame-07.wide · 200 × 50 │ Entry 1 ● running · 48.9s ↳ document scope suspended 2 Description SESSIONS · 3 │ 3 Schema disclosure · ⌥S chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Submit - │ document 5 Execution History · still … + │ document 5 Execution History │ plan-a91f7c │ ▶ ENTER ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar │ │ ● WAITING │ diff --git a/scripts/tests/fixtures/repl-focus/frame-08.wide.txt b/scripts/tests/fixtures/repl-focus/frame-08.wide.txt index 0593906f3..75eeeacf2 100644 --- a/scripts/tests/fixtures/repl-focus/frame-08.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-08.wide.txt @@ -6,7 +6,7 @@ frame-08.wide · 200 × 50 SESSIONS · 1 │ 3 Request changes chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Stop │ document 5 Submit - │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running 6 Execution History · still … + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running 6 Execution History ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER │ │ Create a project README │ │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax diff --git a/scripts/tests/fixtures/repl-focus/frame-09.wide.txt b/scripts/tests/fixtures/repl-focus/frame-09.wide.txt index 19d031327..32f603935 100644 --- a/scripts/tests/fixtures/repl-focus/frame-09.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-09.wide.txt @@ -4,7 +4,7 @@ frame-09.wide · 200 × 50 SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 README preview · scroll re… ─ │ Entry 1 ● running · 48.9s ↳ document scope suspended ▸ 2 Approve SESSIONS · 3 │ 3 Decline - chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Execution History · still … + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 Execution History │ document │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar diff --git a/scripts/tests/fixtures/repl-focus/frame-10.wide.txt b/scripts/tests/fixtures/repl-focus/frame-10.wide.txt index a517cab5d..2674a256c 100644 --- a/scripts/tests/fixtures/repl-focus/frame-10.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-10.wide.txt @@ -1,10 +1,10 @@ frame-10.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document › Plan │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 3 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript ENTRY 1 · CREATE PROJECT README │ 3 Bindings - inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input · Run disabled + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input │ Plan 5 Execution History 00:02 ◆ Entry 1 submitted │ ● ACTIVE ▸ 6 Continue 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head diff --git a/scripts/tests/fixtures/repl-focus/frame-11.wide.txt b/scripts/tests/fixtures/repl-focus/frame-11.wide.txt index b5ff00d89..53b69d415 100644 --- a/scripts/tests/fixtures/repl-focus/frame-11.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-11.wide.txt @@ -1,10 +1,10 @@ frame-11.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document › Plan │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Journal · checkpoint list ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript ENTRY 1 · CREATE PROJECT README │ 3 Bindings - inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input · Run disabled + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input │ Plan ▸ 5 Execution History 00:02 ◆ Entry 1 submitted │ ● ACTIVE 6 Continue 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head diff --git a/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt index 6b969012c..862621387 100644 --- a/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt +++ b/scripts/tests/fixtures/repl-focus/frame-12.narrow.txt @@ -1,12 +1,12 @@ frame-12.narrow · 90 × 28 EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue FOCUS MAP · F1 - 00:53 ││ ││┃ 00:53 1 Journal · chec… - Entry 1 ││ │ ││┃ 2 Transcript · r… + 00:53 ││ ││┃ 00:53 1 Sessions + Entry 1 ││ │ ││┃ 2 Transcript ─◆●─●●───2─≈●─●●┃ 3 Bindings - ▲ 00:12 · snapped · 41.0s before head 4 REPL input · R… + ▲ 00:12 · snapped · 41.0s before head 4 REPL input 5 Execution Hist… - CHECKPOINTS 6 Continue · dis… + CHECKPOINTS 6 Continue 00:02 ◆ Entry 1 submitted REPL 7 Return to paus… 00:05 ● document scope entered Entry ▸ 8 Fork from here 00:12 ● Plan entered Entry diff --git a/scripts/tests/fixtures/repl-focus/frame-12.wide.txt b/scripts/tests/fixtures/repl-focus/frame-12.wide.txt index 938eb2389..99ff84fd2 100644 --- a/scripts/tests/fixtures/repl-focus/frame-12.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-12.wide.txt @@ -1,12 +1,12 @@ frame-12.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Journal · checkpoint list ─ - │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript · read-only + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only 2 Transcript ENTRY 1 · CREATE PROJECT README │ 3 Bindings - inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input · Run disabled + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here 4 REPL input │ Plan 5 Execution History - 00:02 ◆ Entry 1 submitted │ ● ACTIVE 6 Continue · disabled while … + 00:02 ◆ Entry 1 submitted │ ● ACTIVE 6 Continue 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt 7 Return to paused head 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs ▸ 8 Fork from here 00:18 ● planning inputs prepared │ │ ✓ SETTLED diff --git a/scripts/tests/fixtures/repl-focus/frame-13.wide.txt b/scripts/tests/fixtures/repl-focus/frame-13.wide.txt index 30ff271fa..7e9f855ae 100644 --- a/scripts/tests/fixtures/repl-focus/frame-13.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-13.wide.txt @@ -1,10 +1,10 @@ frame-13.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document · suspended │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 3 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ Entry 1 ● running · 48.9s ↳ document scope suspended 2 Transcript SESSIONS · 3 │ 3 Bindings - chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input · Run disabled + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor 4 REPL input │ document 5 Execution History │ plan-a91f7c │ ▶ ENTER ▸ 6 Pause ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details diff --git a/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt index ca11ce6e5..ebf7766ea 100644 --- a/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt +++ b/scripts/tests/fixtures/repl-focus/frame-14.narrow.txt @@ -1,7 +1,7 @@ frame-14.narrow · 90 × 28 TRANSCRIPT · 2 / 4 REPL · Entry 1 settled Tab ▸ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines FOCUS MAP · F1 - 1 Sessions · 3 + 1 Sessions Create a project README 2 Transcript Provide the project name and a one-sentence description. 3 Bindings │ MARKDOWN ▸ 4 REPL input diff --git a/scripts/tests/fixtures/repl-focus/frame-14.wide.txt b/scripts/tests/fixtures/repl-focus/frame-14.wide.txt index f840ff4e2..665c4d8b7 100644 --- a/scripts/tests/fixtures/repl-focus/frame-14.wide.txt +++ b/scripts/tests/fixtures/repl-focus/frame-14.wide.txt @@ -1,7 +1,7 @@ frame-14.wide · 200 × 50 XMD REPL │ REPL · Entry 1 settled │ FOCUS MAP · F1 - SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions · 3 ─ + SESSION JOURNAL STATE │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 Sessions ─ │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines 2 Transcript SESSIONS · 3 │ 3 Bindings persist after settling │ Create a project README ▸ 4 REPL input diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index 2dc3fc7fa..3a25124cb 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -1,16 +1,16 @@ /** - * The route and the focus model, checked against the approved focus study. + * The route, and the tree that owns focus. * - * The study states fourteen frames as numbered target lists with a focused - * number and a `meta` record naming what Tab and Shift+Tab do from there. That - * is the acceptance source, so most of this suite is the same question asked of - * every frame: rebuild the state from its URL, and ask the registry, the map - * and the ring whether they agree with the study. + * #839's first attempt kept a flat `FocusTarget[]` beside the interface and + * rebuilt traversal, ownership and restoration by hand. Every case it wrote + * passed, because a list compared against itself always agrees. What it could + * not do was answer a question about where a control actually *is* — so the + * cases here are chosen to be ones a flat registry cannot satisfy: a key's + * path through its ancestors' middleware, a branch that stops existing, and an + * overlay that is the tree rather than a copy of it. * - * Two claims are driven as **bytes** rather than as synthetic events, because - * synthetic events are what hid the defects this slice repairs. A lone `ESC` - * never reached the harness at a real keyboard, and a real Shift+Tab arrives as - * `Backtab` with no shift flag — both were handled, tested, and unreachable. + * Two claims are still driven as **bytes**, because synthetic events are what + * hid the decoder defects this slice repairs. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -25,18 +25,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { captureFocus, captureText, PROFILE_SIZES, renderFrame } from "../repl-study/capture.ts"; -import { FRAMES, frame, stateFor } from "../repl-study/frames.ts"; -import type { StudyFrame } from "../repl-study/frames.ts"; -import { - counterpartOf, - focusMap, - mapOrder, - numbering, - ownerOf, - registry, - resolve, - step, -} from "../repl-study/focus.ts"; +import { FRAMES, frame, stateFor, useFrame } from "../repl-study/frames.ts"; import { scanKeys } from "../repl-study/host.ts"; import { fold, JOURNAL, journalThrough, markers, siblingsOf } from "../repl-study/journal.ts"; import { @@ -46,20 +35,19 @@ import { ROUTE_SURFACES, surfaceFor, } from "../repl-study/route.ts"; -import type { Route } from "../repl-study/route.ts"; import { fixtureFor, - focusIn, hydrate, layoutOf, - mapOf, openDrawer, projection, - reduce, - targets, viewOf, } from "../repl-study/store.ts"; import type { HarnessEvent, ReplState, Size } from "../repl-study/store.ts"; +import { drive } from "../repl-study/drive.ts"; +import { overlayOf, surfaceOwning, useReplTree, walk } from "../repl-study/tree.ts"; +import type { ReplTree } from "../repl-study/tree.ts"; +import { sendKey } from "../repl-study/keys.ts"; import type { Mutation } from "../repl-study/mutations.ts"; const ROOT = fileURLToPath(new URL("../../", import.meta.url)); @@ -73,31 +61,32 @@ function context(size: Size, mutation?: Mutation) { return { size, mutation, scrollLimit: 40 }; } -/** One keystroke, as the decoder would report it. */ function key(code: string, extra: Record = {}): HarnessEvent { return { kind: "key", event: { type: "keydown", key: code, code, ...extra } }; } -function press(state: ReplState, code: string, size: Size = WIDE, mutation?: Mutation): ReplState { - return reduce(state, key(code), context(size, mutation)); +/** One state and the tree that renders it, built from a URL and a journal. */ +function* opened( + url: string, + head: string | undefined, +): Operation<{ + state: ReplState; + tree: ReplTree; +}> { + const state = hydrate(url, journalThrough(head)); + const tree = yield* useReplTree(state); + return { state, tree }; } -/** The identities in the ring, in the order Tab walks them. */ -function ring(state: ReplState, size: Size = WIDE, mutation?: Mutation): string[] { - return targets(state, size, mutation).map((target) => target.id); +/** The identities Tab walks, in tree order. */ +function chain(tree: ReplTree): string[] { + return tree.chain().map((node) => node.name); } function bytes(...codes: number[]): Uint8Array { return Uint8Array.from(codes); } -/** - * Feed raw bytes to the harness's own decoding path. - * - * Nothing synthesises an event here: the escape sequence goes in and whatever - * the decoder produces comes out, including whatever the pending flush - * eventually releases. - */ function* decoded(input: Input, chunk: Uint8Array, mutation?: Mutation): Operation { const events: InputEvent[] = []; yield* scanKeys(input, chunk, (event) => events.push(event), mutation); @@ -111,10 +100,9 @@ describe("the URL that says where you are", () => { for (const subject of FRAMES) { const parsed = parseRoute(subject.url); expect({ id: subject.id, ok: parsed.ok }).toEqual({ id: subject.id, ok: true }); - if (!parsed.ok) { - continue; + if (parsed.ok) { + expect(formatRoute(parsed.value)).toBe(subject.url); } - expect(formatRoute(parsed.value)).toBe(subject.url); } }); @@ -136,39 +124,24 @@ describe("the URL that says where you are", () => { draft: "", }); - const refusals = [ + for (const url of [ "https://repl/e1/transcript", "xmd://repl/e1/nowhere", "xmd://repl//transcript", "xmd://repl/e1/transcript/+project/plan", "xmd://repl/e1/transcript?zoom=2", "xmd://repl/e1/transcript?at=", - // `inspect` reconstructs a marker, so it cannot arrive without one, and - // it has exactly one spelling. "xmd://repl/e1/transcript?inspect", "xmd://repl/e1/transcript?at=cp-04&inspect=yes", - ]; - for (const url of refusals) { - const result = parseRoute(url); - expect({ url, ok: result.ok }).toEqual({ url, ok: false }); + ]) { + expect({ url, ok: parseRoute(url).ok }).toEqual({ url, ok: false }); } }); - it("spells the live head exactly one way", function* () { - // There is no `at=head` sentinel, so two URLs cannot render the same state - // and hydrate differently. - const following = hydrate("xmd://repl/e1/history", journalThrough("cp-18")); - expect(following.route.at).toBeUndefined(); - expect(following.moment.transport).toBe("paused"); - }); - it("says selecting a marker and reconstructing it separately", function* () { - // The scrubber's selection is canonical location; whether the - // reconstruction is open is a different question about the same marker. const selected = hydrate("xmd://repl/e1/history?at=cp-04", journalThrough("cp-18")); expect(selected.selection).toBeGreaterThanOrEqual(0); expect(selected.moment.transport).toBe("paused"); - const reconstructed = hydrate( "xmd://repl/e1/history?at=cp-04&inspect", journalThrough("cp-18"), @@ -177,54 +150,28 @@ describe("the URL that says where you are", () => { expect(reconstructed.moment.transport).toBe("inspecting"); }); - it("keeps a drawer from being mistaken for a scope of the same name", function* () { - const parsed = parseRoute("xmd://repl/e1/transcript/project/+project"); - expect(parsed.ok).toBe(true); - if (!parsed.ok) { - return; - } - expect(parsed.value.scopes).toEqual(["project"]); - expect(parsed.value.drawers).toEqual(["project"]); - }); - it("names a surface for every region focus can be in", function* () { expect([...ROUTE_SURFACES]).toEqual(["sessions", "transcript", "bindings", "input", "history"]); }); }); describe("every frame of the approved focus study", () => { - it("rebuilds each frame's targets, numbering and focus from its URL", function* () { + it("builds each frame's targets and numbering out of the live tree", function* () { for (const subject of FRAMES) { - const state = stateFor(subject); - const layout = layoutOf(state, WIDE); - const map = focusMap(state, layout); - const numbers = numbering(map); - // The study's overlay draws only the focused target when the map is off, - // and numbers every visible one when it is on. - const ordered = mapOrder(map); + const { tree } = yield* useFrame(subject); + const entries = overlayOf(tree); + // With the overlay off the study draws only the focused target. const shown = subject.overlay - ? ordered - : ordered.filter((target) => target.id === subject.focus); + ? entries + : entries.filter((entry) => entry.id === subject.focus); expect({ frame: subject.id, - targets: shown.map((target) => ({ - n: numbers.get(target.id), - id: target.id, - kind: target.kind, - })), + targets: shown.map((entry) => ({ n: entry.number, id: entry.id })), }).toEqual({ frame: subject.id, - targets: subject.targets.map((target) => ({ - n: target.n, - id: target.id, - kind: target.kind, - })), - }); - expect({ frame: subject.id, fixture: state.moment.shows }).toEqual({ - frame: subject.id, - fixture: subject.fixture, + targets: subject.targets.map((target) => ({ n: target.n, id: target.id })), }); - expect({ frame: subject.id, focus: focusIn(state, WIDE) }).toEqual({ + expect({ frame: subject.id, focus: tree.focused().name }).toEqual({ frame: subject.id, focus: subject.focus, }); @@ -233,90 +180,167 @@ describe("every frame of the approved focus study", () => { it("moves where the study says Tab and Shift+Tab move", function* () { for (const subject of FRAMES) { - const live = targets(stateFor(subject), WIDE); - expect({ frame: subject.id, tab: step(subject.focus, live, 1) }).toEqual({ + const forward = yield* useFrame(subject); + forward.tree.advance(); + expect({ frame: subject.id, tab: forward.tree.focused().name }).toEqual({ frame: subject.id, tab: subject.tab, }); - expect({ frame: subject.id, shift: step(subject.focus, live, -1) }).toEqual({ + const reverse = yield* useFrame(subject); + reverse.tree.retreat(); + expect({ frame: subject.id, shift: reverse.tree.focused().name }).toEqual({ frame: subject.id, shift: subject.shift, }); } }); - it("drives the real reducer to where the study says it goes", function* () { - // The transition, not two destinations built independently. A reducer that - // only changed focus would still satisfy a check that constructed each - // frame from its own URL. + it("drives the real keys through the real tree", function* () { + // The transition, not two destinations built independently. for (const subject of FRAMES) { - const state = stateFor(subject); - const forward = press(state, "Tab"); - expect({ frame: subject.id, tab: focusIn(forward, WIDE) }).toEqual({ + const { state, tree } = yield* useFrame(subject); + yield* drive(tree, state, key("Tab"), context(WIDE)); + expect({ frame: subject.id, tab: tree.focused().name }).toEqual({ frame: subject.id, tab: subject.tab, }); - const reverse = press(state, "Backtab"); - expect({ frame: subject.id, shift: focusIn(reverse, WIDE) }).toEqual({ - frame: subject.id, - shift: subject.shift, - }); } }); it("takes the URL with it whenever focus changes region", function* () { for (const subject of FRAMES) { - const state = stateFor(subject); - for (const [name, moved] of [ - ["tab", press(state, "Tab")], - ["shift", press(state, "Backtab")], - ] as const) { - const landed = focusIn(moved, WIDE); - const expected = surfaceFor(landed); - expect({ frame: subject.id, key: name, surface: moved.route.surface }).toEqual({ - frame: subject.id, - key: name, - surface: expected ?? moved.route.surface, - }); - } + const { state, tree } = yield* useFrame(subject); + const driven = yield* drive(tree, state, key("Tab"), context(WIDE)); + const landed = surfaceOwning(tree.focused()); + expect({ frame: subject.id, surface: driven.state.route.surface }).toEqual({ + frame: subject.id, + surface: landed ?? driven.state.route.surface, + }); } }); it("leaves the URL behind when focus is allowed to move without it", function* () { - const state = stateFor(frame("02")!); - const moved = press(state, "Tab", WIDE, "keep-route-on-focus"); - expect(focusIn(moved, WIDE)).toBe("region:history"); - expect(moved.route.surface).toBe("input"); - // Which is exactly the divergence: a cold start comes back somewhere else. - expect(hydrate(formatRoute(moved.route), moved.journal).focus).toBe("region:input"); + const { state, tree } = yield* useFrame(frame("02")!); + const driven = yield* drive(tree, state, key("Tab"), context(WIDE, "keep-route-on-focus")); + expect(tree.focused().name).toBe("region:history"); + expect(driven.state.route.surface).toBe("input"); }); it("walks the whole ring in both directions and comes back to the start", function* () { for (const subject of FRAMES) { - const live = targets(stateFor(subject), WIDE); - let forward = subject.focus; - const visited: string[] = []; - for (let at = 0; at < live.length; at += 1) { - forward = step(forward, live, 1); - visited.push(forward); + const { tree } = yield* useFrame(subject); + const size = tree.chain().length; + for (let at = 0; at < size; at += 1) { + tree.advance(); } - expect({ frame: subject.id, at: forward }).toEqual({ frame: subject.id, at: subject.focus }); - expect({ frame: subject.id, seen: new Set(visited).size }).toEqual({ + expect({ frame: subject.id, at: tree.focused().name }).toEqual({ frame: subject.id, - seen: live.length, + at: subject.focus, }); - let back = subject.focus; - for (let at = 0; at < live.length; at += 1) { - back = step(back, live, -1); - } - expect({ frame: subject.id, at: back }).toEqual({ frame: subject.id, at: subject.focus }); } }); +}); + +describe("input reaches the focused node through its ancestors", () => { + it("passes through the panel and the drawer that contain it", function* () { + // A flat registry has no way to produce this: the path is the tree's. + const { tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.target).toBe("field:drawer.project.name"); + expect(delivery.path).toEqual(["drawer:project", "panel:project.body"]); + }); + + it("passes through the region that owns a transport control", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + void state; + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.target).toBe("control:transport.continue"); + expect(delivery.path).toEqual(["region:history"]); + }); + + it("stops reaching a control whose branch was removed", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + const field = tree.chain().find((node) => node.name === "field:drawer.project.name")!; + const closed = hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal); + yield* tree.sync(closed); + // The node object still exists in this test's hand; the tree does not hold + // it, nothing can focus it, and no middleware path reaches it any more. + expect(chain(tree)).not.toContain("field:drawer.project.name"); + expect(walk(tree.root.node).map((node) => node.name)).not.toContain("drawer:project"); + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.path).not.toContain("drawer:project"); + expect(delivery.target).not.toBe(field.name); + }); +}); - it("keeps the drawer's trap closed, with the footer inside it", function* () { +describe("branches, and what closing one destroys", () => { + it("adds a nested panel's focusables in tree order", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1", "cp-14"); + const before = chain(tree); + const deeper = hydrate("xmd://repl/e1/transcript/entry-1/document/+project", state.journal); + yield* tree.sync(deeper); + expect(chain(tree)).toEqual([ + "field:drawer.project.name", + "field:drawer.project.description", + "control:drawer.project.schema", + "control:drawer.project.submit", + "region:history", + ]); + expect(before).not.toEqual(chain(tree)); + }); + + it("destroys the whole branch when it closes", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + const names = () => walk(tree.root.node).map((node) => node.name); + expect(names()).toContain("panel:project.body"); + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); + for (const gone of [ + "drawer:project", + "panel:project.body", + "field:drawer.project.name", + "control:drawer.project.submit", + ]) { + expect({ gone, present: names().includes(gone) }).toEqual({ gone, present: false }); + } + }); + + it("keeps a closed drawer's controls alive when the branch is not removed", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + yield* tree.sync( + hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), + "keep-closed-branch", + ); + expect(walk(tree.root.node).map((node) => node.name)).toContain("field:drawer.project.name"); + }); + + it("keeps focus across a sync, because the tree is reconciled and not rebuilt", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + expect(tree.focused().name).toBe("control:transport.continue"); + yield* tree.sync(state); + expect(tree.focused().name).toBe("control:transport.continue"); + }); + + it("loses focus when every node is rebuilt on each sync", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + yield* tree.sync(state, "rebuild-tree-each-sync"); + expect(tree.focused().name).not.toBe("control:transport.continue"); + }); +}); + +describe("drawers trap traversal and restore outward", () => { + it("traps the ring in the top drawer, with the footer inside it", function* () { for (const subject of FRAMES.filter((one) => one.meta.trap)) { - const state = stateFor(subject); - const ids = ring(state); + const { tree } = yield* useFrame(subject); + const ids = chain(tree); expect({ frame: subject.id, last: ids[ids.length - 1] }).toEqual({ frame: subject.id, last: "region:history", @@ -328,179 +352,237 @@ describe("every frame of the approved focus study", () => { } }); - it("lets Tab escape the trap when the ring is rebuilt from the panes", function* () { - const state = stateFor(frame("07")!); - const leaked = ring(state, WIDE, "leak-drawer-trap"); - expect(leaked).toContain("region:transcript"); - expect(leaked).not.toContain("field:drawer.project.name"); + it("restores first to the outer drawer, then to the invoking control", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + // Focus somewhere recognisable before anything is pushed. + yield* drive(tree, state, key("2"), context(WIDE)); + const invoker = tree.focused().name; + expect(invoker).toBe("region:transcript"); + + const outer = openDrawer(state, "project", invoker); + yield* tree.sync(outer); + expect(tree.focused().name).toBe("field:drawer.project.name"); + + const inner = openDrawer(outer, "confirm", tree.focused().name); + yield* tree.sync(inner); + expect(chain(tree)).toEqual([ + "control:drawer.confirm.preview", + "control:drawer.confirm.approve", + "control:drawer.confirm.decline", + "region:history", + ]); + + yield* tree.sync(outer); + expect(tree.focused().name).toBe("field:drawer.project.name"); + yield* tree.sync(state); + expect(tree.focused().name).toBe(invoker); }); - it("numbers a disabled control in the map and skips it in the ring", function* () { - const state = stateFor(frame("12")!); - const map = mapOf(state, WIDE).map((target) => target.id); - expect(map).toContain("control:transport.continue"); - expect(ring(state)).not.toContain("control:transport.continue"); + it("lets Tab escape the trap when the branch is not pushed as a focus root", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + yield* tree.sync( + hydrate("xmd://repl/e1/transcript/entry-1/document/+project", state.journal), + "leak-drawer-trap", + ); + expect(chain(tree)).toContain("region:transcript"); }); - it("admits a disabled control into the ring when the two lists are conflated", function* () { - const state = stateFor(frame("12")!); - expect(ring(state, WIDE, "focus-hidden-target")).toContain("control:transport.continue"); + it("leaves focus behind when the drawer's push is never popped", function* () { + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + yield* tree.sync( + hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), + "forget-drawer-invoker", + ); + expect(tree.focused().name).not.toBe("region:transcript"); }); }); -describe("restoring focus when a target disappears", () => { - it("walks to the nearest surviving owner", function* () { - // Study frame 12: the reconstruction removed the drawer of frame 07, so its - // trapped controls left the sequence. - const suspended = stateFor(frame("07")!); - const reconstructed = hydrate( - "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", - suspended.journal, - ); - expect(resolve("field:drawer.project.name", targets(reconstructed, WIDE))).toBe( - "region:transcript", +describe("removing the focused node", () => { + it("selects a surviving node before teardown", function* () { + const { state, tree } = yield* useFrame(frame("10")!); + expect(tree.focused().name).toBe("control:transport.continue"); + // Resuming removes the paused transport and mounts the live one. + const live = hydrate(formatRoute(state.route), journalThrough("cp-19")); + yield* tree.sync(live); + expect(chain(tree)).toContain(tree.focused().name); + expect(tree.focused().name).not.toBe("control:transport.continue"); + }); + + it("selects a survivor when the branch above the focused node goes", function* () { + // Freedom's own middleware asked whether the *removed node* was focused; + // a drawer is closed by removing the branch above the focused control. + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", ); + expect(tree.focused().name).toBe("field:drawer.project.name"); + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); + expect(tree.focused().name).not.toBe(""); + expect(chain(tree)).toContain(tree.focused().name); }); +}); - it("prefers a transport control's live counterpart over its owner", function* () { - expect(counterpartOf("control:transport.continue")).toBe("control:transport.pause"); - const live = targets(stateFor(frame("13")!), WIDE); - expect(resolve("control:transport.continue", live)).toBe("control:transport.pause"); +describe("background updates", () => { + const streaming = (): HarnessEvent => ({ + kind: "background", + record: { + marker: "cp-live", + at: 50, + kind: "session.started", + scope: "document", + detail: "review-b72e1d", + shows: "drawer", + }, }); - it("reads ownership from the identity, so a target that is gone still has one", function* () { - expect(ownerOf("control:transport.fork")).toBe("region:history"); - expect(ownerOf("control:input.run")).toBe("region:input"); - expect(ownerOf("field:drawer.project.name")).toBe("region:transcript"); - expect(ownerOf("region:history")).toBeUndefined(); + it("changes nothing about where the person is", function* () { + const { state, tree } = yield* useFrame(frame("06")!); + const before = tree.focused().name; + const driven = yield* drive(tree, state, streaming(), context(WIDE)); + expect(tree.focused().name).toBe(before); + expect(driven.state.route).toBe(state.route); + expect(driven.state.journal.length).toBe(state.journal.length + 1); }); - it("falls back to the first target when the chain is exhausted", function* () { - const live = targets(stateFor(frame("02")!), WIDE); - expect(resolve("control:nothing.at.all", live)).toBe("region:sessions"); + it("is rejected when the update moves focus", function* () { + const { state, tree } = yield* useFrame(frame("06")!); + const before = tree.focused().name; + yield* drive(tree, state, streaming(), context(WIDE, "steal-focus-on-background")); + expect(tree.focused().name).not.toBe(before); }); }); -describe("drawers, their trap and what they restore", () => { - const opened = (): ReplState => { - const base = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - return openDrawer(base, "project", "region:transcript", WIDE); - }; - - it("puts focus on the drawer's first meaningful control", function* () { - expect(opened().focus).toBe("field:drawer.project.name"); +describe("a disabled control is drawn and never focusable", () => { + it("numbers Continue in the overlay and keeps it out of the chain", function* () { + const { tree } = yield* useFrame(frame("12")!); + const entries = overlayOf(tree); + const continues = entries.find((entry) => entry.id === "control:transport.continue"); + expect(continues?.enabled).toBe(false); + expect(chain(tree)).not.toContain("control:transport.continue"); + expect(entries.map((entry) => entry.id)).toContain("control:transport.continue"); }); - it("keeps only the top of a nested stack interactive", function* () { - const nested = openDrawer(opened(), "confirm", "field:drawer.project.name", WIDE); - expect(nested.route.drawers).toEqual(["project", "confirm"]); - const ids = ring(nested); - expect(ids).toEqual([ - "control:drawer.confirm.preview", - "control:drawer.confirm.approve", - "control:drawer.confirm.decline", - "region:history", - ]); + it("admits it to the chain when a disabled control is made focusable", function* () { + const { state, tree } = yield* useFrame(frame("12")!); + yield* tree.sync(state, "focus-hidden-target"); + expect(chain(tree)).toContain("control:transport.continue"); }); +}); - it("restores the identity that invoked it when Escape closes it", function* () { - const nested = openDrawer(opened(), "confirm", "field:drawer.project.name", WIDE); - const closed = press(nested, "Escape"); - expect(closed.route.drawers).toEqual(["project"]); - expect(closed.focus).toBe("field:drawer.project.name"); - const outer = press(closed, "Escape"); - expect(outer.route.drawers).toEqual([]); - expect(outer.focus).toBe("region:transcript"); +describe("the overlay is the tree", () => { + it("matches the live tree exactly, node for node", function* () { + for (const subject of FRAMES) { + const { tree } = yield* useFrame(subject); + const fromTree = tree + .map() + .map((node) => node.name) + .sort(); + const fromOverlay = overlayOf(tree) + .map((entry) => entry.id) + .sort(); + expect({ frame: subject.id, fromOverlay }).toEqual({ + frame: subject.id, + fromOverlay: fromTree, + }); + } }); - it("closes the drawer without answering it", function* () { - // The study's frame 09 gives the confirmation drawer `esc declines`. - // Navigation is what this experiment owns, so Escape closes and answers - // nothing: the suspension is still waiting afterwards. - const state = stateFor(frame("09")!); - const closed = press(state, "Escape"); - expect(closed.route.drawers).toEqual([]); - expect(closed.moment.suspension).toBe("confirm"); + it("follows the tree into a drawer rather than numbering the panes behind it", function* () { + const { tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + expect(overlayOf(tree).map((entry) => entry.id)).toEqual([ + "field:drawer.project.name", + "field:drawer.project.description", + "control:drawer.project.schema", + "control:drawer.project.submit", + "region:history", + ]); }); - it("leaves focus where it was when the invoker is forgotten", function* () { - const closed = press(opened(), "Escape", WIDE, "forget-drawer-invoker"); - expect(closed.focus).toBe("field:drawer.project.name"); - expect(closed.route.drawers).toEqual([]); + it("goes on numbering the panes when the overlay is kept beside the tree", function* () { + const { tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + expect(overlayOf(tree, "flat-overlay").map((entry) => entry.id)).toContain("region:transcript"); }); }); describe("inspecting a recorded moment", () => { - const paused = (): ReplState => - hydrate("xmd://repl/e1/history/entry-1/document", journalThrough("cp-18")); - - const inspecting = (): ReplState => - hydrate( - "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", - journalThrough("cp-18"), - ); + const paused = () => opened("xmd://repl/e1/history/entry-1/document", "cp-18"); + const inspecting = () => + opened("xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", "cp-18"); it("refuses a mutation while a reconstruction is open", function* () { - const state = { ...inspecting(), focus: "region:input" }; - const typed = press(state, "x"); - expect(typed.route.draft).toBe(""); - expect(typed).toBe(state); + const { state, tree } = yield* inspecting(); + yield* drive(tree, state, key("4"), context(WIDE)); + const typed = yield* drive(tree, state, key("x"), context(WIDE)); + expect(typed.state.route.draft).toBe(""); }); it("permits that mutation when the read-only rule is removed", function* () { - const state = { ...inspecting(), focus: "region:input" }; - expect(press(state, "x", WIDE, "mutate-while-inspecting").route.draft).toBe("x"); + const { state, tree } = yield* inspecting(); + const focused = yield* drive(tree, state, key("4"), context(WIDE)); + const typed = yield* drive( + tree, + focused.state, + key("x"), + context(WIDE, "mutate-while-inspecting"), + ); + expect(typed.state.route.draft).toBe("x"); }); it("keeps every recorded marker visible, including the ones after it", function* () { - const state = inspecting(); - const checkpoints = fixtureFor(state).history.checkpoints; - const later = checkpoints.filter((point) => point.at > state.moment.at); + const { state } = yield* inspecting(); + const later = fixtureFor(state).history.checkpoints.filter( + (point) => point.at > state.moment.at, + ); expect(later.length).toBeGreaterThan(0); }); it("withholds Continue until the paused head is regained", function* () { - expect(ring(inspecting())).not.toContain("control:transport.continue"); - const returned = press({ ...inspecting(), focus: "control:transport.return-head" }, "Enter"); - expect(returned.route.inspect).toBe(false); + const { state, tree } = yield* inspecting(); + // Enter the footer, so its controls exist to be walked. + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + expect(chain(tree)).not.toContain("control:transport.continue"); + tree.advance(); + expect(tree.focused().name).toBe("control:transport.return-head"); + const returned = yield* drive(tree, entered.state, key("Enter"), context(WIDE)); + expect(returned.state.route.inspect).toBe(false); // Closing the reconstruction is not deselecting the marker. - expect(returned.route.at).toBe("cp-04"); - expect(ring({ ...returned, focus: "region:history" })).toContain("control:transport.continue"); + expect(returned.state.route.at).toBe("cp-04"); }); it("holds the transport slot across freezing and resuming", function* () { - // Study frame 13: leaving history with Continue focused lands on Pause. - const held = { ...paused(), focus: "control:transport.continue" }; - expect(focusIn(held, WIDE)).toBe("control:transport.continue"); - const resumed = press(held, "Enter"); - expect(resumed.moment.transport).toBe("live"); - expect(focusIn(resumed, WIDE)).toBe("control:transport.pause"); + const { state, tree } = yield* paused(); + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + tree.advance(); + expect(tree.focused().name).toBe("control:transport.continue"); + const resumed = yield* drive(tree, entered.state, key("Enter"), context(WIDE)); + expect(resumed.state.moment.transport).toBe("live"); + // `Continue` is gone; the live counterpart is what the footer now offers, + // and focus is on a node that exists. + expect(chain(tree)).toContain("control:transport.pause"); + expect(chain(tree)).toContain(tree.focused().name); }); }); describe("the selected marker is location", () => { - const paused = (): ReplState => - hydrate("xmd://repl/e1/history/entry-1/document", journalThrough("cp-18")); - it("writes the scrubber's selection into the URL, by replacing", function* () { - const state = { ...paused(), focus: "region:history" }; - const scrubbed = press(state, "ArrowLeft"); - expect(scrubbed.route.at).toBeDefined(); - expect(scrubbed.route.inspect).toBe(false); - expect(scrubbed.history.length).toBe(state.history.length); + const { state, tree } = yield* opened("xmd://repl/e1/history/entry-1/document", "cp-18"); + const focused = yield* drive(tree, state, key("5"), context(WIDE)); + const scrubbed = yield* drive(tree, focused.state, key("ArrowLeft"), context(WIDE)); + expect(scrubbed.state.route.at).toBeDefined(); + expect(scrubbed.state.route.inspect).toBe(false); + expect(scrubbed.state.history.length).toBe(focused.state.history.length); + expect(navigationFor("scrub")).toBe("replace"); }); it("comes back to the same marker, scope and bindings from the URL alone", function* () { - // Study frame 11: a marker is selected and the reconstruction is not open. const selected = stateFor(frame("11")!); expect(selected.selection).toBeGreaterThanOrEqual(0); const rebuilt = hydrate(formatRoute(selected.route), selected.journal); - expect(rebuilt.selection).toBe(selected.selection); - const rebuiltProjection = projection(rebuilt); - expect(rebuiltProjection.selected).toBe("cp-16"); - expect(rebuiltProjection.selectedScope).toBe(projection(selected).selectedScope); - expect(rebuiltProjection.selectedPublished).toEqual(projection(selected).selectedPublished); - expect(rebuiltProjection).toEqual(projection(selected)); + expect(projection(rebuilt)).toEqual(projection(selected)); + expect(projection(rebuilt).selected).toBe("cp-16"); }); it("loses the selection when it is kept outside the URL", function* () { @@ -511,22 +593,11 @@ describe("the selected marker is location", () => { "drop-selection-on-hydrate", ); expect(rebuilt.selection).toBe(-1); - expect(rebuilt.selection).not.toBe(selected.selection); - }); - - it("selects without reconstructing, and reconstructs on Enter", function* () { - const scrubbed = press({ ...paused(), focus: "region:history" }, "ArrowLeft"); - expect(scrubbed.moment.transport).toBe("paused"); - const inspected = press(scrubbed, "Enter"); - expect(inspected.route.inspect).toBe(true); - expect(inspected.route.at).toBe(scrubbed.route.at); - expect(inspected.moment.transport).toBe("inspecting"); }); }); describe("structural navigation across siblings", () => { - const settled = (): ReplState => - hydrate("xmd://repl/e1/transcript/entry-1/document/plan", journalThrough("cp-22")); + const settled = () => opened("xmd://repl/e1/transcript/entry-1/document/plan", "cp-22"); it("reads the sibling list out of the journal, in source order", function* () { expect(siblingsOf(JOURNAL, ["document"])).toEqual(["plan", "preview", "write"]); @@ -534,184 +605,152 @@ describe("structural navigation across siblings", () => { }); it("moves to the next and previous sibling, and takes the URL with it", function* () { - const state = settled(); - const next = reduce(state, key("ArrowRight", { ctrl: true }), context(WIDE)); - expect(next.route.scopes).toEqual(["entry-1", "document", "preview"]); - const after = reduce(next, key("ArrowRight", { ctrl: true }), context(WIDE)); - expect(after.route.scopes).toEqual(["entry-1", "document", "write"]); - const back = reduce(after, key("ArrowLeft", { ctrl: true }), context(WIDE)); - expect(back.route.scopes).toEqual(["entry-1", "document", "preview"]); - // Each is a place you went, so each is a place Back returns from. - expect(after.history.length).toBe(state.history.length + 2); - }); - - it("wraps at both ends of the sibling list", function* () { - const first = settled(); - const wrapped = reduce(first, key("ArrowLeft", { ctrl: true }), context(WIDE)); - expect(wrapped.route.scopes).toEqual(["entry-1", "document", "write"]); + const { state, tree } = yield* settled(); + const next = yield* drive(tree, state, key("ArrowRight", { ctrl: true }), context(WIDE)); + expect(next.state.route.scopes).toEqual(["entry-1", "document", "preview"]); + const after = yield* drive(tree, next.state, key("ArrowRight", { ctrl: true }), context(WIDE)); + expect(after.state.route.scopes).toEqual(["entry-1", "document", "write"]); + const back = yield* drive(tree, after.state, key("ArrowLeft", { ctrl: true }), context(WIDE)); + expect(back.state.route.scopes).toEqual(["entry-1", "document", "preview"]); }); it("moves out to the parent and in to the first child", function* () { - const out = reduce(settled(), key("ArrowUp", { ctrl: true }), context(WIDE)); - expect(out.route.scopes).toEqual(["entry-1", "document"]); - const back = reduce(out, key("ArrowDown", { ctrl: true }), context(WIDE)); - expect(back.route.scopes).toEqual(["entry-1", "document", "plan"]); + const { state, tree } = yield* settled(); + const out = yield* drive(tree, state, key("ArrowUp", { ctrl: true }), context(WIDE)); + expect(out.state.route.scopes).toEqual(["entry-1", "document"]); + const back = yield* drive(tree, out.state, key("ArrowDown", { ctrl: true }), context(WIDE)); + expect(back.state.route.scopes).toEqual(["entry-1", "document", "plan"]); }); it("never intercepts a modified arrow out of a draft somebody is typing", function* () { - const typing = { ...settled(), focus: "region:input" }; - const moved = reduce(typing, key("ArrowRight", { ctrl: true }), context(WIDE)); - expect(moved.route.scopes).toEqual(typing.route.scopes); + const { state, tree } = yield* settled(); + const typing = yield* drive(tree, state, key("4"), context(WIDE)); + const moved = yield* drive( + tree, + typing.state, + key("ArrowRight", { ctrl: true }), + context(WIDE), + ); + expect(moved.state.route.scopes).toEqual(typing.state.route.scopes); }); it("leaves the arrows inert when the sibling list is ignored", function* () { - const state = settled(); - const moved = reduce( + const { state, tree } = yield* settled(); + const moved = yield* drive( + tree, state, key("ArrowRight", { ctrl: true }), context(WIDE, "inert-sibling-arrows"), ); - expect(moved.route.scopes).toEqual(state.route.scopes); + expect(moved.state.route.scopes).toEqual(state.route.scopes); }); }); describe("push versus replace", () => { - const start = (): ReplState => - hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const start = () => opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); it("replaces the URL while a draft is typed", function* () { - let state: ReplState = { ...start(), focus: "region:input" }; - const before = state.history.length; + const { state, tree } = yield* start(); + let driven = yield* drive(tree, state, key("4"), context(WIDE)); + const before = driven.state.history.length; for (const glyph of ["a", "b", "c"]) { - state = press(state, glyph); + driven = yield* drive(tree, driven.state, key(glyph), context(WIDE)); } - expect(state.route.draft).toBe("abc"); - expect(state.history.length).toBe(before); + expect(driven.state.route.draft).toBe("abc"); + expect(driven.state.history.length).toBe(before); expect(navigationFor("draft")).toBe("replace"); }); - it("pushes one entry for a drawer and one for entering inspection", function* () { - const drawer = openDrawer(start(), "project", "region:transcript", WIDE); - expect(drawer.history.length).toBe(1); - const scrubbed = press({ ...drawer, focus: "region:history" }, "ArrowLeft"); - const inspected = press(scrubbed, "Enter"); - expect(inspected.route.at).toBeDefined(); - expect(inspected.history.length).toBe(2); - expect(navigationFor("drawer")).toBe("push"); - expect(navigationFor("inspection")).toBe("push"); - }); - - it("returns Back to the head rather than through every scrubbed marker", function* () { - let state = press({ ...start(), focus: "region:history" }, "ArrowLeft"); - state = press(state, "Enter"); - const entered = state.history.length; - for (let at = 0; at < 6; at += 1) { - state = press(state, "ArrowLeft"); - } - expect(state.history.length).toBe(entered); - expect(navigationFor("scrub")).toBe("replace"); - const back = press(state, "Escape"); - expect(back.route.inspect).toBe(false); - }); - it("fills the navigation stack when every keystroke pushes", function* () { - let state: ReplState = { ...start(), focus: "region:input" }; + const { state, tree } = yield* start(); + let driven = yield* drive(tree, state, key("4"), context(WIDE, "push-draft-edits")); + const before = driven.state.history.length; for (const glyph of ["a", "b", "c"]) { - state = press(state, glyph, WIDE, "push-draft-edits"); + driven = yield* drive(tree, driven.state, key(glyph), context(WIDE, "push-draft-edits")); } - expect(state.history.length).toBe(3); + expect(driven.state.history.length).toBe(before + 3); }); }); -describe("background updates", () => { - const streaming = (): HarnessEvent => ({ - kind: "background", - record: { - marker: "cp-live", - at: 50, - kind: "session.started", - scope: "document", - detail: "review-b72e1d", - shows: "drawer", - }, +describe("Ctrl+C, three ways", () => { + it("interrupts a running entry, and a paused or reconstructed one", function* () { + for (const [url, head] of [ + ["xmd://repl/e1/transcript/entry-1/document", "cp-06"], + ["xmd://repl/e1/history/entry-1/document", "cp-18"], + ["xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", "cp-18"], + ] as const) { + const { state, tree } = yield* opened(url, head); + const driven = yield* drive(tree, state, key("c", { ctrl: true }), context(WIDE)); + expect({ url, quit: driven.state.quit, interrupts: driven.state.interrupts }).toEqual({ + url, + quit: false, + interrupts: 1, + }); + } }); - it("changes nothing about where the person is", function* () { - // Study frame 06. Reference equality, not deep equality: a reducer that - // rebuilt an equal route would pass a deep comparison having already lost - // the property this is about. - const before = stateFor(frame("06")!); - const after = reduce(before, streaming(), context(WIDE)); - expect(after.route).toBe(before.route); - expect(after.focus).toBe(before.focus); - expect(after.selection).toBe(before.selection); - expect(after.anchor).toBe(before.anchor); - expect(after.journal.length).toBe(before.journal.length + 1); + it("exits from a paused entry when only a live one counts as active", function* () { + const { state, tree } = yield* opened("xmd://repl/e1/history/entry-1/document", "cp-18"); + const driven = yield* drive( + tree, + state, + key("c", { ctrl: true }), + context(WIDE, "exit-on-paused-interrupt"), + ); + expect(driven.state.quit).toBe(true); }); - it("is rejected when the update moves focus", function* () { - const before = stateFor(frame("06")!); - const after = reduce(before, streaming(), context(WIDE, "steal-focus-on-background")); - expect(after.focus).not.toBe(before.focus); + it("clears a draft, then leaves, when nothing is running", function* () { + const withDraft = yield* opened("xmd://repl/e1/input?draft=%3CPlan%3E", "cp-22"); + const cleared = yield* drive( + withDraft.tree, + withDraft.state, + key("c", { ctrl: true }), + context(WIDE), + ); + expect(cleared.state.route.draft).toBe(""); + expect(cleared.state.quit).toBe(false); + + const empty = yield* opened("xmd://repl/e1/input", "cp-22"); + const left = yield* drive(empty.tree, empty.state, key("c", { ctrl: true }), context(WIDE)); + expect(left.state.quit).toBe(true); }); }); describe("rebuilding from the URL and the journal alone", () => { - /** A long interaction: typing, traversal, a drawer, inspection and back. */ - function journey(): ReplState { - let state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - state = press(state, "4"); + function* journey(): Operation { + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + let driven = yield* drive(tree, state, key("4"), context(WIDE)); for (const glyph of ["<", "P", "l", "a", "n", ">"]) { - state = press(state, glyph); + driven = yield* drive(tree, driven.state, key(glyph), context(WIDE)); } - state = press(state, "Tab"); - state = press(state, "Backtab"); - state = openDrawer(state, "project", "region:transcript", WIDE); - state = press(state, "Tab"); - state = press(state, "Escape"); - state = press(state, "5"); - state = press(state, "ArrowLeft"); - state = press(state, "ArrowLeft"); - state = press(state, "Enter"); - state = press(state, "ArrowLeft"); - return state; + driven = yield* drive(tree, driven.state, key("Tab"), context(WIDE)); + driven = yield* drive(tree, driven.state, key("5"), context(WIDE)); + driven = yield* drive(tree, driven.state, key("ArrowLeft"), context(WIDE)); + driven = yield* drive(tree, driven.state, key("Enter"), context(WIDE)); + return driven.state; } it("comes back to the same semantic state with nothing else", function* () { - const original = journey(); - expect(original.route.at).toBeDefined(); + const original = yield* journey(); expect(original.route.draft).toBe(""); - expect(original.history.length).toBeGreaterThan(0); - const rebuilt = hydrate(formatRoute(original.route), original.journal); expect(projection(rebuilt)).toEqual(projection(original)); }); - it("lands the rebuilt state on a legitimate target", function* () { - const rebuilt = hydrate(formatRoute(journey().route), journey().journal); - const live = targets(rebuilt, WIDE); - expect(live.map((target) => target.id)).toContain(focusIn(rebuilt, WIDE)); - }); - it("throws away the disposable half rather than pretending to restore it", function* () { - const original = journey(); + const original = yield* journey(); const rebuilt = hydrate(formatRoute(original.route), original.journal); expect(rebuilt.anchor).toBe(0); expect(rebuilt.history).toEqual([]); - expect(rebuilt.overlay).toBe(false); - // The selection is not in that half. It is location, so it comes back. expect(rebuilt.selection).toBe(original.selection); - expect(rebuilt.selection).toBeGreaterThanOrEqual(0); }); it("folds the journal rather than reading the fixtures", function* () { - // The journal is authored by hand from the study. A journal derived from - // `fixtures.ts` would make this comparison the fixtures against themselves. const moment = fold(journalThrough("cp-08")); expect(moment.scope).toBe("plan"); expect(moment.published).toEqual(["inputs", "draft"]); expect(moment.suspension).toBe("review"); - expect(moment.sessions).toBe(2); expect(markers(JOURNAL).length).toBe(JOURNAL.length); }); }); @@ -719,100 +758,59 @@ describe("rebuilding from the URL and the journal alone", () => { describe("the same route at two profiles", () => { it("says the same thing wide and narrow", function* () { for (const subject of FRAMES) { - const state = stateFor(subject); + const { state, tree } = yield* useFrame(subject); const before = projection(state); - // The route is not where the profile is recorded, so composing it two - // ways cannot lose it — which is a claim about a state that has actually - // been through both compositions, not about one that was asked twice. expect(layoutOf(state, WIDE).profile).toBe("wide"); expect(layoutOf(state, NARROW).profile).toBe("narrow"); - let moved = reduce(state, { kind: "resize", ...NARROW }, context(NARROW)); - moved = reduce(moved, { kind: "resize", ...WIDE }, context(WIDE)); - expect({ frame: subject.id, after: projection(moved) }).toEqual({ + let moved = yield* drive(tree, state, { kind: "resize", ...NARROW }, context(NARROW)); + moved = yield* drive(tree, moved.state, { kind: "resize", ...WIDE }, context(WIDE)); + expect({ frame: subject.id, after: projection(moved.state) }).toEqual({ frame: subject.id, after: before, }); - expect({ frame: subject.id, focus: focusIn(state, NARROW) }).toEqual({ - frame: subject.id, - focus: focusIn(state, WIDE), - }); - } - }); - - it("composes every frame at both profiles", function* () { - for (const subject of FRAMES) { - const state = stateFor(subject); - for (const size of [WIDE, NARROW]) { - const rendered = yield* renderFrame({ - fixture: fixtureFor(state), - view: viewOf(state), - size, - focus: { here: focusIn(state, size), map: mapOf(state, size), overlay: true }, - }); - expect({ frame: subject.id, drew: rendered.text.trim().length > 0 }).toEqual({ - frame: subject.id, - drew: true, - }); - } } }); - it("keeps the route across a resize", function* () { - const state = stateFor(frame("07")!); - const resized = reduce(state, { kind: "resize", cols: 90, rows: 28 }, context(NARROW)); - expect(resized.route).toBe(state.route); - expect(formatRoute(resized.route)).toBe(frame("07")!.url); - }); - it("loses the route when a resize rebuilds it from the profile", function* () { - const state = stateFor(frame("07")!); - const resized = reduce( + const { state, tree } = yield* useFrame(frame("07")!); + const moved = yield* drive( + tree, state, - { kind: "resize", cols: 90, rows: 28 }, + { kind: "resize", ...NARROW }, context(NARROW, "drop-route-on-resize"), ); - expect(formatRoute(resized.route)).not.toBe(frame("07")!.url); - }); - - it("has nothing to focus on a terminal too small to compose one", function* () { - const state = stateFor(frame("07")!); - expect(focusMap(state, layoutOf(state, PROFILE_SIZES["too-small"]))).toEqual([]); + expect(formatRoute(moved.state.route)).not.toBe(frame("07")!.url); }); }); describe("through a real decoder", () => { it("delivers a lone Escape only after the pending flush", function* () { - const input: Input = yield* until(createInput({})); - const immediate = input.scan(bytes(ESC)); - // The defect, stated as the library states it: the event list is empty and - // the caller is asked to come back. - expect(immediate.events).toEqual([]); - expect(immediate.pending?.delay).toBeGreaterThan(0); + const immediate: Input = yield* until(createInput({})); + const scanned = immediate.scan(bytes(ESC)); + expect(scanned.events).toEqual([]); + expect(scanned.pending?.delay).toBeGreaterThan(0); const flushed = yield* decoded(yield* until(createInput({})), bytes(ESC)); - expect(flushed.map((event) => event.type)).toEqual(["keydown"]); expect(flushed.map((event) => ("code" in event ? event.code : ""))).toEqual(["Escape"]); }); it("acts on the Escape those bytes produced", function* () { const input: Input = yield* until(createInput({})); const events = yield* decoded(input, bytes(ESC)); - let state = openDrawer( - hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")), - "project", - "region:transcript", - WIDE, + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", ); + let driven = { state }; for (const event of events) { - state = reduce(state, { kind: "key", event }, context(WIDE)); + driven = yield* drive(tree, driven.state, { kind: "key", event }, context(WIDE)); } - expect(state.route.drawers).toEqual([]); + expect(driven.state.route.drawers).toEqual([]); }); it("swallows every Escape when the pending flush is dropped", function* () { const input: Input = yield* until(createInput({})); - const events = yield* decoded(input, bytes(ESC), "swallow-pending-escape"); - expect(events).toEqual([]); + expect(yield* decoded(input, bytes(ESC), "swallow-pending-escape")).toEqual([]); }); it("reads a real Shift+Tab, which arrives as Backtab with no shift flag", function* () { @@ -823,85 +821,34 @@ describe("through a real decoder", () => { expect("code" in event ? event.code : "").toBe("Backtab"); expect("shift" in event ? event.shift : undefined).toBeUndefined(); - const state = stateFor(frame("03")!); - let moved = state; + const subject = frame("03")!; + const { state, tree } = yield* useFrame(subject); for (const decodedEvent of events) { - moved = reduce(moved, { kind: "key", event: decodedEvent }, context(WIDE)); + yield* drive(tree, state, { kind: "key", event: decodedEvent }, context(WIDE)); } - expect(moved.focus).toBe(frame("03")!.shift); + expect(tree.focused().name).toBe(subject.shift); }); it("traverses forward when only a synthetic Tab+shift counts as reverse", function* () { const input: Input = yield* until(createInput({})); const events = yield* decoded(input, bytes(ESC, 0x5b, 0x5a)); - let moved = stateFor(frame("03")!); + const subject = frame("03")!; + const { state, tree } = yield* useFrame(subject); for (const event of events) { - moved = reduce(moved, { kind: "key", event }, context(WIDE, "ignore-backtab")); + yield* drive(tree, state, { kind: "key", event }, context(WIDE, "ignore-backtab")); } - expect(moved.focus).toBe(frame("03")!.tab); + expect(tree.focused().name).toBe(subject.tab); }); it("decodes the modified arrows structural navigation is specified on", function* () { const input: Input = yield* until(createInput({})); const events = yield* decoded(input, bytes(ESC, 0x5b, 0x31, 0x3b, 0x35, 0x41)); - expect(events.length).toBe(1); const [event] = events; expect("code" in event ? event.code : "").toBe("ArrowUp"); expect("ctrl" in event ? event.ctrl : undefined).toBe(true); }); }); -describe("Ctrl+C, three ways", () => { - const running = (): ReplState => - hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-06")); - - it("interrupts the entry that is running, and stays open", function* () { - const state = running(); - const interrupted = press(state, "c", WIDE, undefined); - expect(interrupted).toBe(state); - const control = reduce(state, key("c", { ctrl: true }), context(WIDE)); - expect(control.quit).toBe(false); - expect(control.interrupts).toBe(1); - }); - - it("interrupts a paused entry, and one being read through a reconstruction", function* () { - // A paused entry is still an entry. Exiting instead of interrupting it - // hands its lifecycle to whoever closed the terminal. - for (const id of ["10", "12"]) { - const state = stateFor(frame(id)!); - const control = reduce(state, key("c", { ctrl: true }), context(WIDE)); - expect({ frame: id, quit: control.quit, interrupts: control.interrupts }).toEqual({ - frame: id, - quit: false, - interrupts: 1, - }); - } - }); - - it("exits from a paused entry when only a live one counts as active", function* () { - const state = stateFor(frame("10")!); - const control = reduce( - state, - key("c", { ctrl: true }), - context(WIDE, "exit-on-paused-interrupt"), - ); - expect(control.quit).toBe(true); - expect(control.interrupts).toBe(0); - }); - - it("clears a draft when nothing is running", function* () { - const settled = hydrate("xmd://repl/e1/input?draft=%3CPlan%3E", journalThrough("cp-22")); - const cleared = reduce(settled, key("c", { ctrl: true }), context(WIDE)); - expect(cleared.route.draft).toBe(""); - expect(cleared.quit).toBe(false); - }); - - it("leaves when the draft is empty and nothing is running", function* () { - const settled = hydrate("xmd://repl/e1/input", journalThrough("cp-22")); - expect(reduce(settled, key("c", { ctrl: true }), context(WIDE)).quit).toBe(true); - }); -}); - describe("the frames, as pictures", () => { it("renders every committed focus capture exactly", function* () { const captures = yield* captureFocus(); @@ -914,17 +861,15 @@ describe("the frames, as pictures", () => { it("draws the focused region and the numbered map", function* () { const subject = frame("12")!; - const state = stateFor(subject); + const { state, tree } = yield* useFrame(subject); const rendered = yield* renderFrame({ fixture: fixtureFor(state), view: viewOf(state), size: WIDE, - focus: { here: focusIn(state, WIDE), map: mapOf(state, WIDE), overlay: true }, + focus: { here: tree.focused().name, map: overlayOf(tree), overlay: true }, }); expect(rendered.text).toContain("FOCUS MAP"); expect(rendered.text).toContain("Fork from here"); - expect(rendered.text).toContain("Continue · disabled while"); - expect(rendered.text).toContain("▸ 8"); }); it("says nothing about focus in a frame that was not asked about it", function* () { @@ -946,9 +891,6 @@ describe("the documented command", () => { "--frame 07 --focus-map", ]) { const result = yield* exec(`deno run --allow-all ${MAIN} ${argument}`, { cwd: ROOT }).join(); - // There is no terminal here, so the harness refuses interactive mode — - // which is the proof that the invocation was understood rather than - // rejected at the command line. expect({ argument, code: result.code }).toEqual({ argument, code: 2 }); expect(`${result.stdout}${result.stderr}`).toContain("--capture"); } @@ -960,9 +902,39 @@ describe("the documented command", () => { }).join(); expect(bad.code).toBe(2); expect(bad.stdout).toContain("is not a surface"); + }); +}); - const missing = yield* exec(`deno run --allow-all ${MAIN} --frame 99`, { cwd: ROOT }).join(); - expect(missing.code).toBe(2); - expect(missing.stdout).toContain("--frame needs one of"); +describe("the vendored Freedom snapshot", () => { + const VENDOR = fileURLToPath(new URL("../repl-study/vendor/freedom/", import.meta.url)); + + it("matches the bytes its manifest records", function* () { + const manifest = JSON.parse(yield* readTextFile(join(VENDOR, "MANIFEST.json"))); + const digest = function* (path: string): Operation { + const text = yield* readTextFile(join(VENDOR, path)); + const bytes = new TextEncoder().encode(text); + const hash = yield* until(crypto.subtle.digest("SHA-256", bytes)); + return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join(""); + }; + for (const [path, recorded] of Object.entries(manifest.files)) { + expect({ path, sha256: yield* digest(path) }).toEqual({ path, sha256: recorded }); + } + }); + + it("names the upstream commit and every file it patched", function* () { + const manifest = JSON.parse(yield* readTextFile(join(VENDOR, "MANIFEST.json"))); + expect(manifest.upstream.commit).toBe("8be97e7201cd6effddb2f8b240b4b5166641e7f0"); + expect(manifest.upstream.repository).toBe("https://github.com/bombshell-dev/playground"); + const patched = new Set(manifest.patches.map((patch: { file: string }) => patch.file)); + expect([...patched].sort()).toEqual([ + "upstream/lib/focus.ts", + "upstream/lib/mod.ts", + "upstream/lib/node.ts", + "upstream/lib/root.ts", + ]); + for (const patch of manifest.patches) { + expect(typeof patch.reason).toBe("string"); + expect(patch.reason.length).toBeGreaterThan(30); + } }); }); From 81f9ec813cfeb6ef6cc9745654edb9361be77e68 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 10:47:21 -0400 Subject: [PATCH 08/57] =?UTF-8?q?=F0=9F=A9=B9=20Let=20a=20branch=20consume?= =?UTF-8?q?=20a=20key,=20and=20keep=20the=20tree's=20order=20canonical=20(?= =?UTF-8?q?#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two architecture findings against c2923dae, both reproduced before anything changed. **A branch could not consume a key.** `drive()` recorded the dispatch path and then reduced the same event globally regardless, so middleware on the focused node's ancestor path could intercept Escape and watch the drawer close anyway. The hierarchy was annotating the dispatch instead of governing it. `keydown` now reports whether it was handled, and a handled key ends there — no fallback runs it. Proved both ways: a drawer that consumes Escape stays open, and the same drawer with nothing installed closes through the fallback. **A live tree and a rebuilt one disagreed about order.** A replacement is appended wherever there is room, so a control that changed from enabled to disabled ended up last: entering inspection from frame 11 gave `Return → Fork → Continue` live and `Continue → Return → Fork` from the same URL and journal. Every node was present in both, and the reconstruction boundary was still broken. Reconciling now restores the canonical order by sorting the region's children, and the evidence drives frame 11 into inspection, throws the store and the tree away, and compares the ordered topology. Ancestry is no longer reconstructed from identity strings: `ownerRegion()` is gone, Back emits an intent the tree resolves by walking parents, and the route follows `surfaceOwning()` rather than a parsed prefix. `RESULT-focus.md` states the line — an identity may address a route, and may not answer where a node is. The add-before-remove guarantee is kept, and focus is asserted to name a surviving node after replacements and after branch teardown. Two controls: append-replacements, and the consumed-key case that fails if the fallback runs anyway. --- scripts/repl-study/RESULT-focus.md | 60 +++++++++++++-- scripts/repl-study/drive.ts | 53 ++++++++++++-- scripts/repl-study/keys.ts | 47 ++++++------ scripts/repl-study/mutations.ts | 2 + scripts/repl-study/store.ts | 34 +++------ scripts/repl-study/tree.ts | 26 ++++++- scripts/tests/repl-focus.test.ts | 113 ++++++++++++++++++++++++++++- 7 files changed, 270 insertions(+), 65 deletions(-) diff --git a/scripts/repl-study/RESULT-focus.md b/scripts/repl-study/RESULT-focus.md index b80cfbfde..3b2ab7b82 100644 --- a/scripts/repl-study/RESULT-focus.md +++ b/scripts/repl-study/RESULT-focus.md @@ -108,9 +108,9 @@ list compared with itself always agrees. What it could not do was answer a question about where a control actually *is*, and three of this slice's cases are exactly those questions. -**Input goes to the focused node, not to the application.** A key is invoked on -`current(root).scope`, so Effection walks that scope's ancestors and every -branch between the root and the control runs its middleware in order. The +**Input goes to the focused node, and stops where it is consumed.** A key is +invoked on `current(root).scope`, so Effection walks that scope's ancestors and +every branch between the root and the control runs its middleware in order. The evidence reads the path rather than inferring it: ```text @@ -120,6 +120,37 @@ path drawer:project → panel:project.body A flat registry has no way to produce that: the path is the tree's. +**A branch that consumes a key ends the dispatch.** `keydown` returns whether it +was handled; middleware that handles one returns `true` without calling `next`, +and the harness's own fallback does not run. An earlier round recorded the path +and then reduced the same event globally regardless — so a drawer could +intercept Escape and watch the drawer close anyway. That is the difference +between a hierarchy that *annotates* a dispatch and one that *governs* it, and +only the second is worth having. + +## What identities may and may not be used for + +Nodes carry semantic names — `region:transcript`, `control:transport.pause`, +`field:drawer.project.name` — and the line between a legitimate use and a +forbidden one is worth stating exactly, because this experiment crossed it twice +before getting it right. + +**Permitted: annotating routing.** The route's surface segment is one of five +names, and a digit key naming the region to jump to, or a frame table declaring +which node it focuses, are addresses. They say *what to look for*; the tree is +what says whether it is there and where. + +**Forbidden: deriving ancestry or input targeting.** Which region owns a +control, which branch a key passes through, and what focus falls back to when a +node disappears are all questions about where a node *is*. They are answered by +walking the live tree — `surfaceOwning()` climbs parents, the dispatch path is +Effection's own scope chain — never by parsing a prefix out of a name. An +identity string cannot be wrong about its own spelling but can easily be wrong +about the tree, and a second answer is precisely what this architecture removes. + +The earlier `ownerOf()` and `ownerRegion()` helpers, which read ownership out of +the identity, are gone. + **Closing a branch destroys it.** A drawer closes by removing its node; its controls, its body panel and their middleware go with it through structured teardown. Afterwards nothing in the tree can be focused, and no dispatch reaches @@ -172,11 +203,24 @@ recorded against it; `vendor/freedom/PROVENANCE.md` has the detail. control, so the common case left focus on a node that had just been destroyed while a perfectly good sibling survived. -A third thing is this harness's own, and worth stating because it is the same -mistake in miniature: **a reconciler must add before it removes.** Removing the -focused control before its replacement exists leaves the region with nothing to -move focus to, and focus lands outside it — which is how a resumed run first -lost its transport slot. +Two more are this harness's own, and both are the same mistake in miniature — +letting the order things happened to happen in stand in for the order that was +meant. + +**A reconciler must add before it removes.** Removing the focused control +before its replacement exists leaves the region with nothing to move focus to, +and focus lands outside it — which is how a resumed run first lost its transport +slot. + +**And it must then restore the canonical order.** A replacement is appended +wherever there is room, so a control that changed from enabled to disabled ended +up last. The tree a live interaction arrived at and the tree a cold start +rebuilt from the same URL and journal then disagreed — `Return → Fork → +Continue` against `Continue → Return → Fork` — which breaks the reconstruction +boundary even though every node was present in both. Sorting the region's +children by their canonical index after reconciling settles it, and the evidence +drives frame 11 into inspection, throws the store and the tree away, and rebuilds +to compare the ordered topology. ## Divergences from the study, named rather than hidden diff --git a/scripts/repl-study/drive.ts b/scripts/repl-study/drive.ts index 02bbbe98f..8bcfb7196 100644 --- a/scripts/repl-study/drive.ts +++ b/scripts/repl-study/drive.ts @@ -6,9 +6,10 @@ * architecture, in five lines: * * 1. the key is delivered to the focused node, so every branch between the root - * and it runs its middleware; - * 2. the store decides what the event means, told where focus is rather than - * keeping its own answer; + * and it runs its middleware — and if one of them **consumes** it, that is + * the end: no fallback runs a key the hierarchy already answered; + * 2. otherwise the store decides what the event means, told where focus is + * rather than keeping its own answer; * 3. the tree carries out whatever the store decided about focus; * 4. the tree is brought into line with the new state, mounting and removing * branches; @@ -21,12 +22,31 @@ import type { Operation } from "effection"; import { asKey, followFocus, reduce } from "./store.ts"; import type { HarnessEvent, ReduceContext, ReplState } from "./store.ts"; import type { FocusIntent } from "./store.ts"; -import { focus as focusNode } from "./tree.ts"; +import { focus as focusNode, surfaceOwning } from "./tree.ts"; import type { ReplTree } from "./tree.ts"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; import { sendKey } from "./keys.ts"; import type { Delivery } from "./keys.ts"; import type { Mutation } from "./mutations.ts"; +/** + * The nearest focusable ancestor of a node, read off the live tree. + * + * Ownership is a question about where a node *is*, so it is asked of the tree. + * Reconstructing it by parsing the identity string would be a second answer, + * and a second answer is what this architecture exists to remove. + */ +function ownerOf(tree: ReplTree, node: Node): Node | undefined { + const reachable = tree.chain(); + for (let at = node.parent; at; at = at.parent) { + const found = reachable.find((candidate) => candidate === at); + if (found) { + return found; + } + } + return undefined; +} + /** Carry out what the store decided about focus. The tree performs it. */ export function applyFocus(tree: ReplTree, intent: FocusIntent | undefined): void { if (intent === undefined) { @@ -40,8 +60,14 @@ export function applyFocus(tree: ReplTree, intent: FocusIntent | undefined): voi tree.retreat(); return; } - const wanted = intent.kind === "to" ? intent.identity : undefined; - const target = tree.chain().find((node) => node.name === wanted); + if (intent.kind === "owner") { + const owner = ownerOf(tree, tree.focused()); + if (owner) { + focusNode(owner); + } + return; + } + const target = tree.chain().find((node) => node.name === intent.identity); if (target) { focusNode(target); } @@ -65,10 +91,23 @@ export function drive( event.kind === "key" ? sendKey(tree.root.node, tree.focused(), asKey(event.event)) : undefined; + if (delivery?.handled === true) { + // A branch on the live ancestor path claimed it. Running the fallback + // anyway is exactly the defect this ordering exists to prevent: the + // hierarchy would be annotating the dispatch instead of governing it. + return { state, delivery }; + } const reduced = reduce(state, event, { ...context, focused: tree.focused().name }); applyFocus(tree, reduced.focus); yield* tree.sync(reduced.state, context.mutation); - const followed = followFocus(reduced.state, tree.focused().name, "focus", context.mutation); + // Which surface owns focus is read off the tree, not parsed out of the + // focused node's name. + const followed = followFocus( + reduced.state, + surfaceOwning(tree.focused()), + "focus", + context.mutation, + ); yield* tree.sync(followed, context.mutation); return { state: followed, delivery }; }, diff --git a/scripts/repl-study/keys.ts b/scripts/repl-study/keys.ts index be2b828e2..455ea370c 100644 --- a/scripts/repl-study/keys.ts +++ b/scripts/repl-study/keys.ts @@ -1,17 +1,17 @@ /** - * A keystroke goes to the node that has focus, not to the application. + * A keystroke goes to the node that has focus, and stops where it is consumed. * - * The first attempt reduced every key globally: one `reduce()` read the key, - * looked focus up in a flat list, and decided. Nothing about where the focused - * control actually *was* could take part in that decision, so a drawer could - * not intercept a key for its own controls without the global reducer being - * taught about drawers. + * The key is invoked on the focused node's scope, so Effection walks that + * scope's ancestors and every branch between the root and the control — the + * drawer, the panel, the surface — runs its middleware in order. Any of them may + * **consume** the key by not calling `next`, and a consumed key goes no further: + * no fallback runs it afterwards. * - * Here the key is invoked on the focused node's scope. Effection walks that - * scope's ancestors, so every branch between the root and the control — the - * drawer, the panel, the surface — gets its middleware run in order, and any of - * them can handle the key or pass it on. The path is the tree's, and it is - * recorded so the evidence can read it rather than infer it. + * That last sentence is the whole point, and an earlier round of this + * experiment got it wrong. The path was recorded and then the same event was + * reduced globally regardless, so a branch could intercept a key and watch the + * global behavior happen anyway — the hierarchy annotated the dispatch instead + * of governing it. * * `packages/input/src/lib/input.ts` at the pinned Bombshell commit is the * reference this follows. @@ -29,14 +29,16 @@ const PathContext = createContext("xmd:repl:key-path"); /** * One keystroke, delivered to a node. * - * Middleware installed on a branch's scope sees every key bound for anything - * inside it, which is what makes a drawer able to answer for its own controls. + * The return value is whether the key was **handled**. The default is `false`: + * nothing between the root and the node claimed it, so the harness's own + * fallback may run it. Middleware that handles a key returns `true` without + * calling `next`. */ export const KeyboardApi = createApi("xmd:repl:keyboard", { - keydown(node: Node, key: Key): void { - // The default: nothing between the root and the node claimed it. + keydown(node: Node, key: Key): boolean { void node; void key; + return false; }, }); @@ -44,11 +46,12 @@ export const KeyboardApi = createApi("xmd:repl:keyboard", { * Record this branch on the path of every key that passes through it. * * Installed by `tree.ts` when a branch is mounted, and gone when the branch is - * removed — which is the whole of why a closed panel cannot receive input. + * removed — which is the whole of why a closed panel cannot receive input. It + * passes every key on: recording is not handling. */ export function recordPath(node: Node, name: string): void { node.scope.around(KeyboardApi, { - keydown([target, key], next): void { + keydown([target, key], next): boolean { node.scope.get(PathContext)?.push(name); return next(target, key); }, @@ -60,20 +63,22 @@ export interface Delivery { readonly target: string; /** The branches it passed through, outermost first. */ readonly path: readonly string[]; + /** True when something on that path claimed the key. Nothing else may run it. */ + readonly handled: boolean; } /** * Send one key to whichever node has focus. * * The path is collected on the root's scope rather than returned by the - * middleware, because a middleware that had to return it could not also pass - * the key on unchanged. + * middleware, because a middleware that had to return it could not also use its + * return value to say whether it handled the key. */ export function sendKey(root: Node, focused: Node, key: Key): Delivery { const path: string[] = []; root.scope.set(PathContext, path); - KeyboardApi.invoke(focused.scope, "keydown", [focused, key]); + const handled = KeyboardApi.invoke(focused.scope, "keydown", [focused, key]); // Ancestors run outermost first, so the recorded order is already the path // from the root down to the node. - return { target: focused.name, path }; + return { target: focused.name, path, handled: handled === true }; } diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index cbacfa244..4a0142f37 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -32,6 +32,8 @@ export const MUTATIONS = [ "steal-focus-on-background", /** Rebuild the whole tree on every sync instead of reconciling it. */ "rebuild-tree-each-sync", + /** Leave a replaced control wherever it was appended, losing canonical order. */ + "append-replacements", /** Close a drawer without removing its branch, so its controls survive. */ "keep-closed-branch", /** Number the overlay from a static list instead of walking the tree. */ diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts index 639970469..ed8813102 100644 --- a/scripts/repl-study/store.ts +++ b/scripts/repl-study/store.ts @@ -23,7 +23,7 @@ import { layoutFor, SURFACES } from "./layout.ts"; import type { Layout, SurfaceName } from "./layout.ts"; import type { FixtureName, Fixture, TransportMode } from "./model.ts"; import type { Mutation } from "./mutations.ts"; -import { formatRoute, navigationFor, parseRoute, surfaceFor, topDrawer } from "./route.ts"; +import { formatRoute, navigationFor, parseRoute, topDrawer } from "./route.ts"; import type { Route, RouteChange, RouteSurface } from "./route.ts"; /** @@ -303,17 +303,16 @@ function extendTo(state: ReplState, kind: JournalRecord["kind"]): ReplState { * Take the route to wherever focus now is. * * Focus itself is the tree's and never appears here. What the route records is - * the *surface* focus landed in, because the surface segment is what says which - * region owns focus — so a focus move that crosses a region boundary is a move - * the URL has to make too, in the same transition. + * the *surface* focus landed in, and the caller reads that off the tree — this + * does not work it out from an identity string. A focus move that crosses a + * region boundary is a move the URL has to make too, in the same transition. */ export function followFocus( state: ReplState, - identity: string, + surface: RouteSurface | undefined, change: RouteChange = "focus", mutation?: Mutation, ): ReplState { - const surface = surfaceFor(identity); if ( surface === undefined || surface === state.route.surface || @@ -499,10 +498,9 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon const surface = (["sessions", "transcript", "bindings", "input", "history"] as const)[ digit - 1 ]; - const identity = `region:${surface}`; return { - state: followFocus(state, identity, "surface", mutation), - focus: { kind: "to", identity }, + state: followFocus(state, surface, "surface", mutation), + focus: { kind: "to", identity: `region:${surface}` }, }; } @@ -564,11 +562,9 @@ function back(state: ReplState, here: string, size: Size, mutation?: Mutation): return { state: go(state, { ...state.route, inspect: false }, "inspection", mutation) }; } if (!here.startsWith("region:")) { - const owner = ownerRegion(here); - return { - state: followFocus(state, owner, "focus", mutation), - focus: { kind: "to", identity: owner }, - }; + // Which region owns this control is the tree's to answer; the route follows + // once the tree has moved, in the same transition. + return { state, focus: { kind: "owner" } }; } const previous = state.history[state.history.length - 1]; if (previous === undefined) { @@ -590,16 +586,6 @@ function back(state: ReplState, here: string, size: Size, mutation?: Mutation): }; } -function ownerRegion(identity: string): string { - if (identity.startsWith("control:transport.")) { - return "region:history"; - } - if (identity.startsWith("control:input.")) { - return "region:input"; - } - return "region:transcript"; -} - /** Enter: what the focused target does when it is activated. */ function activate(state: ReplState, here: string, mutation?: Mutation): ReplState { if (here === "control:transport.pause") { diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 0dc54ab61..9646d579d 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -134,6 +134,18 @@ export function useReplTree(state: ReplState): Operation { * on each sync would destroy the node focus is on and take focus with * it, which is the defect a live tree exists to avoid. */ + /** + * Bring one region's controls into line, by name. + * + * Two things have to hold at once, and an earlier round held only the + * first. **Surviving nodes keep their identity**, so focus and the + * middleware installed on them survive a sync — that is why this + * reconciles rather than rebuilds. And **the rendered order is + * canonical**, so the tree a live interaction arrives at is the tree a + * cold start rebuilds from the same URL and journal. Replacements are + * appended wherever there is room, so the order is restored explicitly + * rather than left to the order things happened to be created in. + */ const reconcile = function* ( parent: Node, wanted: readonly Control[], @@ -145,9 +157,9 @@ export function useReplTree(state: ReplState): Operation { control.enabled || mutation === "focus-hidden-target"; // Additions come first. Removing the focused control before its - // replacement exists would leave the tree with nothing in that region - // to move focus to, and focus would land outside it — which is how a - // resumed run lost its transport slot. + // replacement exists would leave the region with nothing to move focus + // to, and focus would land outside it — which is how a resumed run once + // lost its transport slot. let present = childrenByName(); for (const control of wanted) { if (!present.has(control.name)) { @@ -182,6 +194,14 @@ export function useReplTree(state: ReplState): Operation { focusable(replacement); } } + + const order = new Map(wanted.map((control, at) => [control.name, at] as const)); + if (mutation === "append-replacements") { + // Leave the order to however the nodes happened to be created, which + // is what makes a live tree and a rebuilt one disagree. + return; + } + parent.sort((one, other) => (order.get(one.name) ?? 0) - (order.get(other.name) ?? 0)); }; const mountControls = function* (next: ReplState, mutation?: Mutation): Operation { diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index 3a25124cb..84dbce353 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -45,9 +45,10 @@ import { } from "../repl-study/store.ts"; import type { HarnessEvent, ReplState, Size } from "../repl-study/store.ts"; import { drive } from "../repl-study/drive.ts"; -import { overlayOf, surfaceOwning, useReplTree, walk } from "../repl-study/tree.ts"; +import { focus as focusNode } from "../repl-study/tree.ts"; +import { find, overlayOf, surfaceOwning, useReplTree, walk } from "../repl-study/tree.ts"; import type { ReplTree } from "../repl-study/tree.ts"; -import { sendKey } from "../repl-study/keys.ts"; +import { KeyboardApi, sendKey } from "../repl-study/keys.ts"; import type { Mutation } from "../repl-study/mutations.ts"; const ROOT = fileURLToPath(new URL("../../", import.meta.url)); @@ -276,6 +277,114 @@ describe("input reaches the focused node through its ancestors", () => { }); }); +describe("a branch may consume a key, and then nothing else runs it", () => { + const suspended = () => opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); + + it("stops at the branch that claimed it, and the fallback never fires", function* () { + const { state, tree } = yield* suspended(); + const drawer = find(tree.root.node, "drawer:project")!; + drawer.scope.around(KeyboardApi, { + keydown([node, pressed], _next): boolean { + void node; + void pressed; + return true; + }, + }); + const driven = yield* drive(tree, state, key("Escape"), context(WIDE)); + expect(driven.delivery?.handled).toBe(true); + // The path stops at the branch that consumed it — the body panel below it + // never ran. + expect(driven.delivery?.path).toEqual(["drawer:project"]); + // And the drawer is still open: Escape's global meaning did not happen. + expect(driven.state.route.drawers).toEqual(["project"]); + }); + + it("reaches the fallback and closes the drawer when nothing claims it", function* () { + const { state, tree } = yield* suspended(); + const driven = yield* drive(tree, state, key("Escape"), context(WIDE)); + expect(driven.delivery?.handled).toBe(false); + expect(driven.delivery?.path).toEqual(["drawer:project", "panel:project.body"]); + expect(driven.state.route.drawers).toEqual([]); + }); + + it("asks the tree which region owns a control, not the control's name", function* () { + // Back from a control returns to the region that owns it. Which region that + // is comes from walking the live tree, so a node that moved would move with + // it. + const { state, tree } = yield* useFrame(frame("10")!); + expect(tree.focused().name).toBe("control:transport.continue"); + const owner = surfaceOwning(tree.focused()); + expect(owner).toBe("history"); + const driven = yield* drive(tree, state, key("Escape"), context(WIDE)); + expect(tree.focused().name).toBe("region:history"); + expect(driven.state.route.surface).toBe("history"); + }); +}); + +describe("a live tree and a rebuilt one are the same tree", () => { + /** Frame 11, driven into historical inspection through the real path. */ + function* inspected(mutation?: Mutation): Operation<{ + state: ReplState; + order: readonly string[]; + }> { + const { state, tree } = yield* useFrame(frame("11")!); + const driven = yield* drive(tree, state, key("Enter"), context(WIDE, mutation)); + return { + state: driven.state, + order: overlayOf(tree).map((entry) => entry.id), + }; + } + + /** The same URL and journal, with the store and the tree thrown away. */ + function* rebuilt(state: ReplState): Operation { + const fresh = hydrate(formatRoute(state.route), state.journal); + const tree = yield* useReplTree(fresh); + const region = tree.chain().find((node) => node.name === "region:history"); + if (region) { + focusNode(region); + } + yield* tree.sync(fresh); + return overlayOf(tree).map((entry) => entry.id); + } + + it("rebuilds the same ordered topology from the URL and the journal", function* () { + const live = yield* inspected(); + expect(live.state.route.inspect).toBe(true); + expect(yield* rebuilt(live.state)).toEqual(live.order); + }); + + it("keeps the transport in its canonical order either way", function* () { + const live = yield* inspected(); + const transport = (order: readonly string[]) => + order.filter((id) => id.startsWith("control:transport.")); + expect(transport(live.order)).toEqual([ + "control:transport.continue", + "control:transport.return-head", + "control:transport.fork", + ]); + expect(transport(yield* rebuilt(live.state))).toEqual(transport(live.order)); + }); + + it("diverges when a replaced control is left where it was appended", function* () { + const live = yield* inspected("append-replacements"); + expect(yield* rebuilt(live.state)).not.toEqual(live.order); + }); + + it("leaves focus on a surviving node after every replacement", function* () { + const { state, tree } = yield* useFrame(frame("11")!); + const driven = yield* drive(tree, state, key("Enter"), context(WIDE)); + void driven; + expect(chain(tree)).toContain(tree.focused().name); + // …and after a branch is torn down as well. + const { state: open, tree: withDrawer } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + yield* withDrawer.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", open.journal)); + expect(chain(withDrawer)).toContain(withDrawer.focused().name); + }); +}); + describe("branches, and what closing one destroys", () => { it("adds a nested panel's focusables in tree order", function* () { const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1", "cp-14"); From 7bdaa857273f6d244b50cb5efbdc0d416f6e878d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 11:06:17 -0400 Subject: [PATCH 09/57] =?UTF-8?q?=F0=9F=A9=B9=20Open=20a=20run=20at=20its?= =?UTF-8?q?=20frame's=20focus,=20not=20only=20its=20route=20(#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running `--frame 12 --focus-map` in a real pseudo-terminal while writing instructions for it. The interactive harness opened at a frame's location but never placed its focus, so the footer — an explicit region whose controls exist only once focus is inside it — drew none of the transport controls that frame is about. The overlay numbered five targets where the study numbers eight. Nothing caught it because the evidence and the captures entered through `useFrame` and the harness entered through `openingState`: two ways in, and only one of them was ever checked. Both now enter through one `enterRoute()`, and a case walks all fourteen frames through the harness's own opening path — `openingState()` then `enterRoute()`, exactly as `runInteractive` does — and asserts the focused node and the numbered overlay. `--frame ` carries the frame's focus through to the run. --- scripts/repl-study/drive.ts | 30 ++++++++++++++++++++++++ scripts/repl-study/frames.ts | 18 ++++---------- scripts/repl-study/host.ts | 9 ++++++- scripts/repl-study/main.ts | 5 ++++ scripts/tests/repl-focus.test.ts | 40 ++++++++++++++++++++++++++++++-- 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/scripts/repl-study/drive.ts b/scripts/repl-study/drive.ts index 8bcfb7196..dfd28538a 100644 --- a/scripts/repl-study/drive.ts +++ b/scripts/repl-study/drive.ts @@ -115,3 +115,33 @@ export function drive( } export type { Mutation }; + +/** + * Put focus where a run is opening. + * + * The route's surface says which region owns focus, and a named study frame + * additionally says which node inside it. Both are addresses; the tree decides + * whether they are there. + * + * The interactive harness and the evidence both enter through here. When they + * did not, `--frame 12` opened at the frame's location but not its focus, so + * the footer — an explicit region, whose controls exist only once focus is + * inside it — drew none of the transport controls that frame is about, and + * nothing noticed because the evidence entered a different way. + */ +export function enterRoute(tree: ReplTree, state: ReplState, wanted?: string): Operation { + return { + *[Symbol.iterator]() { + const region = tree.chain().find((node) => node.name === `region:${state.route.surface}`); + if (region) { + focusNode(region); + } + yield* tree.sync(state); + const target = + wanted === undefined ? undefined : tree.chain().find((node) => node.name === wanted); + if (target) { + focusNode(target); + } + }, + }; +} diff --git a/scripts/repl-study/frames.ts b/scripts/repl-study/frames.ts index 0c9ae92ca..46e99d25f 100644 --- a/scripts/repl-study/frames.ts +++ b/scripts/repl-study/frames.ts @@ -17,7 +17,8 @@ import { journalThrough } from "./journal.ts"; import type { FixtureName } from "./model.ts"; import { hydrate } from "./store.ts"; import type { ReplState } from "./store.ts"; -import { focus as focusNode, useReplTree } from "./tree.ts"; +import { useReplTree } from "./tree.ts"; +import { enterRoute } from "./drive.ts"; import type { ReplTree } from "./tree.ts"; import type { Operation } from "effection"; @@ -367,18 +368,9 @@ export function useFrame(subject: StudyFrame): Operation { *[Symbol.iterator]() { const state = stateFor(subject); const tree = yield* useReplTree(state); - // Entering the region comes first, because the footer is explicit: its - // controls exist only once focus is inside it. The route's surface is - // what says which region that is — the same invariant the URL records. - const region = tree.chain().find((node) => node.name === `region:${state.route.surface}`); - if (region) { - focusNode(region); - } - yield* tree.sync(state); - const target = tree.chain().find((node) => node.name === subject.focus); - if (target) { - focusNode(target); - } + // The same entry the interactive harness uses, so a frame opened by + // `--frame` and a frame built here cannot come out different. + yield* enterRoute(tree, state, subject.focus); return { state, tree }; }, }; diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index e7cb6afb6..4f25de2fa 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -26,7 +26,7 @@ import type { FocusView } from "./render.ts"; import { initialView } from "./store.ts"; import { asKey, fixtureFor, hydrate, reduce, viewOf } from "./store.ts"; import { overlayOf, useReplTree } from "./tree.ts"; -import { drive } from "./drive.ts"; +import { drive, enterRoute } from "./drive.ts"; import type { HarnessEvent, ReplState, View } from "./store.ts"; import { journalThrough, markerShowing } from "./journal.ts"; import { formatRoute } from "./route.ts"; @@ -307,6 +307,8 @@ export interface InteractiveOptions { readonly route?: string; /** How far the execution has recorded, which a URL never carries. */ readonly head?: string; + /** The node to put focus on, for a run that opens at a named study frame. */ + readonly focus?: string; /** Start with the numbered focus map drawn. */ readonly focusMap?: boolean; /** Start this playback immediately, rather than waiting for `p`. */ @@ -353,6 +355,11 @@ export function* runInteractive(options: InteractiveOptions): Operation { // The tree is acquired before the terminal is touched, so its teardown runs // after the terminal has been given back rather than into a restored one. const tree = yield* useReplTree(repl); + // Entering the region the route names comes first, because the footer is an + // explicit region: its controls exist only once focus is inside it. Without + // this the interactive harness opened at a frame's *location* but not its + // focus, so `--frame 12` drew none of the transport controls it is about. + yield* enterRoute(tree, repl, options.focus); let term = yield* useTerm({ cols: state.cols, rows: state.rows }); const input: Input = yield* until(createInput({})); diff --git a/scripts/repl-study/main.ts b/scripts/repl-study/main.ts index 37ff0b39b..973d44cda 100644 --- a/scripts/repl-study/main.ts +++ b/scripts/repl-study/main.ts @@ -63,6 +63,7 @@ type Mode = readonly trace?: string; readonly route?: string; readonly head?: string; + readonly focus?: string; readonly focusMap?: boolean; } | { readonly kind: "capture"; readonly directory: string; readonly focus?: boolean } @@ -100,6 +101,7 @@ export function parse(argv: readonly string[]): Invocation | string { let trace: string | undefined; let route: string | undefined; let head: string | undefined; + let focus: string | undefined; let focusMap = false; let at = 0; @@ -186,6 +188,7 @@ export function parse(argv: readonly string[]): Invocation | string { } route = found.url; head = found.head; + focus = found.focus; fixtureName = found.fixture; } else if (argument === "--route") { const url = value(); @@ -236,6 +239,7 @@ export function parse(argv: readonly string[]): Invocation | string { trace, route, head, + focus, focusMap, }, mutation, @@ -295,6 +299,7 @@ function* run(invocation: Invocation): Operation { fixture: mode.fixture, route: mode.route, head: mode.head, + focus: mode.focus, focusMap: mode.focusMap, mutation, play: mode.play, diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index 84dbce353..035ebdd0e 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url"; import { captureFocus, captureText, PROFILE_SIZES, renderFrame } from "../repl-study/capture.ts"; import { FRAMES, frame, stateFor, useFrame } from "../repl-study/frames.ts"; -import { scanKeys } from "../repl-study/host.ts"; +import { openingState, scanKeys } from "../repl-study/host.ts"; import { fold, JOURNAL, journalThrough, markers, siblingsOf } from "../repl-study/journal.ts"; import { formatRoute, @@ -44,7 +44,7 @@ import { viewOf, } from "../repl-study/store.ts"; import type { HarnessEvent, ReplState, Size } from "../repl-study/store.ts"; -import { drive } from "../repl-study/drive.ts"; +import { drive, enterRoute } from "../repl-study/drive.ts"; import { focus as focusNode } from "../repl-study/tree.ts"; import { find, overlayOf, surfaceOwning, useReplTree, walk } from "../repl-study/tree.ts"; import type { ReplTree } from "../repl-study/tree.ts"; @@ -992,6 +992,42 @@ describe("the frames, as pictures", () => { }); }); +describe("the command opens at the frame it names", () => { + it("reproduces every frame through the harness's own opening path", function* () { + // `--frame ` builds its state the way `runInteractive` does, not the + // way the rest of this suite does. They were once different: the harness + // opened at a frame's location but not its focus, so the footer — whose + // controls exist only once focus is inside it — drew none of them, and no + // case noticed because every case entered another way. + for (const subject of FRAMES) { + // Exactly what `runInteractive` does: build the opening state from the + // flags, then enter the route with the frame's focus. + const state = openingState({ + fixture: subject.fixture, + route: subject.url, + head: subject.head, + }); + const tree = yield* useReplTree(state); + yield* enterRoute(tree, state, subject.focus); + expect({ frame: subject.id, focus: tree.focused().name }).toEqual({ + frame: subject.id, + focus: subject.focus, + }); + const entries = overlayOf(tree); + const shown = subject.overlay + ? entries + : entries.filter((entry) => entry.id === subject.focus); + expect({ + frame: subject.id, + targets: shown.map((entry) => ({ n: entry.number, id: entry.id })), + }).toEqual({ + frame: subject.id, + targets: subject.targets.map((target) => ({ n: target.n, id: target.id })), + }); + } + }); +}); + describe("the documented command", () => { it("opens at a route, a frame and with the map on", function* () { for (const argument of [ From c5b1704dcbe7e68aa5d14a8a0db91b0025740b81 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 12:19:16 -0400 Subject: [PATCH 10/57] =?UTF-8?q?=F0=9F=A7=B1=20Give=20the=20REPL=20a=20co?= =?UTF-8?q?mponent=20boundary=20and=20one=20render=20walk=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first slice of #840: the architecture, proven end to end, with the renderer port that follows it still to come. `view.ts` projects one immutable `ReplView` with isolated subtrees for sessions, transcript, bindings, contextual content and history. It is JSON — a case round-trips it to prove so — which is how it carries no journal, no store handle, no Freedom node, no geometry and no callback. `indexOf()` names what a route may address, so the router in the next slice can refuse what the view does not contain. `component.ts` is the whole component interface: a render body attached to a Freedom node, and a `walk()` that renders depth-first with each parent wrapping what its children already produced — the pinned Bombshell shape. `attach()` returns a typed updater rather than storing the data behind an `unknown`, so handing a component new data keeps its node, and with it the node's identity, focus, middleware and generator-local state. `components.ts` holds the bodies; `paint.ts` is the downward pass that hands every mounted node its own slice and then walks the tree. Rendering and the focus chain now come off one structure: a case renders the REPL through the mounted tree and asserts the same tree answers both. Nothing is switched over yet. `render.ts` still draws #838's frames from rectangles, and the band's notch geometry, the drawer, the transitions and the overlay are not ported. Completing that is the rest of this slice, and it reaches further than regenerating captures — `bandGeometry`, `notchLayout` and `columnFor` key off `Fixture["history"]` and #838's suite calls them directly. --- scripts/repl-study/component.ts | 107 +++++++ scripts/repl-study/components.ts | 326 ++++++++++++++++++++ scripts/repl-study/paint.ts | 109 +++++++ scripts/repl-study/render.ts | 30 +- scripts/repl-study/view.ts | 414 ++++++++++++++++++++++++++ scripts/runtime-test-exclusions.ts | 6 + scripts/tests/repl-components.test.ts | 146 +++++++++ 7 files changed, 1127 insertions(+), 11 deletions(-) create mode 100644 scripts/repl-study/component.ts create mode 100644 scripts/repl-study/components.ts create mode 100644 scripts/repl-study/paint.ts create mode 100644 scripts/repl-study/view.ts create mode 100644 scripts/tests/repl-components.test.ts diff --git a/scripts/repl-study/component.ts b/scripts/repl-study/component.ts new file mode 100644 index 000000000..aedd48733 --- /dev/null +++ b/scripts/repl-study/component.ts @@ -0,0 +1,107 @@ +/** + * The smallest component interface this experiment could find. + * + * A component is a **render body attached to a Freedom node**. Mounting one + * creates a node beneath its rendered parent; the node's scope owns its + * focusability, its input middleware and its disposable local state, and its + * `data` carries the body. Rendering walks that same tree: each parent wraps + * its already-rendered children in terminal operations. + * + * That is the whole of it, and the shape is the pinned Bombshell example's. It + * matters because it leaves nothing for a second structure to be: there is no + * focus-target list to return, no ownership map to keep in step and no + * interaction registry to consult. Removing the node removes the rendering, the + * focusables, the middleware and the local state together, because they were + * never anywhere else. + * + * A component is handed its own immutable view subtree and nothing else — no + * journal, no store, no node, no geometry beyond the box its parent gives it, + * and no callback. What it wants to happen it says as an action. + */ + +import { createNodeData } from "./vendor/freedom/upstream/index.ts"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; +import type { Op } from "@bomb.sh/tty"; + +import type { Rect } from "./layout.ts"; + +/** + * What a parent tells a child about where it may draw. + * + * Parents own the visibility, order and placement of their direct children, so + * a child receives its box rather than measuring the screen. It is the only + * geometry that crosses the boundary, and it arrives from the parent rather + * than from a layout a child looked up for itself. + */ +export interface Placement { + readonly rect: Rect; + /** True where the pane is at its floor and secondary detail is dropped. */ + readonly dense: boolean; +} + +export interface BodyContext { + readonly node: Node; + /** This component's own immutable view subtree. */ + readonly data: Data; + readonly placement: Placement; + /** The children's operations, already rendered. */ + readonly children: readonly Op[]; +} + +export type Body = (context: BodyContext) => Op[]; + +interface Attached { + readonly render: (node: Node, children: readonly Op[]) => Op[]; +} + +const bodyKey = createNodeData("xmd:repl:body"); + +/** Hand a mounted component new data, without replacing the node. */ +export type Update = (data: Data, placement: Placement) => void; + +/** + * Attach a body to a node, closing over the data and placement it was given. + * + * The updater comes back typed rather than living on the node behind an + * `unknown`: whoever mounted the component knows its data's shape, and nothing + * else needs to. Handing new data through it keeps the node — and with it the + * node's identity, its focus, its middleware and its generator-local state — + * which is what preserving unchanged nodes across an immutable update means in + * practice. + */ +export function attach( + node: Node, + body: Body, + data: Data, + placement: Placement, +): Update { + let current = data; + let where = placement; + node.data.set(bodyKey, { + render: (self, children) => body({ node: self, data: current, placement: where, children }), + }); + return (next, to) => { + current = next; + where = to; + }; +} + +export function hasBody(node: Node): boolean { + return node.data.get(bodyKey) !== undefined; +} + +/** + * Render the tree, depth first, each parent wrapping its rendered children. + * + * A node without a body contributes its children's operations unchanged, which + * is what lets a purely structural node — a routing outlet, a focus root — exist + * without drawing anything. + */ +export function walk(node: Node): Op[] { + const children: Op[] = []; + for (const child of node.children) { + children.push(...walk(child)); + } + const attached = node.data.get(bodyKey); + return attached ? attached.render(node, children) : children; +} diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts new file mode 100644 index 000000000..87638cc0e --- /dev/null +++ b/scripts/repl-study/components.ts @@ -0,0 +1,326 @@ +/** + * The REPL's components: one render body each, attached to one Freedom node. + * + * Every body here takes its **own immutable view subtree** and the box its + * parent gave it, and returns terminal operations wrapping whatever its + * children already rendered. None of them can reach the journal, the store, the + * tree or the terminal — a component that could would be able to act on the + * application without saying so as an action. + * + * The drawing primitives come from `render.ts`, which still owns the study's + * colours, glyphs and line arithmetic. What moved here is *which* component + * draws *what*, which is the part the mounted tree now decides. + */ + +import { close, fixed, open, text } from "@bomb.sh/tty"; +import type { Op } from "@bomb.sh/tty"; + +import type { Body } from "./component.ts"; +import { + BG, + blank, + C, + clock, + fit, + label, + plain, + region, + transcriptLines, + wrapText, +} from "./render.ts"; +import type { VisualLine } from "./render.ts"; +import type { + BindingsView, + ContextualView, + HistoryView, + ReplView, + SessionsView, + TranscriptView, +} from "./view.ts"; +import { isRedacted } from "./view.ts"; + +/** The crumb line above the panes. */ +export const headerBody: Body> = ({ node, data, placement }) => + region( + node.name, + placement.rect, + [ + { + segments: [ + { text: data.crumb, color: C.label }, + ...(data.badge === undefined + ? [] + : [{ text: data.badge, color: C.gold, width: [...data.badge].length + 2 }]), + ], + }, + blank(), + ], + { bg: BG.center }, + ); + +export const sessionsBody: Body = ({ node, data, placement, children }) => { + const lines: VisualLine[] = []; + if (!placement.dense) { + lines.push(plain("XMD REPL", C.out), blank()); + } + lines.push( + { + segments: [ + { text: "SESSION", color: data.tab === "sessions" ? C.intro : C.label, width: 10 }, + { text: "JOURNAL", color: data.tab === "journal" ? C.intro : C.label, width: 10 }, + { text: "STATE", color: data.tab === "state" ? C.intro : C.label, width: 8 }, + ], + }, + blank(), + ); + if (data.heading !== undefined) { + lines.push(label(data.heading)); + } + if (data.subheading !== undefined && !placement.dense) { + lines.push(plain(data.subheading, C.dim)); + } + lines.push(blank()); + + if (data.tab === "journal") { + for (const marker of data.markers) { + lines.push({ + segments: [ + { text: clock(marker.at), color: marker.selected ? C.gold : C.dim, width: 6 }, + { + text: marker.boundary ? "◆" : "●", + color: marker.selected ? C.gold : marker.later ? C.dim : C.active, + width: 2, + }, + { text: marker.label, color: marker.selected ? C.out : marker.later ? C.dim : C.src }, + ], + }); + } + lines.push(blank(), plain("▸ 26 internal records", C.dim)); + const chosen = data.markers.find((marker) => marker.selected); + if (chosen) { + lines.push( + blank(), + label("SELECTED CHECKPOINT"), + plain(chosen.label, C.out), + plain(`${clock(chosen.at)} elapsed · ${chosen.scope}`, C.dim), + ); + for (const record of chosen.records) { + lines.push(plain(`· ${record}`, C.dim)); + } + } + return region(node.name, placement.rect, lines, { bg: BG.side, children }); + } + + if (data.sessions.length === 0) { + for (const placeholder of data.placeholder) { + for (const wrapped of wrapText(placeholder, Math.max(1, placement.rect.width - 2))) { + lines.push(plain(wrapped, C.dim)); + } + } + return region(node.name, placement.rect, lines, { bg: BG.side, children }); + } + + for (const session of data.sessions) { + lines.push({ + segments: [ + { text: session.selected ? "│ " : " ", color: C.active, width: 2 }, + { text: session.id, color: session.selected ? C.out : C.name }, + ], + }); + lines.push({ + segments: [ + { text: " ", width: 2 }, + { + text: session.label, + color: + session.state === "active" ? C.active : session.state === "completed" ? C.tick : C.dim, + width: 14, + }, + { + text: placement.dense ? session.agent : `${session.agent} · ${session.turn}`, + color: C.dim, + }, + ], + }); + if (session.note !== undefined && !placement.dense) { + lines.push(plain(` ${session.note}`, C.dim)); + } + lines.push(blank()); + } + return region(node.name, placement.rect, lines, { bg: BG.side, children }); +}; + +export interface TranscriptData { + readonly view: TranscriptView; + /** The window over a long transcript, which the renderer clips rather than scrolls. */ + readonly anchor: number; +} + +export const transcriptBody: Body = ({ node, data, placement, children }) => { + const rect = placement.rect; + const width = Math.max(0, rect.width - 2); + const lines: VisualLine[] = []; + const entry = data.view.entry; + if (entry === undefined) { + lines.push(label("TRANSCRIPT"), blank(), plain("No executions yet.", C.dim), blank()); + for (const placeholder of data.view.placeholder) { + for (const wrapped of wrapText(placeholder, width)) { + lines.push(plain(wrapped, C.dim)); + } + } + return region(node.name, rect, lines, { bg: BG.center, children }); + } + const running = entry.state === "running"; + lines.push( + { + segments: [ + { text: entry.id, color: C.out, width: entry.id.length + 2 }, + { + text: running ? `● running · ${entry.elapsed}s` : `✓ completed · ${entry.elapsed}s`, + color: running ? C.active : C.tick, + width: 24, + }, + { text: entry.scopeNote, color: C.dim }, + ], + }, + blank(), + ); + + const body = transcriptLines({ ...entry, sourceLines: 0, rows: data.view.rows }, width); + const capacity = Math.max(0, rect.height - lines.length); + const windowed = body.slice(data.anchor, data.anchor + Math.max(0, capacity - 1)); + lines.push(...windowed); + const remaining = body.length - data.anchor - windowed.length; + if (remaining > 0) { + lines.push(plain(`▸ ${remaining} more lines · ↑↓ PgUp PgDn`, C.dim)); + } else if (data.anchor > 0) { + lines.push(plain(`▴ ${data.anchor} earlier lines · ↑ scrolls back`, C.dim)); + } + return region(node.name, rect, lines, { bg: BG.center, children }); +}; + +export const bindingsBody: Body = ({ node, data, placement, children }) => { + const lines: VisualLine[] = [label("BINDINGS"), plain(data.scopeName, C.dim), blank()]; + if (data.bindings.length === 0) { + for (const placeholder of data.placeholder) { + for (const wrapped of wrapText(placeholder, Math.max(1, placement.rect.width - 2))) { + lines.push(plain(wrapped, C.dim)); + } + } + return region(node.name, placement.rect, lines, { bg: BG.bind, children }); + } + for (const binding of data.bindings) { + if (isRedacted(binding)) { + // A secret has no representable value here. There is nothing to leak + // because there is nothing to render. + lines.push(plain("secret", C.name)); + lines.push(plain(binding.state === "answered" ? "answered · redacted" : "required", C.dim)); + lines.push(blank()); + continue; + } + lines.push(plain(binding.name, C.name)); + if (binding.note !== undefined && !placement.dense) { + lines.push(plain(binding.note, C.dim)); + } + for (const value of binding.lines) { + lines.push(plain(value, C.settledText)); + } + lines.push(blank()); + } + return region(node.name, placement.rect, lines, { bg: BG.bind, children }); +}; + +export const inputBody: Body = ({ node, data, placement, children }) => { + const width = Math.max(0, placement.rect.width - 2); + const lines: VisualLine[] = [ + { + segments: [ + { text: data.label, color: C.label, width: Math.min(width, 18) }, + { text: data.hint, color: data.run === undefined ? C.hold : C.dim }, + { + text: data.run === undefined ? "[ Run ]" : "[ Run ⌘⏎ ]", + color: data.run === undefined ? C.dim : C.tick, + width: 12, + }, + ], + }, + plain(data.draft === "" ? data.placeholder : data.draft, C.settledText), + ]; + return region(node.name, placement.rect, lines, { bg: BG.input, children }); +}; + +export const historyBody: Body = ({ node, data, placement, children }) => { + const rect = placement.rect; + const inner = Math.max(0, rect.width - 2); + const compact = placement.dense; + const controls = data.controls.map((control) => `[ ${control.label} ]`).join(" "); + const word = + data.transport === "live" + ? "LIVE" + : data.transport === "paused" + ? "PAUSED" + : data.transport === "inspecting" + ? "INSPECTING" + : "IDLE"; + const lines: VisualLine[] = [ + { + segments: [ + { text: fit(compact ? "HISTORY" : "EXECUTION HISTORY", 20), color: C.label }, + { text: `${word} ${controls}`, color: C.dim }, + ], + }, + { + segments: [ + { + text: + data.markers.length === 0 ? "No recorded execution yet" : `recorded · ${data.elapsed}`, + color: C.dim, + }, + ], + }, + ]; + for (const marker of data.markers.slice(0, Math.max(0, rect.height - 3))) { + lines.push({ + segments: [ + { text: clock(marker.at), color: marker.selected ? C.gold : C.dim, width: 6 }, + { text: marker.boundary ? "◆" : "●", color: marker.selected ? C.gold : C.active, width: 2 }, + { text: marker.label, color: marker.selected ? C.out : C.src }, + { text: marker.scope, color: C.dim, width: Math.min(28, Math.max(0, inner - 40)) }, + ], + }); + } + return region(node.name, rect, lines, { bg: BG.footer, children }); +}; + +/** A structural outlet: it draws nothing and contributes its children unchanged. */ +export const outletBody: Body = ({ children }) => [...children]; + +/** The screen, which every region floats against. */ +export const rootBody: Body = ({ placement, children }) => [ + // The root node has no name of its own; the renderer's own id for the screen + // is what every region floats against. + open("root", { + layout: { width: fixed(placement.rect.width), height: fixed(placement.rect.height) }, + bg: BG.app, + }), + ...children, + close(), +]; + +export const tooSmallBody: Body<{ readonly cols: number; readonly rows: number }> = ({ + node, + data, + placement, +}) => + region( + node.name, + placement.rect, + [ + plain("Terminal too small", C.out), + plain(`72 × 20 required · ${data.cols} × ${data.rows} now`, C.hold), + plain("resize to continue", C.dim), + ], + { bg: BG.app, padding: { left: 1, right: 1, top: 1 } }, + ); + +void text; diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts new file mode 100644 index 000000000..a09548aa8 --- /dev/null +++ b/scripts/repl-study/paint.ts @@ -0,0 +1,109 @@ +/** + * One downward pass: every mounted node is handed its own slice of the view. + * + * This is the "data down" half. The projector above the tree produces one + * immutable `ReplView`; this walks the mounted nodes and gives each the subtree + * it owns together with the box its parent allows it. Nothing is rebuilt — a + * node that was already mounted keeps its identity, its focus, its middleware + * and its generator-local state, and only the data it renders changes. + * + * Rendering is then `walk()` over that same tree, each parent wrapping what its + * children already produced. Rendering, focus order, the scoped input path and + * the `F1` overlay therefore all come off one structure, which is the whole + * claim #840 makes. + */ + +import type { Op } from "@bomb.sh/tty"; + +import { attach, walk } from "./component.ts"; +import type { Placement } from "./component.ts"; +import { + bindingsBody, + headerBody, + historyBody, + inputBody, + outletBody, + rootBody, + sessionsBody, + transcriptBody, +} from "./components.ts"; +import type { Layout, Rect } from "./layout.ts"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; +import type { ReplView } from "./view.ts"; + +/** A node that is mounted but not composed at this profile draws nothing. */ +const NOWHERE: Rect = { x: 0, y: 0, width: 0, height: 0 }; + +function placed(rect: Rect | undefined, layout: Layout): Placement { + return { rect: rect ?? NOWHERE, dense: layout.dense }; +} + +/** + * Give one node the body and the data it owns. + * + * Attaching is idempotent and keeps the node, so this runs every frame: the + * alternative — attaching once and mutating a captured reference — would make + * "the data a component rendered" a thing two places could answer. + */ +function dress(node: Node, view: ReplView, layout: Layout, anchor: number): void { + const name = node.name; + if (name === "region:sessions") { + attach(node, sessionsBody, view.sessions, placed(layout.sidebar, layout)); + return; + } + if (name === "region:transcript") { + attach( + node, + transcriptBody, + { view: view.transcript, anchor }, + placed(layout.transcript, layout), + ); + return; + } + if (name === "region:bindings") { + attach(node, bindingsBody, view.bindings, placed(layout.bindings, layout)); + return; + } + if (name === "region:input") { + attach(node, inputBody, view.contextual.input, placed(layout.contextual, layout)); + return; + } + if (name === "region:history") { + attach(node, historyBody, view.history, placed(layout.footer, layout)); + return; + } + if (name === "header") { + attach( + node, + headerBody, + { crumb: view.crumb, badge: view.badge }, + placed(layout.header, layout), + ); + return; + } + // Panels, drawers and controls are structural for now: they own ancestry, + // focus and input, and contribute their children's operations unchanged. + attach(node, outletBody, undefined, placed(undefined, layout)); +} + +export interface PaintRequest { + readonly root: Node; + readonly view: ReplView; + readonly layout: Layout; + /** The transcript window, which the renderer clips rather than scrolls. */ + readonly anchor: number; +} + +/** Hand every mounted node its data, then render the tree. */ +export function paint(request: PaintRequest): Op[] { + const { root, view, layout, anchor } = request; + attach(root, rootBody, undefined, { rect: layout.screen, dense: layout.dense }); + const visit = (node: Node): void => { + for (const child of node.children) { + dress(child, view, layout, anchor); + visit(child); + } + }; + visit(root); + return walk(root); +} diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 31b6b652c..f832ec22f 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -23,7 +23,7 @@ import type { Mutation } from "./mutations.ts"; import type { OverlayEntry } from "./tree.ts"; import type { Motion } from "./playback.ts"; -const C = { +export const C = { src: rgba(0xc8, 0xd2, 0xd9), active: rgba(0x7f, 0xd3, 0xe8), tick: rgba(0x5a, 0xa8, 0x7c), @@ -41,7 +41,7 @@ const C = { focus: rgba(0x9a, 0xe0, 0xa8), }; -const BG = { +export const BG = { app: rgba(0x0b, 0x0d, 0x0f), side: rgba(0x09, 0x0b, 0x0c), center: rgba(0x0c, 0x0e, 0x11), @@ -80,7 +80,7 @@ export interface VisualLine { readonly segments: readonly Segment[]; } -function fit(value: string, width: number): string { +export function fit(value: string, width: number): string { if (width <= 0) { return ""; } @@ -143,8 +143,15 @@ function lineOps(id: string, width: number, line: VisualLine): Op[] { return ops; } -interface RegionOptions { +export interface RegionOptions { readonly bg?: number; + /** + * What this region's children already rendered. + * + * A parent wraps them rather than drawing over them, which is what makes the + * mounted tree and the rendered composition the same shape. + */ + readonly children?: readonly Op[]; readonly padding?: { readonly left?: number; readonly right?: number; readonly top?: number }; /** * A transition the renderer owns. @@ -160,7 +167,7 @@ interface RegionOptions { }; } -function region( +export function region( id: string, rect: Rect, lines: readonly VisualLine[], @@ -196,11 +203,12 @@ function region( lines.slice(0, capacity).forEach((line, index) => { ops.push(...lineOps(`${id}.line.${index}`, innerWidth, line)); }); + ops.push(...(options.children ?? [])); ops.push(close()); return ops; } -function rule(id: string, rect: Rect, glyph: string): Op[] { +export function rule(id: string, rect: Rect, glyph: string): Op[] { const ops: Op[] = [ open(id, { layout: { width: fixed(rect.width), height: fixed(rect.height), direction: "ttb" }, @@ -339,15 +347,15 @@ function focusMapRegion(layout: Layout, focus: FocusView): Op[] { return region("focus-map", rect, lines, { bg: BG.drawer }); } -function blank(): VisualLine { +export function blank(): VisualLine { return { segments: [{ text: "" }] }; } -function plain(value: string, color = C.src): VisualLine { +export function plain(value: string, color = C.src): VisualLine { return { segments: [{ text: value, color }] }; } -function label(value: string): VisualLine { +export function label(value: string): VisualLine { return plain(value, C.label); } @@ -767,7 +775,7 @@ function contextualRegion( return region("contextual", rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION }); } -function clock(seconds: number): string { +export function clock(seconds: number): string { const minutes = Math.floor(Math.max(0, seconds) / 60); const rest = Math.floor(Math.max(0, seconds) % 60); return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; @@ -1130,7 +1138,7 @@ function markTransport(right: string, focus: FocusView | undefined): string { } /** Keep each cell's colour when a grid row becomes segments. */ -function runsOf(glyphs: readonly string[], colors: readonly number[]): Segment[] { +export function runsOf(glyphs: readonly string[], colors: readonly number[]): Segment[] { const segments: Segment[] = []; let run = ""; let color = colors[0] ?? C.dim; diff --git a/scripts/repl-study/view.ts b/scripts/repl-study/view.ts new file mode 100644 index 000000000..599fcf713 --- /dev/null +++ b/scripts/repl-study/view.ts @@ -0,0 +1,414 @@ +/** + * What the interface is showing, as data a component may be handed. + * + * `ReplView` is the one immutable projection between the fixtures and the + * component tree. It is built above the tree and flows down through it, and it + * carries **semantic content only** — identity, text, lifecycle, selection and + * redaction. It carries no journal, no store handle, no Freedom node, no + * terminal geometry, no callback and no way to mutate anything, because a + * component that could reach any of those could act on the application without + * saying so as an action. + * + * The subtrees are isolated on purpose: a component receives its own and + * nothing else, so what it can render is bounded by what it was given rather + * than by what it remembered to ignore. + */ + +import type { Binding, Checkpoint, Phase, TranscriptRow } from "./model.ts"; +import type { DrawerKind } from "./fixtures.ts"; +import type { Moment } from "./journal.ts"; +import type { Route, RouteSurface } from "./route.ts"; +import { fixtureFor } from "./store.ts"; +import type { ReplState } from "./store.ts"; + +/** A value a component must not be able to reveal, whatever it renders. */ +export interface Redacted { + readonly redacted: true; + /** What kind of answer is required, which is all a person may be told. */ + readonly kind: "secret"; + readonly state: "required" | "answered"; +} + +export function isRedacted(value: unknown): value is Redacted { + return typeof value === "object" && value !== null && "redacted" in value; +} + +export interface SessionView { + readonly id: string; + readonly agent: string; + readonly state: "queued" | "active" | "completed"; + readonly label: string; + readonly turn: string; + readonly selected: boolean; + readonly note?: string; +} + +export interface SessionsView { + readonly tab: "sessions" | "journal" | "state"; + readonly heading?: string; + readonly subheading?: string; + readonly placeholder: readonly string[]; + readonly sessions: readonly SessionView[]; + /** The recorded markers, when the sidebar is showing the journal. */ + readonly markers: readonly MarkerView[]; +} + +export interface MarkerView { + readonly id: string; + readonly at: number; + readonly label: string; + readonly scope: string; + readonly depth: number; + readonly boundary: boolean; + readonly selected: boolean; + /** True for a marker recorded after the one being inspected. */ + readonly later: boolean; + readonly records: readonly string[]; +} + +/** One visible scope inside an entry: the unit the router mounts. */ +export interface ScopeView { + readonly id: string; + readonly name: string; + readonly phase: Phase; + readonly rows: readonly TranscriptRow[]; + readonly scopes: readonly ScopeView[]; +} + +export interface TranscriptView { + readonly entry?: { + readonly id: string; + readonly title: string; + readonly state: "running" | "completed"; + readonly elapsed: string; + readonly scopeNote: string; + }; + readonly rows: readonly TranscriptRow[]; + /** The visible scopes the route may address, in source order. */ + readonly scopes: readonly ScopeView[]; + readonly placeholder: readonly string[]; + readonly readOnly: boolean; +} + +export interface BindingsView { + readonly scopeName: string; + readonly bindings: readonly (Binding | Redacted)[]; + readonly placeholder: readonly string[]; +} + +export interface ControlView { + readonly id: string; + readonly label: string; + readonly enabled: boolean; +} + +export interface DrawerView { + readonly kind: DrawerKind; + readonly heading: string; + readonly origin: string; + readonly lines: readonly string[]; + readonly controls: readonly ControlView[]; + /** A recorded drawer renders its state and offers nothing to act on. */ + readonly historical: boolean; +} + +export interface InputView { + readonly label: string; + readonly hint: string; + readonly placeholder: string; + readonly draft: string; + readonly run?: ControlView; +} + +export interface ContextualView { + /** The drawer stack. Only the last is visible and interactive. */ + readonly drawers: readonly DrawerView[]; + readonly input: InputView; +} + +export interface HistoryView { + readonly elapsed: string; + readonly headAt: number; + readonly selectedAt?: number; + readonly transport: Moment["transport"]; + readonly controls: readonly ControlView[]; + readonly markers: readonly MarkerView[]; + readonly compressed?: { readonly at: number; readonly note: string }; +} + +export interface ReplView { + readonly execution: string; + readonly surface: RouteSurface; + readonly crumb: string; + readonly badge?: string; + readonly sessions: SessionsView; + readonly transcript: TranscriptView; + readonly bindings: BindingsView; + readonly contextual: ContextualView; + readonly history: HistoryView; +} + +/** The identities a route may address, so the router can refuse what is absent. */ +export interface ViewIndex { + readonly surfaces: readonly RouteSurface[]; + readonly scopes: readonly string[]; + readonly markers: readonly string[]; + readonly drawers: readonly DrawerKind[]; +} + +function scopeIds(scopes: readonly ScopeView[]): string[] { + return scopes.flatMap((scope) => [scope.id, ...scopeIds(scope.scopes)]); +} + +export function indexOf(view: ReplView): ViewIndex { + return { + surfaces: ["sessions", "transcript", "bindings", "input", "history"], + scopes: scopeIds(view.transcript.scopes), + markers: view.history.markers.map((marker) => marker.id), + drawers: view.contextual.drawers.map((drawer) => drawer.kind), + }; +} + +/** Which recorded markers the sidebar and the band show, and how. */ +function markersOf(state: ReplState): MarkerView[] { + const subject = fixtureFor(state); + const selected = subject.history.selectedAt; + return subject.history.checkpoints.map((point: Checkpoint) => ({ + id: `cp-${point.at}`, + at: point.at, + label: point.label, + scope: point.scope, + depth: point.depth, + boundary: point.kind === "entry", + selected: selected !== undefined && point.at === selected, + later: selected !== undefined && point.at > selected, + records: point.records, + })); +} + +/** + * The visible scopes of an entry, in source order. + * + * Only a component with a body is a visible nesting level, which is what the + * router addresses and what the scrubber's notch height follows. An invisible + * helper scope produces no level here, and therefore no marker and no nesting. + */ +function scopesOf(state: ReplState): ScopeView[] { + const rows = fixtureFor(state).entry?.rows ?? []; + // Built mutably and frozen on the way out: a view is immutable to everything + // that receives it, and this is the one place that is true by construction + // rather than by everyone remembering. + interface Building { + readonly id: string; + readonly name: string; + readonly phase: Phase; + readonly rows: TranscriptRow[]; + readonly scopes: Building[]; + } + const opened: Building[] = []; + const stack: Building[] = []; + for (const row of rows) { + if (row.kind !== "lifecycle" || row.pair === undefined) { + stack[stack.length - 1]?.rows.push(row); + continue; + } + if (row.close) { + stack.pop(); + continue; + } + const scope: Building = { + id: row.pair, + name: row.source.trim(), + phase: row.phase, + rows: [], + scopes: [], + }; + const parent = stack[stack.length - 1]; + if (parent) { + parent.scopes.push(scope); + } else { + opened.push(scope); + } + stack.push(scope); + } + const settle = (built: Building): ScopeView => ({ + id: built.id, + name: built.name, + phase: built.phase, + rows: built.rows, + scopes: built.scopes.map(settle), + }); + return opened.map(settle); +} + +function controlsOf(state: ReplState): ControlView[] { + const transport = state.moment.transport; + if (state.moment.entry === "none") { + return []; + } + if (transport === "live") { + return [{ id: "control:transport.pause", label: "Pause", enabled: true }]; + } + if (transport === "paused") { + return [ + { id: "control:transport.continue", label: "Continue", enabled: true }, + { id: "control:transport.return-head", label: "Return to paused head", enabled: true }, + ]; + } + if (transport === "inspecting") { + return [ + { id: "control:transport.continue", label: "Continue", enabled: false }, + { id: "control:transport.return-head", label: "Return to paused head", enabled: true }, + { id: "control:transport.fork", label: "Fork from here", enabled: true }, + ]; + } + return []; +} + +/** + * One state, projected into the view its components are handed. + * + * Everything below this line is data. Nothing a component receives from here + * can reach the journal, the store, the tree or the terminal. + */ +export function project(state: ReplState): ReplView { + const subject = fixtureFor(state); + const route: Route = state.route; + const markers = markersOf(state); + const runnable = state.moment.entry !== "running" && route.draft !== ""; + const drawers = route.drawers.flatMap((kind) => { + const drawer = subject.drawer; + if (drawer === undefined || drawer.kind !== kind) { + return []; + } + return [drawerViewOf(drawer, route.inspect)]; + }); + return { + execution: route.execution, + surface: route.surface, + crumb: subject.crumb, + badge: subject.badge, + sessions: { + tab: subject.sidebar.tab, + heading: subject.sidebar.heading, + subheading: subject.sidebar.subheading, + placeholder: subject.sidebar.placeholder ?? [], + sessions: subject.sessions.map((session) => ({ + id: session.id, + agent: session.agent, + state: session.state, + label: session.label, + turn: session.turn, + selected: session.selected === true, + note: session.note, + })), + markers, + }, + transcript: { + entry: + subject.entry === undefined + ? undefined + : { + id: subject.entry.id, + title: subject.entry.title, + state: subject.entry.state, + elapsed: subject.entry.elapsed, + scopeNote: subject.entry.scopeNote, + }, + rows: subject.entry?.rows ?? [], + scopes: scopesOf(state), + placeholder: + subject.entry === undefined + ? [ + "Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the bindings it published.", + ] + : [], + readOnly: subject.readOnly === true, + }, + bindings: { + scopeName: subject.bindings.scopeName, + bindings: subject.bindings.bindings, + placeholder: subject.bindings.placeholder ?? [], + }, + contextual: { + drawers, + input: { + label: subject.input.label, + hint: subject.input.hint, + placeholder: subject.input.placeholder ?? "", + draft: route.draft, + run: runnable ? { id: "control:input.run", label: "Run", enabled: true } : undefined, + }, + }, + history: { + elapsed: subject.history.elapsed, + headAt: subject.history.headAt, + selectedAt: subject.history.selectedAt, + transport: state.moment.transport, + controls: controlsOf(state), + markers, + compressed: subject.history.compressed, + }, + }; +} + +function drawerViewOf( + drawer: NonNullable["drawer"]>, + historical: boolean, +): DrawerView { + if (drawer.kind === "project") { + return { + kind: "project", + heading: drawer.heading, + origin: drawer.origin, + lines: [drawer.prompt, ...drawer.fields.map((f) => `${f.label}: ${f.value}`)], + controls: [ + { id: "field:drawer.project.name", label: "Project name", enabled: !historical }, + { id: "field:drawer.project.description", label: "Description", enabled: !historical }, + { + id: "control:drawer.project.schema", + label: "Schema disclosure · ⌥S", + enabled: !historical, + }, + { id: "control:drawer.project.submit", label: "Submit", enabled: !historical }, + ], + historical, + }; + } + if (drawer.kind === "review") { + return { + kind: "review", + heading: drawer.heading, + origin: drawer.origin, + lines: [...drawer.plan, drawer.more], + controls: [ + { + id: "control:drawer.review.scroll", + label: "Plan review · scroll region", + enabled: !historical, + }, + { id: "control:drawer.review.approve", label: "Approve", enabled: !historical }, + { id: "control:drawer.review.request", label: "Request changes", enabled: !historical }, + { id: "control:drawer.review.stop", label: "Stop", enabled: !historical }, + { id: "control:drawer.review.submit", label: "Submit", enabled: !historical }, + ], + historical, + }; + } + return { + kind: "confirm", + heading: drawer.heading, + origin: drawer.origin, + lines: [drawer.prompt, ...drawer.preview], + controls: [ + { + id: "control:drawer.confirm.preview", + label: "README preview · scroll region", + enabled: !historical, + }, + { id: "control:drawer.confirm.approve", label: "Approve", enabled: !historical }, + { id: "control:drawer.confirm.decline", label: "Decline", enabled: !historical }, + ], + historical, + }; +} diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 07d5743fd..5ae5f947b 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -41,6 +41,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "its subject is the Deno terminal harness in scripts/repl-study: the host reads Deno.consoleSize(), sets Deno.stdin raw mode, installs Deno signal listeners, and the restoration cases run `deno run` as a child. A Node or Bun shard has no `deno` on PATH and no equivalent of the host it is testing", issue: "https://github.com/taras/executable.md/issues/838", }, + { + path: "scripts/tests/repl-components.test.ts", + reason: + "its subject is the component boundary of the same Deno terminal harness: it mounts a Freedom tree, renders through `@bomb.sh/tty` and reads the frame back as cells. A Node or Bun shard has no equivalent of the host it is testing", + issue: "https://github.com/taras/executable.md/issues/840", + }, { path: "scripts/tests/repl-focus.test.ts", reason: diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts new file mode 100644 index 000000000..5500c74cb --- /dev/null +++ b/scripts/tests/repl-components.test.ts @@ -0,0 +1,146 @@ +/** + * The component boundary: one tree, and data that cannot reach past itself. + * + * #840's claim is that the mounted Freedom tree is the single authority for + * component ancestry, rendering order, focus, scoped input and branch lifetime. + * These cases hold the foundation of that claim: a projection a component + * cannot see past, a body attached to a node, and a render that walks the same + * tree the focus chain comes off. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import type { Operation } from "effection"; + +import { PROFILE_SIZES, useTerm } from "../repl-study/capture.ts"; +import { attach, walk } from "../repl-study/component.ts"; +import { hydrate, layoutOf } from "../repl-study/store.ts"; +import { journalThrough } from "../repl-study/journal.ts"; +import { paint } from "../repl-study/paint.ts"; +import { applyAnsi, createGrid, gridText } from "../repl-study/screen.ts"; +import { useReplTree } from "../repl-study/tree.ts"; +import { indexOf, project } from "../repl-study/view.ts"; +import type { ReplView } from "../repl-study/view.ts"; + +const WIDE = PROFILE_SIZES.wide; + +function* mounted( + url: string, + head: string | undefined, +): Operation<{ + view: ReplView; + screen: string; + chain: string[]; +}> { + const state = hydrate(url, journalThrough(head)); + const tree = yield* useReplTree(state); + const view = project(state); + const term = yield* useTerm(WIDE); + const result = term.render( + paint({ root: tree.root.node, view, layout: layoutOf(state, WIDE), anchor: 0 }), + { deltaTime: 0 }, + ); + expect(result.errors).toEqual([]); + const grid = applyAnsi(createGrid(WIDE.cols, WIDE.rows), Uint8Array.from(result.output)); + return { view, screen: gridText(grid), chain: tree.chain().map((node) => node.name) }; +} + +describe("the view a component is handed", () => { + it("is JSON, so it can carry no handle and no callback", function* () { + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const view = project(state); + // The boundary is a capability one, not a naming one: a marker's `scope` is + // a label and belongs here, while a journal, a store handle, a Freedom node + // or a callback could not survive this round trip. `architecture.md` states + // the same rule for an ordinary component's props. + expect(JSON.parse(JSON.stringify(view))).toEqual(view); + + const functions: string[] = []; + const seek = (value: unknown, at: string): void => { + if (typeof value === "function") { + functions.push(at); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => seek(item, `${at}[${index}]`)); + return; + } + if (typeof value === "object" && value !== null) { + for (const [key, nested] of Object.entries(value)) { + seek(nested, `${at}.${key}`); + } + } + }; + seek(view, "view"); + expect(functions).toEqual([]); + }); + + it("names what a route may address, so the router can refuse the rest", function* () { + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const index = indexOf(project(state)); + expect(index.surfaces).toContain("transcript"); + expect(index.markers.length).toBeGreaterThan(0); + // The view decides what exists; a URL only addresses it. + expect(index.scopes).not.toContain("no-such-scope"); + }); +}); + +describe("a component is a body on a node", () => { + it("renders the REPL by walking the mounted tree", function* () { + const { screen } = yield* mounted("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + expect(screen).toContain("SESSION"); + expect(screen).toContain("BINDINGS"); + expect(screen).toContain("Entry 1"); + }); + + it("gives rendering and focus the same tree to come off", function* () { + const { screen, chain } = yield* mounted("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + // The five regions are focusable *and* drawn, from one structure. + expect(chain).toEqual([ + "region:sessions", + "region:transcript", + "region:bindings", + "region:input", + "region:history", + ]); + expect(screen.length).toBeGreaterThan(0); + }); + + it("lets a parent wrap what its children already rendered", function* () { + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const tree = yield* useReplTree(state); + const parent = tree.root.node.createChild("probe:parent"); + const child = parent.createChild("probe:child"); + attach(child, ({ node }) => [{ kind: "text", value: node.name } as never], undefined, { + rect: { x: 0, y: 0, width: 1, height: 1 }, + dense: false, + }); + attach( + parent, + ({ children }) => ["before" as never, ...children, "after" as never], + undefined, + { + rect: { x: 0, y: 0, width: 1, height: 1 }, + dense: false, + }, + ); + const ops = walk(parent); + expect(ops.length).toBe(3); + expect(ops[0]).toBe("before"); + expect(ops[2]).toBe("after"); + }); + + it("keeps the node when its data changes, rather than rebuilding", function* () { + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const tree = yield* useReplTree(state); + const node = tree.chain().find((candidate) => candidate.name === "region:transcript")!; + const before = node.id; + const view = project(state); + const layout = layoutOf(state, WIDE); + paint({ root: tree.root.node, view, layout, anchor: 0 }); + paint({ root: tree.root.node, view, layout, anchor: 12 }); + const after = tree.chain().find((candidate) => candidate.name === "region:transcript")!; + expect(after.id).toBe(before); + expect(after).toBe(node); + }); +}); From 46d4db938f16ea8bc4177cbc414762fc17cb684a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 12:31:16 -0400 Subject: [PATCH 11/57] =?UTF-8?q?=F0=9F=93=9A=20Render=20the=20component?= =?UTF-8?q?=20catalog=20from=20the=20mounted=20tree=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deno task repl:study --catalog` is #840's documented command. It renders every state the contract names — an empty REPL, nested execution, three concurrent Agent sessions, the project, review and confirmation drawers, bindings at two scopes, historical inspection, a recorded drawer and a settled entry — through the mounted Freedom tree at the wide and the narrow profile. Twenty-two captures are committed under `scripts/tests/fixtures/repl-catalog/` and re-rendered exactly by the suite. A catalog entry is a *location* — a URL and how far the execution had recorded — because that is what the interface is addressed by. Each one hydrates, mounts a tree, enters through the same `enterRoute()` the interactive harness uses, projects one immutable `ReplView`, and walks the tree. A catalog capture therefore cannot show something the running REPL would not. `drawerBody` renders a recorded drawer as the state it recorded: every control disabled, `recorded · read-only` beneath them, and no affordance that would do nothing. A case holds that. Two corrections from review, both mine: **`BodyContext` no longer carries the Freedom node**, which its own documentation had already said it did not. A body receives a read-only `Surface` — one unique id and its semantic name — so it cannot create children, remove itself, set props or reach its scope. A case asserts the context has exactly those four members. **Components are addressed by the node's unique id, not its name.** Two nodes legitimately share a name — the Execution History region is both a pane and the way out of a drawer's trap — and the renderer refused the duplicate. `render.ts` still draws #838's frames from rectangles. Removing that second path is the next slice; it is a migration state, not a limitation. --- scripts/repl-study/README.md | 15 ++ scripts/repl-study/catalog.ts | 169 ++++++++++++++++++ scripts/repl-study/component.ts | 31 +++- scripts/repl-study/components.ts | 77 +++++--- scripts/repl-study/main.ts | 32 +++- scripts/repl-study/paint.ts | 21 ++- .../repl-catalog/bindings-document.narrow.txt | 7 + .../repl-catalog/bindings-document.wide.txt | 50 ++++++ .../repl-catalog/bindings-plan.narrow.txt | 23 +++ .../repl-catalog/bindings-plan.wide.txt | 50 ++++++ .../repl-catalog/drawer-confirm.narrow.txt | 12 ++ .../repl-catalog/drawer-confirm.wide.txt | 50 ++++++ .../repl-catalog/drawer-historical.narrow.txt | 15 ++ .../repl-catalog/drawer-historical.wide.txt | 50 ++++++ .../repl-catalog/drawer-project.narrow.txt | 13 ++ .../repl-catalog/drawer-project.wide.txt | 50 ++++++ .../repl-catalog/drawer-review.narrow.txt | 17 ++ .../repl-catalog/drawer-review.wide.txt | 50 ++++++ .../fixtures/repl-catalog/empty.narrow.txt | 28 +++ .../fixtures/repl-catalog/empty.wide.txt | 48 +++++ .../repl-catalog/inspecting.narrow.txt | 15 ++ .../fixtures/repl-catalog/inspecting.wide.txt | 50 ++++++ .../fixtures/repl-catalog/nested.narrow.txt | 27 +++ .../fixtures/repl-catalog/nested.wide.txt | 50 ++++++ .../fixtures/repl-catalog/sessions.narrow.txt | 14 ++ .../fixtures/repl-catalog/sessions.wide.txt | 50 ++++++ .../fixtures/repl-catalog/settled.narrow.txt | 28 +++ .../fixtures/repl-catalog/settled.wide.txt | 50 ++++++ scripts/tests/repl-components.test.ts | 89 ++++++++- 29 files changed, 1154 insertions(+), 27 deletions(-) create mode 100644 scripts/repl-study/catalog.ts create mode 100644 scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/empty.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/empty.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/inspecting.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/nested.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/nested.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/sessions.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/sessions.wide.txt create mode 100644 scripts/tests/fixtures/repl-catalog/settled.narrow.txt create mode 100644 scripts/tests/fixtures/repl-catalog/settled.wide.txt diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index e63129c15..d8d3a3f2c 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -28,8 +28,18 @@ deno task repl:study --frame 07 # one frame of the focus study deno task repl:study --route 'xmd://repl/e1/transcript/entry-1/plan/+project' deno task repl:study --frame 12 --focus-map # with the numbered overlay on deno task repl:study --capture-focus captures/ # the focus study's frames, as text + +deno task repl:study --catalog # every component state, wide and narrow +deno task repl:study --catalog captures/ # …written as text files ``` +**`--catalog` is #840's documented command.** It renders every state the +component contract names — an empty REPL, nested execution, three concurrent +Agent sessions, the three Elicit drawers, bindings at two scopes, historical +inspection, a recorded drawer and a settled entry — through the **mounted +Freedom tree**, at the wide and the narrow profile. The committed captures are +under `scripts/tests/fixtures/repl-catalog/`. + **`--frame` and `--route` are the same door.** A frame is a location the Product Owner's focus study names, and `--frame 07` is shorthand for its URL plus how far the execution had recorded when it was taken. `--route` takes any @@ -173,6 +183,11 @@ makes a capture legible as evidence against the frame it reproduces. | `route.ts` | the URL schema, parsing, formatting, and push versus replace | | `vendor/freedom/` | `@bomb.sh/freedom`, vendored and pinned — the node tree that owns focus | | `tree.ts` | the interface as Freedom nodes: surfaces, panels, drawers, controls | +| `view.ts` | the immutable `ReplView` a component is handed, and what a route may address | +| `component.ts` | the component interface: a body on a node, and the render walk | +| `components.ts` | the bodies — sessions, transcript, bindings, input, drawer, history | +| `paint.ts` | the downward pass that hands each mounted node its own slice | +| `catalog.ts` | every component state the catalog renders | | `keys.ts` | a key delivered to the focused node, through its ancestors' middleware | | `drive.ts` | one event, carried through the tree and the store — the harness and the evidence share it | | `surfaces.ts` | which controls a drawer carries, and in what order | diff --git a/scripts/repl-study/catalog.ts b/scripts/repl-study/catalog.ts new file mode 100644 index 000000000..ac71d7a72 --- /dev/null +++ b/scripts/repl-study/catalog.ts @@ -0,0 +1,169 @@ +/** + * Every state the component catalog renders, from fixtures alone. + * + * One documented command draws each of these through the mounted component + * tree, at each layout profile. A catalog entry is a *location* — a URL and how + * far the execution had recorded — because that is what the interface is + * addressed by; the components it mounts follow from projecting that state. + * + * The long tail #840 also asks for — large values, long source, code blocks, + * tables, deep nesting, dense history, secrets and an invisible helper scope — + * is a later slice. What is here is the baseline the rest hangs off. + */ + +import type { Operation } from "effection"; + +import { paint } from "./paint.ts"; +import { PROFILE_SIZES, useTerm } from "./capture.ts"; +import type { Size } from "./capture.ts"; +import { journalThrough } from "./journal.ts"; +import { hydrate, layoutOf } from "./store.ts"; +import { useReplTree } from "./tree.ts"; +import { enterRoute } from "./drive.ts"; +import { project } from "./view.ts"; +import { applyAnsi, createGrid, gridText } from "./screen.ts"; +import type { Profile } from "./layout.ts"; + +export interface CatalogEntry { + readonly id: string; + readonly title: string; + readonly url: string; + /** How far the execution had recorded. A URL never carries this. */ + readonly head?: string; +} + +export const CATALOG: readonly CatalogEntry[] = [ + { id: "empty", title: "An empty REPL, before anything has run", url: "xmd://repl/e1/input" }, + { + id: "nested", + title: "Nested execution, with the Plan scope open", + url: "xmd://repl/e1/transcript/entry-1/document", + head: "cp-06", + }, + { + id: "sessions", + title: "Three concurrent Agent sessions", + url: "xmd://repl/e1/sessions/entry-1/document", + head: "cp-13", + }, + { + id: "drawer-project", + title: "The project Elicit drawer", + url: "xmd://repl/e1/transcript/entry-1/document/+project", + head: "cp-14", + }, + { + id: "drawer-review", + title: "The plan-review Elicit drawer", + url: "xmd://repl/e1/transcript/entry-1/document/plan/+review", + head: "cp-08", + }, + { + id: "drawer-confirm", + title: "The README confirmation drawer", + url: "xmd://repl/e1/transcript/entry-1/document/+confirm", + head: "cp-16", + }, + { + id: "bindings-plan", + title: "Bindings at the Plan scope", + url: "xmd://repl/e1/bindings/entry-1/document/plan", + head: "cp-06", + }, + { + id: "bindings-document", + title: "Bindings at the document scope", + url: "xmd://repl/e1/bindings/entry-1/document", + head: "cp-13", + }, + { + id: "inspecting", + title: "Historical inspection, reconstructed and read-only", + url: "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", + head: "cp-18", + }, + { + id: "drawer-historical", + title: "A recorded drawer, rendered without anything to act on", + url: "xmd://repl/e1/transcript/entry-1/document/+project?at=cp-04&inspect", + head: "cp-18", + }, + { + id: "settled", + title: "A settled entry, the input ready for the next one", + url: "xmd://repl/e1/input?draft=%3CPlan%3E", + head: "cp-22", + }, +]; + +export function entry(id: string): CatalogEntry | undefined { + return CATALOG.find((one) => one.id === id); +} + +export interface CatalogFrame { + readonly name: string; + readonly entry: CatalogEntry; + readonly profile: Profile; + readonly size: Size; + readonly text: string; +} + +/** + * One catalog state, rendered through the mounted component tree. + * + * The tree is mounted, entered, handed the projected view and walked — the same + * path the interactive harness takes, so a catalog capture cannot show + * something the running REPL would not. + */ +export function renderCatalog(subject: CatalogEntry, profile: Profile): Operation { + return { + *[Symbol.iterator]() { + const size = PROFILE_SIZES[profile]; + const state = hydrate(subject.url, journalThrough(subject.head)); + const tree = yield* useReplTree(state); + yield* enterRoute(tree, state); + const term = yield* useTerm(size); + const result = term.render( + paint({ + root: tree.root.node, + view: project(state), + layout: layoutOf(state, size), + anchor: 0, + }), + { deltaTime: 0 }, + ); + if (result.errors.length > 0) { + throw new Error(`the renderer reported ${JSON.stringify(result.errors)}`); + } + const grid = applyAnsi(createGrid(size.cols, size.rows), Uint8Array.from(result.output)); + return { + name: `${subject.id}.${profile}`, + entry: subject, + profile, + size, + text: gridText(grid), + }; + }, + }; +} + +/** The whole catalog, at the profiles a reader can check it at. */ +export function renderAll(): Operation { + return { + *[Symbol.iterator]() { + const frames: CatalogFrame[] = []; + for (const subject of CATALOG) { + for (const profile of ["wide", "narrow"] as const) { + frames.push(yield* renderCatalog(subject, profile)); + } + } + return frames; + }, + }; +} + +/** What a catalog capture is written as: the state it shows, then the screen. */ +export function catalogText(frame: CatalogFrame): string { + const header = `${frame.name} · ${frame.size.cols} × ${frame.size.rows} · ${frame.entry.title}`; + return `${header}\n${frame.text.replace(/\n+$/, "")}\n`; +} diff --git a/scripts/repl-study/component.ts b/scripts/repl-study/component.ts index aedd48733..12fb6c291 100644 --- a/scripts/repl-study/component.ts +++ b/scripts/repl-study/component.ts @@ -17,6 +17,11 @@ * A component is handed its own immutable view subtree and nothing else — no * journal, no store, no node, no geometry beyond the box its parent gives it, * and no callback. What it wants to happen it says as an action. + * + * "No node" is literal. A body receives a read-only `Surface` carrying the one + * identity its operations are addressed by, never the Freedom node itself: a + * body holding the node could create children, remove itself, set props or + * reach its scope, and the tree's authority over topology would be advisory. */ import { createNodeData } from "./vendor/freedom/upstream/index.ts"; @@ -39,8 +44,29 @@ export interface Placement { readonly dense: boolean; } +/** + * The minimum a body needs to know about itself. + * + * One identity, read-only. Not the node — a body cannot reach topology, focus, + * scope or props through this, which is what keeps the tree authoritative + * rather than merely conventional. + */ +export interface Surface { + /** + * The unique id this component's operations are addressed by. + * + * The node's own id, not its name: two nodes may legitimately share a + * semantic name — the Execution History region is both a pane and the way + * out of a drawer's trap — and the renderer requires each addressed element + * to be declared once. + */ + readonly id: string; + /** The semantic name, for a body that renders itself differently by role. */ + readonly name: string; +} + export interface BodyContext { - readonly node: Node; + readonly self: Surface; /** This component's own immutable view subtree. */ readonly data: Data; readonly placement: Placement; @@ -77,8 +103,9 @@ export function attach( ): Update { let current = data; let where = placement; + const self: Surface = { id: node.id, name: node.name === "" ? "root" : node.name }; node.data.set(bodyKey, { - render: (self, children) => body({ node: self, data: current, placement: where, children }), + render: (_node, children) => body({ self, data: current, placement: where, children }), }); return (next, to) => { current = next; diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index 87638cc0e..05c92ca0e 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -31,6 +31,7 @@ import { import type { VisualLine } from "./render.ts"; import type { BindingsView, + DrawerView, ContextualView, HistoryView, ReplView, @@ -40,9 +41,9 @@ import type { import { isRedacted } from "./view.ts"; /** The crumb line above the panes. */ -export const headerBody: Body> = ({ node, data, placement }) => +export const headerBody: Body> = ({ self, data, placement }) => region( - node.name, + self.id, placement.rect, [ { @@ -58,7 +59,7 @@ export const headerBody: Body> = ({ node, data { bg: BG.center }, ); -export const sessionsBody: Body = ({ node, data, placement, children }) => { +export const sessionsBody: Body = ({ self, data, placement, children }) => { const lines: VisualLine[] = []; if (!placement.dense) { lines.push(plain("XMD REPL", C.out), blank()); @@ -108,7 +109,7 @@ export const sessionsBody: Body = ({ node, data, placement, childr lines.push(plain(`· ${record}`, C.dim)); } } - return region(node.name, placement.rect, lines, { bg: BG.side, children }); + return region(self.id, placement.rect, lines, { bg: BG.side, children }); } if (data.sessions.length === 0) { @@ -117,7 +118,7 @@ export const sessionsBody: Body = ({ node, data, placement, childr lines.push(plain(wrapped, C.dim)); } } - return region(node.name, placement.rect, lines, { bg: BG.side, children }); + return region(self.id, placement.rect, lines, { bg: BG.side, children }); } for (const session of data.sessions) { @@ -147,7 +148,7 @@ export const sessionsBody: Body = ({ node, data, placement, childr } lines.push(blank()); } - return region(node.name, placement.rect, lines, { bg: BG.side, children }); + return region(self.id, placement.rect, lines, { bg: BG.side, children }); }; export interface TranscriptData { @@ -156,7 +157,7 @@ export interface TranscriptData { readonly anchor: number; } -export const transcriptBody: Body = ({ node, data, placement, children }) => { +export const transcriptBody: Body = ({ self, data, placement, children }) => { const rect = placement.rect; const width = Math.max(0, rect.width - 2); const lines: VisualLine[] = []; @@ -168,7 +169,7 @@ export const transcriptBody: Body = ({ node, data, placement, ch lines.push(plain(wrapped, C.dim)); } } - return region(node.name, rect, lines, { bg: BG.center, children }); + return region(self.id, rect, lines, { bg: BG.center, children }); } const running = entry.state === "running"; lines.push( @@ -196,10 +197,10 @@ export const transcriptBody: Body = ({ node, data, placement, ch } else if (data.anchor > 0) { lines.push(plain(`▴ ${data.anchor} earlier lines · ↑ scrolls back`, C.dim)); } - return region(node.name, rect, lines, { bg: BG.center, children }); + return region(self.id, rect, lines, { bg: BG.center, children }); }; -export const bindingsBody: Body = ({ node, data, placement, children }) => { +export const bindingsBody: Body = ({ self, data, placement, children }) => { const lines: VisualLine[] = [label("BINDINGS"), plain(data.scopeName, C.dim), blank()]; if (data.bindings.length === 0) { for (const placeholder of data.placeholder) { @@ -207,7 +208,7 @@ export const bindingsBody: Body = ({ node, data, placement, childr lines.push(plain(wrapped, C.dim)); } } - return region(node.name, placement.rect, lines, { bg: BG.bind, children }); + return region(self.id, placement.rect, lines, { bg: BG.bind, children }); } for (const binding of data.bindings) { if (isRedacted(binding)) { @@ -227,10 +228,10 @@ export const bindingsBody: Body = ({ node, data, placement, childr } lines.push(blank()); } - return region(node.name, placement.rect, lines, { bg: BG.bind, children }); + return region(self.id, placement.rect, lines, { bg: BG.bind, children }); }; -export const inputBody: Body = ({ node, data, placement, children }) => { +export const inputBody: Body = ({ self, data, placement, children }) => { const width = Math.max(0, placement.rect.width - 2); const lines: VisualLine[] = [ { @@ -246,10 +247,10 @@ export const inputBody: Body = ({ node, data, placement }, plain(data.draft === "" ? data.placeholder : data.draft, C.settledText), ]; - return region(node.name, placement.rect, lines, { bg: BG.input, children }); + return region(self.id, placement.rect, lines, { bg: BG.input, children }); }; -export const historyBody: Body = ({ node, data, placement, children }) => { +export const historyBody: Body = ({ self, data, placement, children }) => { const rect = placement.rect; const inner = Math.max(0, rect.width - 2); const compact = placement.dense; @@ -289,17 +290,49 @@ export const historyBody: Body = ({ node, data, placement, children ], }); } - return region(node.name, rect, lines, { bg: BG.footer, children }); + return region(self.id, rect, lines, { bg: BG.footer, children }); +}; + +/** + * One suspension's drawer. + * + * A recorded drawer renders the state it recorded and offers nothing to act on: + * `historical` disables every control, and the body says so rather than drawing + * affordances that would do nothing. + */ +export const drawerBody: Body = ({ self, data, placement, children }) => { + const width = Math.max(0, placement.rect.width - 2); + const lines: VisualLine[] = [plain(data.heading, C.hold)]; + for (const wrapped of wrapText(data.origin, width)) { + lines.push(plain(wrapped, C.dim)); + } + lines.push(blank()); + for (const line of data.lines) { + for (const wrapped of wrapText(line, width)) { + lines.push(plain(wrapped, C.src)); + } + } + lines.push(blank()); + for (const control of data.controls) { + lines.push({ + segments: [ + { text: control.enabled ? " " : "· ", color: C.dim, width: 2 }, + { text: control.label, color: control.enabled ? C.src : C.dim }, + ], + }); + } + if (data.historical) { + lines.push(blank(), plain("recorded · read-only", C.gold)); + } + return region(self.id, placement.rect, lines, { bg: BG.drawer, children }); }; /** A structural outlet: it draws nothing and contributes its children unchanged. */ export const outletBody: Body = ({ children }) => [...children]; /** The screen, which every region floats against. */ -export const rootBody: Body = ({ placement, children }) => [ - // The root node has no name of its own; the renderer's own id for the screen - // is what every region floats against. - open("root", { +export const rootBody: Body = ({ self, placement, children }) => [ + open(self.id, { layout: { width: fixed(placement.rect.width), height: fixed(placement.rect.height) }, bg: BG.app, }), @@ -308,12 +341,12 @@ export const rootBody: Body = ({ placement, children }) => [ ]; export const tooSmallBody: Body<{ readonly cols: number; readonly rows: number }> = ({ - node, + self, data, placement, }) => region( - node.name, + self.id, placement.rect, [ plain("Terminal too small", C.out), diff --git a/scripts/repl-study/main.ts b/scripts/repl-study/main.ts index 973d44cda..6c6e91810 100644 --- a/scripts/repl-study/main.ts +++ b/scripts/repl-study/main.ts @@ -18,13 +18,15 @@ import type { Operation } from "effection"; import { captureAll, captureFocus, PROFILE_SIZES, renderFrame, writeCaptures } from "./capture.ts"; import { frame as studyFrame, FRAMES } from "./frames.ts"; +import { catalogText, CATALOG, renderAll } from "./catalog.ts"; import { parseRoute } from "./route.ts"; import { fixture } from "./fixtures.ts"; import { runInteractive, runReplay } from "./host.ts"; import type { TraceEntry } from "./host.ts"; import { JOURNEY, playbackBetween } from "./playback.ts"; import type { Playback } from "./playback.ts"; -import { writeTextFile } from "@effectionx/fs"; +import { ensureDir, writeTextFile } from "@effectionx/fs"; +import { join } from "node:path"; import { isFixtureName } from "./model.ts"; import type { FixtureName } from "./model.ts"; import type { Profile } from "./layout.ts"; @@ -43,6 +45,7 @@ const USAGE = [ " repl-study [--frame ] --focus-map with the numbered focus map drawn", " repl-study --capture [--mutation ]", " repl-study --capture-focus the focus study's frames, as text", + " repl-study --catalog [] every component state, wide and narrow", " repl-study --print [--mutation ]", " repl-study --replay [--interrupt-after ] [--fail-after ] [--mutation ]", "", @@ -50,6 +53,7 @@ const USAGE = [ "profiles: wide, medium, narrow, too-small", "playbacks: empty→nested, nested→generated, generated→drawer, drawer→paused, paused→settled", `frames: ${FRAMES.map((one) => one.id).join(", ")}`, + `catalog: ${CATALOG.map((one) => one.id).join(", ")}`, ].join("\n"); type Mode = @@ -67,6 +71,7 @@ type Mode = readonly focusMap?: boolean; } | { readonly kind: "capture"; readonly directory: string; readonly focus?: boolean } + | { readonly kind: "catalog"; readonly directory?: string } | { readonly kind: "print"; readonly fixture: FixtureName; readonly profile: Profile } | { readonly kind: "replay"; readonly interruptAfter?: number; readonly failAfter?: number }; @@ -202,6 +207,15 @@ export function parse(argv: readonly string[]): Invocation | string { route = url; } else if (argument === "--focus-map") { focusMap = true; + } else if (argument === "--catalog") { + // A bare `--catalog` prints every state; a directory writes them. + const next = argv[at + 1]; + if (next === undefined || next.startsWith("--")) { + mode = { kind: "catalog" }; + } else { + at += 1; + mode = { kind: "catalog", directory: next }; + } } else if (argument === "--capture-focus") { const directory = value(); if (directory === undefined) { @@ -249,6 +263,22 @@ export function parse(argv: readonly string[]): Invocation | string { function* run(invocation: Invocation): Operation { const { mode, mutation } = invocation; + if (mode.kind === "catalog") { + const frames = yield* renderAll(); + if (mode.directory === undefined) { + for (const frame of frames) { + console.log(catalogText(frame)); + } + return; + } + yield* ensureDir(mode.directory); + for (const frame of frames) { + yield* writeTextFile(join(mode.directory, `${frame.name}.txt`), catalogText(frame)); + } + console.log(`wrote ${frames.length} catalog states to ${mode.directory}`); + return; + } + if (mode.kind === "capture") { const captures = mode.focus === true ? yield* captureFocus() : yield* captureAll(); yield* writeCaptures(mode.directory, captures); diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts index a09548aa8..8f4150db3 100644 --- a/scripts/repl-study/paint.ts +++ b/scripts/repl-study/paint.ts @@ -19,6 +19,7 @@ import { attach, walk } from "./component.ts"; import type { Placement } from "./component.ts"; import { bindingsBody, + drawerBody, headerBody, historyBody, inputBody, @@ -65,9 +66,27 @@ function dress(node: Node, view: ReplView, layout: Layout, anchor: number): void return; } if (name === "region:input") { - attach(node, inputBody, view.contextual.input, placed(layout.contextual, layout)); + // While a drawer is open it owns the contextual band, so the input has + // nowhere to draw — the parent decides placement, not the child. + const taken = view.contextual.drawers.length > 0; + attach( + node, + inputBody, + view.contextual.input, + placed(taken ? undefined : layout.contextual, layout), + ); return; } + if (name.startsWith("drawer:")) { + const kind = name.slice("drawer:".length); + const drawer = view.contextual.drawers.find((candidate) => candidate.kind === kind); + if (drawer !== undefined) { + // A drawer takes the contextual band; the input keeps its own node and + // simply has nowhere to draw while one is open. + attach(node, drawerBody, drawer, placed(layout.contextual, layout)); + return; + } + } if (name === "region:history") { attach(node, historyBody, view.history, placed(layout.footer, layout)); return; diff --git a/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt b/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt new file mode 100644 index 000000000..75993c27e --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt @@ -0,0 +1,7 @@ +bindings-document.narrow · 90 × 28 · Bindings at the document scope + + BINDINGS + document scope + + readme + # Northstar diff --git a/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt new file mode 100644 index 000000000..73662bad9 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt @@ -0,0 +1,50 @@ +bindings-document.wide · 200 × 50 · Bindings at the document scope + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 48.9s ↳ document scope suspended BINDINGS + SESSIONS · 3 document scope + chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor + document readme + │ plan-a91f7c ▶ ENTER markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar + │ ● WAITING + review-b72e1d │ │ Enter the project details. + ● responding reviewer · turn 1 · streaming │ ● WAITING + streaming · background update · selection u… │ ▲ suspended · answer in the drawer below + + implement-c31d2e + · queued implementer · no turn yet + + + + + + + + + + + + + + + + + + + + + + + + + + DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + + + + EXECUTION HISTORY LIVE [ Pause ] + recorded · 00:49 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt b/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt new file mode 100644 index 000000000..b6d053375 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt @@ -0,0 +1,23 @@ +bindings-plan.narrow · 90 × 28 · Bindings at the Plan scope + + BINDINGS + Plan scope + + prompt + "Create an XMD program that asks me for a + project name and a description…" + + syntax + XMD catalog · 47 symbols + component, control, agent, io + + inputs + { + surface: "component", + session: "plan-a91f7c", + budget: 3 + } + + draft + # Create a project README + diff --git a/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt new file mode 100644 index 000000000..588948261 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt @@ -0,0 +1,50 @@ +bindings-plan.wide · 200 × 50 · Bindings at the Plan scope + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 31.4s ↳ Plan scope open BINDINGS + SESSIONS · 1 Plan scope + chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor + document prompt + │ plan-a91f7c repl:entry-1 · submitted source is immutable while running "Create an XMD program that a… + ✓ completed planner · turn 1 · returned 5… ▶ ENTER project name and a descriptio… + │ Create a project README + │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, syntax + │ reviews its own draft, and returns it for admission into this document scope. prose + │ ● ACTIVE XMD catalog · 47 symbols + │ │ ✓ Read the Prompt · prompt component, control, agent, io + │ │ ✓ Prepare the planning inputs · syntax, inputs + │ │ ✓ Create the first draft · draft inputs + │ │ ▾ Check the draft json + │ │ ● ACTIVE { + │ │ │ ✓ SETTLED surface: "component", + │ │ ● WAITING session: "plan-a91f7c", + │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. budget: 3 + │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be } + │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. + │ │ │ ✓ SETTLED draft + │ │ │ ✓ SETTLED XMD source · 59 lines + │ │ ● WAITING # Create a project README + │ │ ● ACTIVE · document scope + + Create README.md with the content shown above? + # Northstar + + A lightweight workspace for coordinating coding agents. + + README preview · scroll region + Approve + Decline diff --git a/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt new file mode 100644 index 000000000..fe826f71d --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt @@ -0,0 +1,50 @@ +drawer-confirm.wide · 200 × 50 · The README confirmation drawer + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 48.9s ↳ document scope suspended BINDINGS + SESSIONS · 3 document scope + chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor + document readme + │ plan-a91f7c ▶ ENTER markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar + │ ● WAITING + review-b72e1d │ │ Enter the project details. + ● responding reviewer · turn 1 · streaming │ ● WAITING + streaming · background update · selection u… │ ▲ suspended · answer in the drawer below + + implement-c31d2e + · queued implementer · no turn yet + + + + + + + + + + + + + + + + CONFIRMATION REQUIRED + suspended at · document scope + + Create README.md with the content shown above? + # Northstar + + A lightweight workspace for coordinating coding agents. + + README preview · scroll region + Approve + Decline + + + + EXECUTION HISTORY LIVE [ Pause ] + recorded · 00:49 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt new file mode 100644 index 000000000..439ec9891 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt @@ -0,0 +1,15 @@ +drawer-historical.narrow · 90 × 28 · A recorded drawer, rendered without anything to act on + INPUT REQUIRED + suspended at · document scope · validated against the Elicit + schema + + Enter the project details. + Project name: Northstar + Description: A lightweight workspace for coordinating coding agents. + + · Project name + · Description + · Schema disclosure · ⌥S + · Submit + + recorded · read-only diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt new file mode 100644 index 000000000..b49ac14dd --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt @@ -0,0 +1,50 @@ +drawer-historical.wide · 200 × 50 · A recorded drawer, rendered without anything to act on + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 53.0s ↳ reconstructed · read-only BINDINGS + ENTRY 1 · CREATE PROJECT README Plan scope · as recorded + inspecting recorded history · read-only reconstructed from the journal · no live action is possible here + Plan prompt + 00:02 ◆ Entry 1 submitted ● ACTIVE "Create an XMD program that a… + 00:05 ● document scope entered │ ✓ Read the Prompt · prompt + 00:12 ● Plan entered │ ▾ Prepare the planning inputs + 00:18 ● planning inputs prepared │ ✓ SETTLED + 00:29 ● planning Agent response admitted │ ● ACTIVE + 00:30 ● draft checked │ XMD catalog · 47 symbols · component, control, agent, io + 00:41 ● review returned Approve + 00:47 ● Plan replaced by returned program + 00:49 ● project Elicit requested + 00:52 ● project Elicit answered + 00:53 ● confirmation Elicit requested + + ▸ 26 internal records + + SELECTED CHECKPOINT + Plan entered + 00:12 elapsed · Entry 1 › document › Plan + · scope.enter Plan + · inputs.bound content + · component.resolved Plan.md + + + + + INPUT REQUIRED + suspended at · document scope · validated against the Elicit schema + + Enter the project details. + Project name: Northstar + Description: A lightweight workspace for coordinating coding agents. + + · Project name + · Description + · Schema disclosure · ⌥S + · Submit + + recorded · read-only + + EXECUTION HISTORY INSPECTING [ Continue ] [ Return to paused head ] [ Fork from here ] + recorded · 00:53 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt new file mode 100644 index 000000000..4420a4f5f --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt @@ -0,0 +1,13 @@ +drawer-project.narrow · 90 × 28 · The project Elicit drawer + INPUT REQUIRED + suspended at · document scope · validated against the Elicit + schema + + Enter the project details. + Project name: Northstar + Description: A lightweight workspace for coordinating coding agents. + + Project name + Description + Schema disclosure · ⌥S + Submit diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt new file mode 100644 index 000000000..c1635099e --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt @@ -0,0 +1,50 @@ +drawer-project.wide · 200 × 50 · The project Elicit drawer + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 48.9s ↳ document scope suspended BINDINGS + SESSIONS · 3 document scope + chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor + document readme + │ plan-a91f7c ▶ ENTER markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar + │ ● WAITING + review-b72e1d │ │ Enter the project details. + ● responding reviewer · turn 1 · streaming │ ● WAITING + streaming · background update · selection u… │ ▲ suspended · answer in the drawer below + + implement-c31d2e + · queued implementer · no turn yet + + + + + + + + + + + + + + + + INPUT REQUIRED + suspended at · document scope · validated against the Elicit schema + + Enter the project details. + Project name: Northstar + Description: A lightweight workspace for coordinating coding agents. + + Project name + Description + Schema disclosure · ⌥S + Submit + + + + EXECUTION HISTORY LIVE [ Pause ] + recorded · 00:49 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt new file mode 100644 index 000000000..647df6a74 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt @@ -0,0 +1,17 @@ +drawer-review.narrow · 90 × 28 · The plan-review Elicit drawer + REVIEW REQUIRED + suspended at · Plan scope · 59 lines returned + + # Create a project README + + Provide the project name and a one-sentence description. + + + Enter the project details. + ▸ 53 more lines · ⌥↓ scrolls the Plan + + Plan review · scroll region + Approve + Request changes + Stop + Submit diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt new file mode 100644 index 000000000..4ddf30dc0 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt @@ -0,0 +1,50 @@ +drawer-review.wide · 200 × 50 · The plan-review Elicit drawer + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 31.4s ↳ Plan scope open BINDINGS + SESSIONS · 1 Plan scope + chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor + document prompt + │ plan-a91f7c repl:entry-1 · submitted source is immutable while running "Create an XMD program that a… + ✓ completed planner · turn 1 · returned 5… ▶ ENTER project name and a descriptio… + │ Create a project README + │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, syntax + │ reviews its own draft, and returns it for admission into this document scope. prose + │ ● ACTIVE XMD catalog · 47 symbols + │ │ ✓ Read the Prompt · prompt component, control, agent, io + │ │ ✓ Prepare the planning inputs · syntax, inputs + │ │ ✓ Create the first draft · draft inputs + │ │ ▾ Check the draft json + │ │ ● ACTIVE { + │ │ │ ✓ SETTLED surface: "component", + │ │ ● WAITING session: "plan-a91f7c", + │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. budget: 3 + │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be } + │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. + │ │ │ ✓ SETTLED draft + │ │ │ ✓ SETTLED XMD source · 59 lines + │ │ ● WAITING # Create a project README + │ │ ● ACTIVE · Plan scope · 59 lines returned + + # Create a project README + + Provide the project name and a one-sentence description. + + + Enter the project details. + ▸ 53 more lines · ⌥↓ scrolls the Plan + + Plan review · scroll region + Approve + Request changes + EXECUTION HISTORY LIVE [ Pause ] + recorded · 00:31 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/empty.narrow.txt b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt new file mode 100644 index 000000000..18bb796a9 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt @@ -0,0 +1,28 @@ +empty.narrow · 90 × 28 · An empty REPL, before anything has run + + TRANSCRIPT + + No executions yet. + + Submitted blocks append here as immutable entries. Each entry keeps its source, its + rendered output, and the bindings it published. + + + + + + + + + + + + + + + + + + + REPL INPUT ⇧⏎ newline [ Run ] + Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-catalog/empty.wide.txt b/scripts/tests/fixtures/repl-catalog/empty.wide.txt new file mode 100644 index 000000000..4ff081091 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/empty.wide.txt @@ -0,0 +1,48 @@ +empty.wide · 200 × 50 · An empty REPL, before anything has run + XMD REPL + + SESSION JOURNAL STATE + TRANSCRIPT BINDINGS + No sessions yet REPL scope + No executions yet. + Agent sessions appear here as executions open No REPL bindings yet + them. Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the Values named with as appear + They persist after an entry settles. bindings it published. here for the active scope. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REPL INPUT ⇧⏎ newline [ Run ] + Enter XMD or invoke a document… + + + EXECUTION HISTORY IDLE + No recorded execution yet diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt new file mode 100644 index 000000000..9f912e222 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt @@ -0,0 +1,15 @@ +inspecting.narrow · 90 × 28 · Historical inspection, reconstructed and read-only + + HISTORY INSPECTING [ Continue ] [ Return to paused… + recorded · 00:53 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document + 00:12 ● Plan entered Entry 1 › document › Plan + 00:18 ● planning inputs prepared … › Plan › PlanInputs + 00:29 ● planning Agent response admitted … › Plan › Prompt + 00:30 ● draft checked … › Plan › Check + 00:41 ● review returned Approve … › Plan › Elicit + 00:47 ● Plan replaced by returned program Entry 1 › document + 00:49 ● project Elicit requested Entry 1 › document + 00:52 ● project Elicit answered Entry 1 › document + 00:53 ● confirmation Elicit requested Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt new file mode 100644 index 000000000..f3e907c2f --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt @@ -0,0 +1,50 @@ +inspecting.wide · 200 × 50 · Historical inspection, reconstructed and read-only + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 53.0s ↳ reconstructed · read-only BINDINGS + ENTRY 1 · CREATE PROJECT README Plan scope · as recorded + inspecting recorded history · read-only reconstructed from the journal · no live action is possible here + Plan prompt + 00:02 ◆ Entry 1 submitted ● ACTIVE "Create an XMD program that a… + 00:05 ● document scope entered │ ✓ Read the Prompt · prompt + 00:12 ● Plan entered │ ▾ Prepare the planning inputs + 00:18 ● planning inputs prepared │ ✓ SETTLED + 00:29 ● planning Agent response admitted │ ● ACTIVE + 00:30 ● draft checked │ XMD catalog · 47 symbols · component, control, agent, io + 00:41 ● review returned Approve + 00:47 ● Plan replaced by returned program + 00:49 ● project Elicit requested + 00:52 ● project Elicit answered + 00:53 ● confirmation Elicit requested + + ▸ 26 internal records + + SELECTED CHECKPOINT + Plan entered + 00:12 elapsed · Entry 1 › document › Plan + · scope.enter Plan + · inputs.bound content + · component.resolved Plan.md + + + + + + + + + + + + + + + DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + + + + EXECUTION HISTORY INSPECTING [ Continue ] [ Return to paused head ] [ Fork from here ] + recorded · 00:53 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/nested.narrow.txt b/scripts/tests/fixtures/repl-catalog/nested.narrow.txt new file mode 100644 index 000000000..f5a8d1286 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/nested.narrow.txt @@ -0,0 +1,27 @@ +nested.narrow · 90 × 28 · Nested execution, with the Plan scope open + + Entry 1 ● running · 31.4s ↳ Plan scope open + + ← opened from Entry 1 · live execution projection, not an editor + document + repl:entry-1 · submitted source is immutable while running + ▶ ENTER + │ Create a project README + │ Provide the project name and a one-sentence description. The Plan component drafts the + │ program that asks for them, reviews its own draft, and returns it for admission into + │ this document scope. + │ ● ACTIVE + │ │ ✓ Read the Prompt · prompt + │ │ ✓ Prepare the planning inputs · syntax, inputs + │ │ ✓ Create the first draft · draft + │ │ ▾ Check the draft + │ │ ● ACTIVE + │ │ │ ✓ SETTLED + │ │ ● WAITING + │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. + │ │ │ The reviewer has the draft, the schema it was checked against, and the + │ │ │ capabilities the document would be granted if the Plan is admitted. Nothing it + │ │ │ returns runs until this scope admits it. + │ │ │ ✓ SETTLED + ▸ 5 more lines · ↑↓ PgUp PgDn + DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] diff --git a/scripts/tests/fixtures/repl-catalog/nested.wide.txt b/scripts/tests/fixtures/repl-catalog/nested.wide.txt new file mode 100644 index 000000000..abe9eeafe --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/nested.wide.txt @@ -0,0 +1,50 @@ +nested.wide · 200 × 50 · Nested execution, with the Plan scope open + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ● running · 31.4s ↳ Plan scope open BINDINGS + SESSIONS · 1 Plan scope + chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor + document prompt + │ plan-a91f7c repl:entry-1 · submitted source is immutable while running "Create an XMD program that a… + ✓ completed planner · turn 1 · returned 5… ▶ ENTER project name and a descriptio… + │ Create a project README + │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, syntax + │ reviews its own draft, and returns it for admission into this document scope. prose + │ ● ACTIVE XMD catalog · 47 symbols + │ │ ✓ Read the Prompt · prompt component, control, agent, io + │ │ ✓ Prepare the planning inputs · syntax, inputs + │ │ ✓ Create the first draft · draft inputs + │ │ ▾ Check the draft json + │ │ ● ACTIVE { + │ │ │ ✓ SETTLED surface: "component", + │ │ ● WAITING session: "plan-a91f7c", + │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. budget: 3 + │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be } + │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. + │ │ │ ✓ SETTLED draft + │ │ │ ✓ SETTLED XMD source · 59 lines + │ │ ● WAITING # Create a project README + │ │ ● ACTIVE ENTER markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar + │ ● WAITING + review-b72e1d │ │ Enter the project details. + ● responding reviewer · turn 1 · streaming │ ● WAITING + streaming · background update · selection u… │ ▲ suspended · answer in the drawer below + + implement-c31d2e + · queued implementer · no turn yet + + + + + + + + + + + + + + + + + + + + + + + + + + DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + + + + EXECUTION HISTORY LIVE [ Pause ] + recorded · 00:49 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/settled.narrow.txt b/scripts/tests/fixtures/repl-catalog/settled.narrow.txt new file mode 100644 index 000000000..076c66b38 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/settled.narrow.txt @@ -0,0 +1,28 @@ +settled.narrow · 90 × 28 · A settled entry, the input ready for the next one + + Entry 1 ✓ completed · 41.2s ▸ source · 8 lines + + Create a project README + Provide the project name and a one-sentence description. + │ MARKDOWN + │ # Northstar + │ + │ A lightweight workspace for coordinating coding agents. + │ README.md · 63 bytes · +3 lines + README.md was created for Northstar. + no REPL bindings published · 1 file written + + + + + + + + + + + + + + REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + diff --git a/scripts/tests/fixtures/repl-catalog/settled.wide.txt b/scripts/tests/fixtures/repl-catalog/settled.wide.txt new file mode 100644 index 000000000..ccec401e2 --- /dev/null +++ b/scripts/tests/fixtures/repl-catalog/settled.wide.txt @@ -0,0 +1,50 @@ +settled.wide · 200 × 50 · A settled entry, the input ready for the next one + XMD REPL + + SESSION JOURNAL STATE + Entry 1 ✓ completed · 41.2s ▸ source · 8 lines BINDINGS + SESSIONS · 3 REPL scope + persist after settling Create a project README + Provide the project name and a one-sentence description. Entry 1 published none + │ plan-a91f7c │ MARKDOWN Values named with as appear + ✓ completed planner · turn 1 · returned 5… │ # Northstar here for the active scope. + │ + review-b72e1d │ A lightweight workspace for coordinating coding agents. + ● responding reviewer · turn 1 · streaming │ README.md · 63 bytes · +3 lines + streaming · background update · selection u… README.md was created for Northstar. + no REPL bindings published · 1 file written + implement-c31d2e + · queued implementer · no turn yet + + + + + + + + + + + + + + + + + + + + + + + + + + REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + + + + EXECUTION HISTORY IDLE + recorded · 01:01 + 00:02 ◆ Entry 1 submitted REPL + 00:05 ● document scope entered Entry 1 › document diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index 5500c74cb..bc848167b 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -13,6 +13,10 @@ import { expect } from "@executablemd/test-support/expect"; import type { Operation } from "effection"; import { PROFILE_SIZES, useTerm } from "../repl-study/capture.ts"; +import { CATALOG, catalogText, renderAll, renderCatalog } from "../repl-study/catalog.ts"; +import { readTextFile } from "@effectionx/fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { attach, walk } from "../repl-study/component.ts"; import { hydrate, layoutOf } from "../repl-study/store.ts"; import { journalThrough } from "../repl-study/journal.ts"; @@ -86,6 +90,27 @@ describe("the view a component is handed", () => { }); describe("a component is a body on a node", () => { + it("hands a body its identity and no way to reach the tree", function* () { + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const tree = yield* useReplTree(state); + const node = tree.root.node.createChild("probe:self"); + let seen: Record = {}; + attach( + node, + (context) => { + seen = { ...context }; + return []; + }, + undefined, + { rect: { x: 0, y: 0, width: 1, height: 1 }, dense: false }, + ); + walk(node); + // A body that held the node could create children, remove itself, set props + // or reach its scope; the tree's authority would be advisory. + expect(Object.keys(seen).toSorted()).toEqual(["children", "data", "placement", "self"]); + expect(Object.keys(seen.self as object).toSorted()).toEqual(["id", "name"]); + }); + it("renders the REPL by walking the mounted tree", function* () { const { screen } = yield* mounted("xmd://repl/e1/transcript/entry-1/document", "cp-14"); expect(screen).toContain("SESSION"); @@ -111,7 +136,7 @@ describe("a component is a body on a node", () => { const tree = yield* useReplTree(state); const parent = tree.root.node.createChild("probe:parent"); const child = parent.createChild("probe:child"); - attach(child, ({ node }) => [{ kind: "text", value: node.name } as never], undefined, { + attach(child, ({ self }) => [{ kind: "text", value: self.name } as never], undefined, { rect: { x: 0, y: 0, width: 1, height: 1 }, dense: false, }); @@ -144,3 +169,65 @@ describe("a component is a body on a node", () => { expect(after).toBe(node); }); }); + +const CATALOG_GOLDENS = fileURLToPath(new URL("./fixtures/repl-catalog/", import.meta.url)); + +describe("the component catalog", () => { + it("renders every committed catalog capture exactly", function* () { + const frames = yield* renderAll(); + expect(frames.length).toBe(CATALOG.length * 2); + for (const frame of frames) { + const golden = yield* readTextFile(join(CATALOG_GOLDENS, `${frame.name}.txt`)); + expect(catalogText(frame)).toBe(golden); + } + }); + + it("covers every state the contract names", function* () { + // Each of these is a state #840's Story asks the catalog to render. + for (const required of [ + "empty", + "nested", + "sessions", + "drawer-project", + "drawer-review", + "drawer-confirm", + "bindings-plan", + "bindings-document", + "inspecting", + "drawer-historical", + "settled", + ]) { + expect({ required, present: CATALOG.some((one) => one.id === required) }).toEqual({ + required, + present: true, + }); + } + }); + + it("renders a recorded drawer with nothing to act on", function* () { + const frame = yield* renderCatalog( + CATALOG.find((one) => one.id === "drawer-historical")!, + "wide", + ); + expect(frame.text).toContain("recorded · read-only"); + // A disabled control is shown as recorded state rather than as an + // affordance that would do nothing. + expect(frame.text).toContain("· Submit"); + expect(frame.text).not.toContain("[ Submit ]"); + }); + + it("says the same thing at both profiles", function* () { + for (const subject of CATALOG) { + const wide = yield* renderCatalog(subject, "wide"); + const narrow = yield* renderCatalog(subject, "narrow"); + expect({ id: subject.id, drew: wide.text.trim().length > 0 }).toEqual({ + id: subject.id, + drew: true, + }); + expect({ id: subject.id, drew: narrow.text.trim().length > 0 }).toEqual({ + id: subject.id, + drew: true, + }); + } + }); +}); From cf8c60f2688ac5fc2ea9b04db1c92f2c5caceefc Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 12:40:37 -0400 Subject: [PATCH 12/57] =?UTF-8?q?=F0=9F=94=92=20Mount=20a=20recorded=20dra?= =?UTF-8?q?wer=20with=20nothing=20to=20act=20on=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog's `drawer-historical` state rendered `recorded · read-only` while the tree went on offering its fields for focus and input: the chain held all four controls, focus landed on the project-name field, and a key was delivered to it. The picture said one thing and the mounted tree said another. A drawer reconstructed during historical inspection now keeps its complete recorded presentation — every field and control is still mounted, so the components render exactly what was recorded — and none of them is made focusable. Nothing enters the ring, nothing receives a key. What stays is the navigation that remains valid while a recorded moment is open: the Execution History path inside the drawer's own focus root. `focusable()` is one-way, so a live drawer cannot quietly become a recorded one. A drawer whose mode changed is a different drawer: reconciliation unwinds to it and rebuilds it without actionability, which is the same rule the region controls already follow. Three paths are proved rather than one: a live drawer exposes its controls in tree order and receives input; the recorded state, rebuilt cold from its URL and journal alone, renders all four controls while `tree.chain()` holds only `region:history`; and a mounted live drawer transitioned into inspection loses its actionability through reconciliation, with focus still naming a node that survived. All 22 catalog goldens are unchanged — the projection already reported the drawer as historical, so only the tree was wrong. --- scripts/repl-study/tree.ts | 25 +++++++-- scripts/tests/repl-components.test.ts | 74 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 9646d579d..5162866d1 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -102,6 +102,8 @@ export interface ReplTree { interface Mounted { readonly node: Node; readonly pop?: PopFocus; + /** True while this drawer was mounted as a recorded, read-only one. */ + readonly historical: boolean; } /** @@ -262,6 +264,16 @@ export function useReplTree(state: ReplState): Operation { ) { kept += 1; } + // A drawer that changed between live and recorded is a different + // drawer: `focusable()` is one-way, so a mounted node cannot stop being + // focusable. Unwinding to it lets it be rebuilt without actionability. + const historical = next.route.inspect; + for (let at = 0; at < Math.min(kept, drawers.length); at += 1) { + if (drawers[at].historical !== historical) { + kept = at; + break; + } + } while (drawers.length > kept) { const top = drawers.pop()!; // Pop the focus root first, so the invoking focus is restored while @@ -290,14 +302,21 @@ export function useReplTree(state: ReplState): Operation { recordPath(body, body.name); for (const target of drawerTargets(kind)) { const child = body.createChild(target.id); - focusable(child); + // A recorded drawer keeps its complete presentation and offers + // nothing to act on: its fields and controls are mounted so the + // components can render them, and never made focusable, so none of + // them enters the ring or receives a key. + if (!historical) { + focusable(child); + } } // The footer stays reachable through a suspension, so it is inside - // the pushed root rather than outside it. + // the pushed root rather than outside it — and it is the navigation + // that stays valid while a recorded moment is open. const footer = node.createChild("region:history"); focusable(footer); const pop = mutation === "leak-drawer-trap" ? undefined : focusPush(node); - drawers.push({ node, pop }); + drawers.push({ node, pop, historical }); } }; diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index bc848167b..aae876839 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -23,6 +23,8 @@ import { journalThrough } from "../repl-study/journal.ts"; import { paint } from "../repl-study/paint.ts"; import { applyAnsi, createGrid, gridText } from "../repl-study/screen.ts"; import { useReplTree } from "../repl-study/tree.ts"; +import { enterRoute } from "../repl-study/drive.ts"; +import { sendKey } from "../repl-study/keys.ts"; import { indexOf, project } from "../repl-study/view.ts"; import type { ReplView } from "../repl-study/view.ts"; @@ -231,3 +233,75 @@ describe("the component catalog", () => { } }); }); + +describe("a recorded drawer keeps its presentation and loses its actionability", () => { + const LIVE = "xmd://repl/e1/transcript/entry-1/document/+project"; + const RECORDED = "xmd://repl/e1/transcript/entry-1/document/+project?at=cp-04&inspect"; + + function* open(url: string, head: string) { + const state = hydrate(url, journalThrough(head)); + const tree = yield* useReplTree(state); + yield* enterRoute(tree, state); + return { state, tree }; + } + + it("exposes a live drawer's controls in tree order", function* () { + const { tree } = yield* open(LIVE, "cp-14"); + expect(tree.chain().map((node) => node.name)).toEqual([ + "field:drawer.project.name", + "field:drawer.project.description", + "control:drawer.project.schema", + "control:drawer.project.submit", + "region:history", + ]); + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.target).toBe("field:drawer.project.name"); + }); + + it("renders the recorded controls while none of them is focusable", function* () { + // Cold: the URL and the journal alone, with nothing carried over. + const { tree } = yield* open(RECORDED, "cp-18"); + const frame = yield* renderCatalog( + CATALOG.find((one) => one.id === "drawer-historical")!, + "wide", + ); + // The complete recorded presentation is still drawn… + for (const control of ["Project name", "Description", "Schema disclosure", "Submit"]) { + expect(frame.text).toContain(control); + } + expect(frame.text).toContain("recorded · read-only"); + // …and none of it is in the ring, so none of it can be focused. + const chain = tree.chain().map((node) => node.name); + expect(chain).toEqual(["region:history"]); + for (const control of [ + "field:drawer.project.name", + "field:drawer.project.description", + "control:drawer.project.schema", + "control:drawer.project.submit", + ]) { + expect({ control, focusable: chain.includes(control) }).toEqual({ + control, + focusable: false, + }); + } + }); + + it("keeps only the navigation that stays valid while inspecting", function* () { + const { tree } = yield* open(RECORDED, "cp-18"); + expect(tree.focused().name).toBe("region:history"); + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + // A key reaches the history path and nothing recorded. + expect(delivery.target).toBe("region:history"); + expect(delivery.path).not.toContain("panel:project.body"); + }); + + it("removes actionability when a mounted drawer becomes recorded", function* () { + // `focusable()` is one-way, so reconciliation has to rebuild the branch + // rather than quietly leave a focusable node describing a recorded one. + const { tree } = yield* open(LIVE, "cp-14"); + expect(tree.chain().length).toBe(5); + yield* tree.sync(hydrate(RECORDED, journalThrough("cp-18"))); + expect(tree.chain().map((node) => node.name)).toEqual(["region:history"]); + expect(tree.chain()).toContain(tree.focused()); + }); +}); From 51ca33d7f298243c8596f3bf53d1aaa1a43b92b9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 12:51:26 -0400 Subject: [PATCH 13/57] =?UTF-8?q?=F0=9F=93=90=20Ask=20the=20band's=20geome?= =?UTF-8?q?try=20of=20the=20view=20model=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the renderer migration. `bandGeometry`, `notchLayout` and `columnFor` took a `Fixture`; they take `HistoryView` now, because the band's arithmetic is about what is *shown* — which transport controls are visible, how wide their labels are, which markers share a column — and none of that is a question about storage. `historyViewFrom()` is the one projection of a fixture's recorded history, used both by `project()` for the component tree and by the rectangle path that still draws #838's frames. Two projections of the same thing would be two answers while both paths exist. A `Notch` now gathers `markers` rather than `checkpoints`, and the band reads its selection from the marker the view says is selected rather than from an index into a fixture's array. #838's geometry assertions are retargeted, not replaced: a `bandOf()` helper hands each one the view model and every expectation is unchanged. All 39 tests across the three suites pass and no golden moved. The old composition path is still there. Removing it is the rest of this slice. --- scripts/repl-study/render.ts | 111 ++++++++++++++++++------------- scripts/repl-study/view.ts | 49 +++++++++++--- scripts/tests/repl-study.test.ts | 47 +++++++++---- 3 files changed, 139 insertions(+), 68 deletions(-) diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index f832ec22f..2fea6d5bb 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -15,7 +15,9 @@ import { close, grow, fixed, open, rgba, text } from "@bomb.sh/tty"; import type { Op } from "@bomb.sh/tty"; -import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow } from "./model.ts"; +import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow, TransportMode } from "./model.ts"; +import type { HistoryView, MarkerView } from "./view.ts"; +import { historyViewFrom } from "./view.ts"; import type { Layout, Rect } from "./layout.ts"; import { MINIMUM } from "./layout.ts"; import type { View } from "./store.ts"; @@ -787,8 +789,7 @@ export interface Transport { readonly controls: readonly string[]; } -function transportFor(fixture: Fixture, dense: boolean): Transport { - const mode = fixture.history.transport; +export function transportFor(mode: TransportMode, dense: boolean): Transport { if (mode === "live") { return { word: "LIVE", color: C.active, controls: ["Pause"] }; } @@ -816,7 +817,8 @@ function transportFor(fixture: Fixture, dense: boolean): Transport { /** What one column of the band is carrying. */ export interface Notch { readonly column: number; - readonly checkpoints: readonly Checkpoint[]; + /** The recorded markers sharing this column, which the band gathers. */ + readonly markers: readonly MarkerView[]; } /** @@ -828,32 +830,32 @@ export interface Notch { * and the scrubber still steps through every checkpoint behind it. */ export function notchLayout( - history: Fixture["history"], + history: HistoryView, trackLeft: number, trackWidth: number, mutation?: Mutation, ): Notch[] { if (mutation === "clip-long-transcript") { - return history.checkpoints.map((checkpoint) => ({ - column: columnFor(checkpoint.at, history, trackLeft, trackWidth), - checkpoints: [checkpoint], + return history.markers.map((marker) => ({ + column: columnFor(marker.at, history, trackLeft, trackWidth), + markers: [marker], })); } - const columns = new Map(); - for (const checkpoint of history.checkpoints) { - const column = columnFor(checkpoint.at, history, trackLeft, trackWidth); + const columns = new Map(); + for (const marker of history.markers) { + const column = columnFor(marker.at, history, trackLeft, trackWidth); const bucket = columns.get(column) ?? []; - bucket.push(checkpoint); + bucket.push(marker); columns.set(column, bucket); } return [...columns.entries()] - .map(([column, checkpoints]) => ({ column, checkpoints })) + .map(([column, markers]) => ({ column, markers })) .toSorted((one, other) => one.column - other.column); } export function columnFor( at: number, - history: Fixture["history"], + history: Pick, trackLeft: number, trackWidth: number, ): number { @@ -881,8 +883,15 @@ export interface BandGeometry { * three controls — leaves a much shorter track than running does. The head's * label sits just right of the head, so it is reserved too. */ -export function bandGeometry(fixture: Fixture, layout: Layout, rect: Rect): BandGeometry { - const transport = transportFor(fixture, layout.dense || layout.profile === "narrow"); +/** + * How much of the band the track gets, asked of the semantic view. + * + * The band's arithmetic is about what is *shown* — which transport controls are + * visible, how wide their labels are — so it takes the view model rather than a + * fixture. Nothing about a checkpoint's storage reaches it. + */ +export function bandGeometry(history: HistoryView, layout: Layout, rect: Rect): BandGeometry { + const transport = transportFor(history.transport, layout.dense || layout.profile === "narrow"); const controls = transport.controls.map((control) => `[ ${control} ]`).join(" "); const right = `${transport.word} ${controls}`; const inner = Math.max(0, rect.width - 2); @@ -890,7 +899,7 @@ export function bandGeometry(fixture: Fixture, layout: Layout, rect: Rect): Band // surface bar above it already carries. const labelWidth = layout.profile === "narrow" ? 10 : Math.min(Math.max(18, Math.round(rect.width * 0.12)), 26); - const headLabelRoom = fixture.history.transport === "live" ? 8 : 15; + const headLabelRoom = history.transport === "live" ? 8 : 15; const rightReserve = Math.min(inner - labelWidth - 4, [...right].length + 2 + headLabelRoom); return { transport, @@ -937,21 +946,19 @@ export function isDeeperThanBand(depth: number): boolean { * it, an entry boundary is `◆` where an ordinary event is `●`, and a column * holding several checkpoints shows how many. */ -function footerRegion( - fixture: Fixture, - view: View, +export function bandRegion( + history: HistoryView, layout: Layout, rect: Rect, mutation?: Mutation, motion?: Motion, focus?: FocusView, ): Op[] { - const history = fixture.history; // While a playback runs, the head is where the application says it is; the // recorded head is where it will be when the motion settles. const headAt = motion !== undefined && !motion.done ? motion.headAt : history.headAt; const flat = mutation === "flatten-notches"; - const geometry = bandGeometry(fixture, layout, rect); + const geometry = bandGeometry(history, layout, rect); const { transport, inner, labelWidth, trackLeft, trackWidth } = geometry; // The marker replaces the space inside the bracket rather than widening it: // the track's room is computed from this string, and a focused control that @@ -975,8 +982,8 @@ function footerRegion( }); }; - const hasHistory = history.checkpoints.length > 0; - const selectedCheckpoint = history.checkpoints[view.checkpoint]; + const hasHistory = history.markers.length > 0; + const selectedCheckpoint = history.markers.find((marker) => marker.selected); // Text goes down before the markers do, so a notch or a caret always wins the // column it belongs in rather than being written over by a label. @@ -995,7 +1002,7 @@ function footerRegion( ), C.dim, ); - putText(2, 0, fit(fixture.entry ? fixture.entry.id : "", labelWidth - 1), C.dim); + putText(2, 0, fit(history.entryId ?? "", labelWidth - 1), C.dim); putText(0, Math.max(0, inner - [...right].length), right, transport.color); if (hasHistory) { @@ -1030,22 +1037,20 @@ function footerRegion( const notches = notchLayout(history, trackLeft, trackWidth, mutation); for (const notch of notches) { - const boundary = notch.checkpoints.some((checkpoint) => checkpoint.kind === "entry"); - const deepest = Math.max(...notch.checkpoints.map((checkpoint) => checkpoint.depth)); + const boundary = notch.markers.some((marker) => marker.boundary); + const deepest = Math.max(...notch.markers.map((marker) => marker.depth)); const later = - selected !== undefined && - notch.checkpoints.every((checkpoint) => checkpoint.at > selected.at); + selected !== undefined && notch.markers.every((marker) => marker.at > selected.at); const color = later ? C.dim : boundary ? C.out : C.active; - const coalesced = notch.checkpoints.length > 1; + const coalesced = notch.markers.length > 1; // The shallowest scope in the column owns the notch's height, so a // coalesced column never hides the outermost thing that happened there. - const shallowest = Math.min(...notch.checkpoints.map((checkpoint) => checkpoint.depth)); + const shallowest = Math.min(...notch.markers.map((marker) => marker.depth)); const chosen = - selected !== undefined && - notch.checkpoints.some((checkpoint) => checkpoint.at === selected.at); + selected !== undefined && notch.markers.some((marker) => marker.at === selected.at); const glyph = coalesced - ? notch.checkpoints.length < 10 - ? String(notch.checkpoints.length) + ? notch.markers.length < 10 + ? String(notch.markers.length) : "+" : boundary ? "◆" @@ -1098,23 +1103,23 @@ function footerRegion( if (rect.height > 6) { lines.push(blank(), label("CHECKPOINTS")); const room = rect.height - lines.length; - const listed = history.checkpoints.slice(0, Math.max(0, room - 1)); - listed.forEach((point, index) => { - const on = index === view.checkpoint; + const listed = history.markers.slice(0, Math.max(0, room - 1)); + for (const marker of listed) { + const on = marker.selected; lines.push({ segments: [ - { text: clock(point.at), color: on ? C.gold : C.dim, width: 6 }, + { text: clock(marker.at), color: on ? C.gold : C.dim, width: 6 }, { - text: point.kind === "entry" ? "◆" : isDeeperThanBand(point.depth) ? "·" : "●", + text: marker.boundary ? "◆" : isDeeperThanBand(marker.depth) ? "·" : "●", color: on ? C.gold : C.active, width: 2, }, - { text: point.label, color: on ? C.out : C.src }, - { text: point.scope, color: C.dim, width: Math.min(28, Math.max(0, rect.width - 40)) }, + { text: marker.label, color: on ? C.out : C.src }, + { text: marker.scope, color: C.dim, width: Math.min(28, Math.max(0, rect.width - 40)) }, ], }); - }); - const hidden = history.checkpoints.length - listed.length; + } + const hidden = history.markers.length - listed.length; if (hidden > 0) { lines.push(plain(`▸ ${hidden} more checkpoints · ←/→ moves through every one`, C.dim)); } @@ -1257,7 +1262,23 @@ export function renderScreen(request: ScreenRequest): Op[] { ops.push(...contextualRegion(fixture, view, layout, layout.contextual, focus)); } if (layout.footer) { - ops.push(...footerRegion(fixture, view, layout, layout.footer, mutation, motion, focus)); + // The rectangle path draws the band from the same projection the component + // tree does, so the two cannot disagree while both exist. + ops.push( + ...bandRegion( + historyViewFrom( + fixture, + fixture.history.transport, + [], + fixture.history.checkpoints[view.checkpoint]?.at, + ), + layout, + layout.footer, + mutation, + motion, + focus, + ), + ); } if (layout.contextual && covering) { // Drawn last, so it lands on top of the band the study says is never diff --git a/scripts/repl-study/view.ts b/scripts/repl-study/view.ts index 599fcf713..e464a1902 100644 --- a/scripts/repl-study/view.ts +++ b/scripts/repl-study/view.ts @@ -127,6 +127,8 @@ export interface ContextualView { } export interface HistoryView { + /** The entry the band is recording, for the line under its label. */ + readonly entryId?: string; readonly elapsed: string; readonly headAt: number; readonly selectedAt?: number; @@ -169,6 +171,43 @@ export function indexOf(view: ReplView): ViewIndex { }; } +/** + * The band's own view of a fixture's recorded history. + * + * Exported because the composition path that still draws from rectangles needs + * exactly this projection, and two projections of the same thing would be two + * answers. It goes when that path does. + */ +export function historyViewFrom( + subject: ReturnType, + transport: Moment["transport"], + controls: readonly ControlView[], + /** Which recorded second is selected, when it is not the fixture's own. */ + selectedAt?: number, +): HistoryView { + const selected = selectedAt ?? subject.history.selectedAt; + return { + entryId: subject.entry?.id, + elapsed: subject.history.elapsed, + headAt: subject.history.headAt, + selectedAt: selected, + transport, + controls, + markers: subject.history.checkpoints.map((point) => ({ + id: `cp-${point.at}`, + at: point.at, + label: point.label, + scope: point.scope, + depth: point.depth, + boundary: point.kind === "entry", + selected: selected !== undefined && point.at === selected, + later: selected !== undefined && point.at > selected, + records: point.records, + })), + compressed: subject.history.compressed, + }; +} + /** Which recorded markers the sidebar and the band show, and how. */ function markersOf(state: ReplState): MarkerView[] { const subject = fixtureFor(state); @@ -340,15 +379,7 @@ export function project(state: ReplState): ReplView { run: runnable ? { id: "control:input.run", label: "Run", enabled: true } : undefined, }, }, - history: { - elapsed: subject.history.elapsed, - headAt: subject.history.headAt, - selectedAt: subject.history.selectedAt, - transport: state.moment.transport, - controls: controlsOf(state), - markers, - compressed: subject.history.compressed, - }, + history: historyViewFrom(subject, state.moment.transport, controlsOf(state)), }; } diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index 1ed3355c1..641573e74 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -73,6 +73,19 @@ import { viewport, } from "../repl-study/screen.ts"; import { initialView, scrollBy } from "../repl-study/store.ts"; +import { historyViewFrom } from "../repl-study/view.ts"; +import type { Fixture } from "../repl-study/model.ts"; + +/** + * A fixture's band, as the semantic view model the geometry now takes. + * + * The band's arithmetic moved onto the view when the renderer moved onto the + * component tree. The assertions below are unchanged; only what they are asked + * of is. + */ +function bandOf(subject: Fixture) { + return historyViewFrom(subject, subject.history.transport, []); +} const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const GOLDENS = fileURLToPath(new URL("./fixtures/repl-study/", import.meta.url)); @@ -118,11 +131,11 @@ function soleNotchColumns( geometry: { readonly trackLeft: number; readonly trackWidth: number }, ): Map { const byDepth = new Map(); - for (const notch of notchLayout(subject.history, geometry.trackLeft, geometry.trackWidth)) { - if (notch.checkpoints.length !== 1) { + for (const notch of notchLayout(bandOf(subject), geometry.trackLeft, geometry.trackWidth)) { + if (notch.markers.length !== 1) { continue; } - const [point] = notch.checkpoints; + const [point] = notch.markers; if (!byDepth.has(point.depth)) { byDepth.set(point.depth, notch.column); } @@ -378,7 +391,7 @@ describe("the history footer", () => { drawer: false, surface: "transcript", }); - const geometry = bandGeometry(subject, layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); const rows = bandRows(frame.text, size); const byDepth = soleNotchColumns(subject, geometry); @@ -409,12 +422,13 @@ describe("the history footer", () => { drawer: false, surface: "transcript", }); - const geometry = bandGeometry(subject, layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); const byDepth = soleNotchColumns(subject, geometry); const deepColumn = byDepth.get(3) ?? byDepth.get(2)!; const deepPoint = history.checkpoints.find( (point) => - columnFor(point.at, history, geometry.trackLeft, geometry.trackWidth) === deepColumn, + columnFor(point.at, bandOf(subject), geometry.trackLeft, geometry.trackWidth) === + deepColumn, )!; const unselected = yield* renderFrame({ @@ -438,7 +452,12 @@ describe("the history footer", () => { // An entry boundary is a glyph, and the playhead is its own stem and label. const boundary = history.checkpoints.find((point) => point.kind === "entry")!; - const boundaryColumn = columnFor(boundary.at, history, geometry.trackLeft, geometry.trackWidth); + const boundaryColumn = columnFor( + boundary.at, + bandOf(subject), + geometry.trackLeft, + geometry.trackWidth, + ); expect(glyphAt(bandRows(unselected.text, size)[TRACK_ROW], 1 + boundaryColumn)).toBe("◆"); expect(bandRows(unselected.text, size)[0]).toContain("PAUSED HEAD"); }); @@ -452,7 +471,7 @@ describe("the history footer", () => { drawer: false, surface: "transcript", }); - const geometry = bandGeometry(subject, layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), @@ -638,11 +657,11 @@ describe("staying operable", () => { surface: "history", }); const rect = layout.footer!; - const geometry = bandGeometry(subject, layout, rect); - const notches = notchLayout(subject.history, geometry.trackLeft, geometry.trackWidth); - const gathered = notches.reduce((total, notch) => total + notch.checkpoints.length, 0); + const geometry = bandGeometry(bandOf(subject), layout, rect); + const notches = notchLayout(bandOf(subject), geometry.trackLeft, geometry.trackWidth); + const gathered = notches.reduce((total, notch) => total + notch.markers.length, 0); - expect(notches.some((notch) => notch.checkpoints.length > 1)).toBe(true); + expect(notches.some((notch) => notch.markers.length > 1)).toBe(true); expect(gathered).toBe(subject.history.checkpoints.length); for (let index = 0; index < subject.history.checkpoints.length; index += 1) { @@ -668,9 +687,9 @@ describe("staying operable", () => { drawer: false, surface: "history", }); - const geometry = bandGeometry(subject, layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); const notches = notchLayout( - subject.history, + bandOf(subject), geometry.trackLeft, geometry.trackWidth, "clip-long-transcript", From b4876cd1e4df10f2de036afde2f514c5d253f19a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 12:54:20 -0400 Subject: [PATCH 14/57] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20Give=20the=20cata?= =?UTF-8?q?log=20the=20real=20Execution=20History=20band=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of the renderer migration. The band is a component now: it takes its own `HistoryView` and the box its parent gives it, and the drawing is `render.ts`'s unchanged — notch height is scope depth, the playhead is its own stem, a selection is gold with a caret under it. Components receive a `Placement` carrying the profile as well as the rectangle and the density, so a component is told the presentation constraints it renders within rather than looking up the whole layout for itself. `bandGeometry` takes that placement; #838's call sites are retargeted and their assertions unchanged. Two nodes legitimately named `region:history` surfaced the same duplicate-id class of bug once more: the band is addressed by the node's own id, and only the pane draws it — the identically-named node inside a drawer is that trap's way out, not a second Execution History. **Twelve of the twenty-two catalog captures changed, for one intentional reason**: the catalog's band was a simplified list of markers and now renders the real band — the track, the playhead, and notches whose height is scope depth. That is the migration doing what it is for. The ten states with no recorded history are byte-identical, and no #838 or #839 golden moved. The rectangle path still draws #838's frames. Removing it is the rest of this slice. --- scripts/repl-study/component.ts | 13 +++- scripts/repl-study/components.ts | 65 +++++++------------ scripts/repl-study/paint.ts | 15 +++-- scripts/repl-study/render.ts | 25 ++++--- .../repl-catalog/bindings-document.wide.txt | 9 +-- .../repl-catalog/bindings-plan.wide.txt | 9 +-- .../repl-catalog/drawer-confirm.wide.txt | 9 +-- .../repl-catalog/drawer-historical.wide.txt | 9 +-- .../repl-catalog/drawer-project.wide.txt | 9 +-- .../repl-catalog/drawer-review.wide.txt | 9 +-- .../fixtures/repl-catalog/empty.wide.txt | 4 +- .../repl-catalog/inspecting.narrow.txt | 11 +++- .../fixtures/repl-catalog/inspecting.wide.txt | 9 +-- .../fixtures/repl-catalog/nested.wide.txt | 9 +-- .../fixtures/repl-catalog/sessions.wide.txt | 9 +-- .../fixtures/repl-catalog/settled.wide.txt | 9 +-- scripts/tests/repl-components.test.ts | 4 +- scripts/tests/repl-study.test.ts | 11 ++-- 18 files changed, 129 insertions(+), 109 deletions(-) diff --git a/scripts/repl-study/component.ts b/scripts/repl-study/component.ts index 12fb6c291..0dc8b07e1 100644 --- a/scripts/repl-study/component.ts +++ b/scripts/repl-study/component.ts @@ -28,7 +28,7 @@ import { createNodeData } from "./vendor/freedom/upstream/index.ts"; import type { Node } from "./vendor/freedom/upstream/index.ts"; import type { Op } from "@bomb.sh/tty"; -import type { Rect } from "./layout.ts"; +import type { Layout, Profile, Rect } from "./layout.ts"; /** * What a parent tells a child about where it may draw. @@ -42,6 +42,17 @@ export interface Placement { readonly rect: Rect; /** True where the pane is at its floor and secondary detail is dropped. */ readonly dense: boolean; + /** Which composition this is, which changes what a component may spend room on. */ + readonly profile: Profile; +} + +/** The presentation constraints one region of a composed layout is given. */ +export function placementOf(layout: Layout, rect: Rect | undefined): Placement { + return { + rect: rect ?? { x: 0, y: 0, width: 0, height: 0 }, + dense: layout.dense, + profile: layout.profile, + }; } /** diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index 05c92ca0e..cd390aa60 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -17,6 +17,7 @@ import type { Op } from "@bomb.sh/tty"; import type { Body } from "./component.ts"; import { + bandRegion, BG, blank, C, @@ -28,7 +29,9 @@ import { transcriptLines, wrapText, } from "./render.ts"; -import type { VisualLine } from "./render.ts"; +import type { FocusView, VisualLine } from "./render.ts"; +import type { Mutation } from "./mutations.ts"; +import type { Motion } from "./playback.ts"; import type { BindingsView, DrawerView, @@ -250,48 +253,24 @@ export const inputBody: Body = ({ self, data, placement return region(self.id, placement.rect, lines, { bg: BG.input, children }); }; -export const historyBody: Body = ({ self, data, placement, children }) => { - const rect = placement.rect; - const inner = Math.max(0, rect.width - 2); - const compact = placement.dense; - const controls = data.controls.map((control) => `[ ${control.label} ]`).join(" "); - const word = - data.transport === "live" - ? "LIVE" - : data.transport === "paused" - ? "PAUSED" - : data.transport === "inspecting" - ? "INSPECTING" - : "IDLE"; - const lines: VisualLine[] = [ - { - segments: [ - { text: fit(compact ? "HISTORY" : "EXECUTION HISTORY", 20), color: C.label }, - { text: `${word} ${controls}`, color: C.dim }, - ], - }, - { - segments: [ - { - text: - data.markers.length === 0 ? "No recorded execution yet" : `recorded · ${data.elapsed}`, - color: C.dim, - }, - ], - }, - ]; - for (const marker of data.markers.slice(0, Math.max(0, rect.height - 3))) { - lines.push({ - segments: [ - { text: clock(marker.at), color: marker.selected ? C.gold : C.dim, width: 6 }, - { text: marker.boundary ? "◆" : "●", color: marker.selected ? C.gold : C.active, width: 2 }, - { text: marker.label, color: marker.selected ? C.out : C.src }, - { text: marker.scope, color: C.dim, width: Math.min(28, Math.max(0, inner - 40)) }, - ], - }); - } - return region(self.id, rect, lines, { bg: BG.footer, children }); -}; +/** + * The Execution History band. + * + * The drawing is `render.ts`'s — notch height is scope depth, the playhead is + * its own stem, a selection is gold with a caret — and what changed is who asks + * for it: the band is a component handed its own view and its own box. + */ +export const historyBody: Body = ({ self, data, placement, children }) => [ + ...bandRegion(self.id, data.view, placement, data.mutation, data.motion, data.focus), + ...children, +]; + +export interface HistoryData { + readonly view: HistoryView; + readonly mutation?: Mutation; + readonly motion?: Motion; + readonly focus?: FocusView; +} /** * One suspension's drawer. diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts index 8f4150db3..0414a0782 100644 --- a/scripts/repl-study/paint.ts +++ b/scripts/repl-study/paint.ts @@ -15,7 +15,7 @@ import type { Op } from "@bomb.sh/tty"; -import { attach, walk } from "./component.ts"; +import { attach, placementOf, walk } from "./component.ts"; import type { Placement } from "./component.ts"; import { bindingsBody, @@ -36,7 +36,7 @@ import type { ReplView } from "./view.ts"; const NOWHERE: Rect = { x: 0, y: 0, width: 0, height: 0 }; function placed(rect: Rect | undefined, layout: Layout): Placement { - return { rect: rect ?? NOWHERE, dense: layout.dense }; + return placementOf(layout, rect ?? NOWHERE); } /** @@ -88,8 +88,13 @@ function dress(node: Node, view: ReplView, layout: Layout, anchor: number): void } } if (name === "region:history") { - attach(node, historyBody, view.history, placed(layout.footer, layout)); - return; + // Only the pane draws the band. The identically-named node inside a drawer + // is that trap's way out, not a second Execution History. + const pane = node.parent?.parent === undefined; + if (pane) { + attach(node, historyBody, { view: view.history }, placed(layout.footer, layout)); + return; + } } if (name === "header") { attach( @@ -116,7 +121,7 @@ export interface PaintRequest { /** Hand every mounted node its data, then render the tree. */ export function paint(request: PaintRequest): Op[] { const { root, view, layout, anchor } = request; - attach(root, rootBody, undefined, { rect: layout.screen, dense: layout.dense }); + attach(root, rootBody, undefined, placementOf(layout, layout.screen)); const visit = (node: Node): void => { for (const child of node.children) { dress(child, view, layout, anchor); diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 2fea6d5bb..4d4f8352f 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -19,6 +19,8 @@ import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow, TransportMode } import type { HistoryView, MarkerView } from "./view.ts"; import { historyViewFrom } from "./view.ts"; import type { Layout, Rect } from "./layout.ts"; +import type { Placement } from "./component.ts"; +import { placementOf } from "./component.ts"; import { MINIMUM } from "./layout.ts"; import type { View } from "./store.ts"; import type { Mutation } from "./mutations.ts"; @@ -890,15 +892,19 @@ export interface BandGeometry { * visible, how wide their labels are — so it takes the view model rather than a * fixture. Nothing about a checkpoint's storage reaches it. */ -export function bandGeometry(history: HistoryView, layout: Layout, rect: Rect): BandGeometry { - const transport = transportFor(history.transport, layout.dense || layout.profile === "narrow"); +export function bandGeometry(history: HistoryView, placement: Placement): BandGeometry { + const rect = placement.rect; + const transport = transportFor( + history.transport, + placement.dense || placement.profile === "narrow", + ); const controls = transport.controls.map((control) => `[ ${control} ]`).join(" "); const right = `${transport.word} ${controls}`; const inner = Math.max(0, rect.width - 2); // A narrow band spends its columns on the track instead of on a label the // surface bar above it already carries. const labelWidth = - layout.profile === "narrow" ? 10 : Math.min(Math.max(18, Math.round(rect.width * 0.12)), 26); + placement.profile === "narrow" ? 10 : Math.min(Math.max(18, Math.round(rect.width * 0.12)), 26); const headLabelRoom = history.transport === "live" ? 8 : 15; const rightReserve = Math.min(inner - labelWidth - 4, [...right].length + 2 + headLabelRoom); return { @@ -947,18 +953,19 @@ export function isDeeperThanBand(depth: number): boolean { * holding several checkpoints shows how many. */ export function bandRegion( + id: string, history: HistoryView, - layout: Layout, - rect: Rect, + placement: Placement, mutation?: Mutation, motion?: Motion, focus?: FocusView, ): Op[] { + const rect = placement.rect; // While a playback runs, the head is where the application says it is; the // recorded head is where it will be when the motion settles. const headAt = motion !== undefined && !motion.done ? motion.headAt : history.headAt; const flat = mutation === "flatten-notches"; - const geometry = bandGeometry(history, layout, rect); + const geometry = bandGeometry(history, placement); const { transport, inner, labelWidth, trackLeft, trackWidth } = geometry; // The marker replaces the space inside the bracket rather than widening it: // the track's room is computed from this string, and a focused control that @@ -1125,7 +1132,7 @@ export function bandRegion( } } - return region("footer", rect, lines, { bg: BG.footer, padding: { left: 1, right: 1 } }); + return region(id, rect, lines, { bg: BG.footer, padding: { left: 1, right: 1 } }); } /** `[ Continue ]` becomes `[▸Continue ]` — the same width, one glyph louder. */ @@ -1266,14 +1273,14 @@ export function renderScreen(request: ScreenRequest): Op[] { // tree does, so the two cannot disagree while both exist. ops.push( ...bandRegion( + "footer", historyViewFrom( fixture, fixture.history.transport, [], fixture.history.checkpoints[view.checkpoint]?.at, ), - layout, - layout.footer, + placementOf(layout, layout.footer), mutation, motion, focus, diff --git a/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt index 73662bad9..622fc1cdb 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt @@ -44,7 +44,8 @@ bindings-document.wide · 200 × 50 · Bindings at the document scope - EXECUTION HISTORY LIVE [ Pause ] - recorded · 00:49 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt index 588948261..815b6ce25 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt @@ -44,7 +44,8 @@ bindings-plan.wide · 200 × 50 · Bindings at the Plan scope - EXECUTION HISTORY LIVE [ Pause ] - recorded · 00:31 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────────◆─────────────●────────────────────────────────●────────────────────────────●───────────────────────────────────────────────────●────·────┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt index fe826f71d..f72934505 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt @@ -44,7 +44,8 @@ drawer-confirm.wide · 200 × 50 · The README confirmation drawer - EXECUTION HISTORY LIVE [ Pause ] - recorded · 00:49 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt index b49ac14dd..d6a849992 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt @@ -44,7 +44,8 @@ drawer-historical.wide · 200 × 50 · A recorded drawer, rendered without anyth recorded · read-only - EXECUTION HISTORY INSPECTING [ Continue ] [ Return to paused head ] [ Fork from here ] - recorded · 00:53 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt index c1635099e..7b4a8430b 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt @@ -44,7 +44,8 @@ drawer-project.wide · 200 × 50 · The project Elicit drawer - EXECUTION HISTORY LIVE [ Pause ] - recorded · 00:49 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt index 4ddf30dc0..1be01b321 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt @@ -44,7 +44,8 @@ drawer-review.wide · 200 × 50 · The plan-review Elicit drawer Plan review · scroll region Approve Request changes - EXECUTION HISTORY LIVE [ Pause ] - recorded · 00:31 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────────◆─────────────●────────────────────────────────●────────────────────────────●───────────────────────────────────────────────────●────·────┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-catalog/empty.wide.txt b/scripts/tests/fixtures/repl-catalog/empty.wide.txt index 4ff081091..3c237d93c 100644 --- a/scripts/tests/fixtures/repl-catalog/empty.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/empty.wide.txt @@ -44,5 +44,5 @@ empty.wide · 200 × 50 · An empty REPL, before anything has run Enter XMD or invoke a document… - EXECUTION HISTORY IDLE - No recorded execution yet + EXECUTION HISTORY IDLE [ Pause ] + No recorded execution … diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt index 9f912e222..cb8db4a34 100644 --- a/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt @@ -1,13 +1,18 @@ inspecting.narrow · 90 × 28 · Historical inspection, reconstructed and read-only - HISTORY INSPECTING [ Continue ] [ Return to paused… - recorded · 00:53 + HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] + 00:53 ││ ││┃ 00:53 + Entry 1 ││ │ ││┃ + ─◆●─●●───2─≈●─●●┃ + ▲ 00:12 · snapped · 41.0s before head + + CHECKPOINTS 00:02 ◆ Entry 1 submitted REPL 00:05 ● document scope entered Entry 1 › document 00:12 ● Plan entered Entry 1 › document › Plan 00:18 ● planning inputs prepared … › Plan › PlanInputs 00:29 ● planning Agent response admitted … › Plan › Prompt - 00:30 ● draft checked … › Plan › Check + 00:30 · draft checked … › Plan › Check 00:41 ● review returned Approve … › Plan › Elicit 00:47 ● Plan replaced by returned program Entry 1 › document 00:49 ● project Elicit requested Entry 1 › document diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt index f3e907c2f..e30217303 100644 --- a/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt @@ -44,7 +44,8 @@ inspecting.wide · 200 × 50 · Historical inspection, reconstructed and read-on - EXECUTION HISTORY INSPECTING [ Continue ] [ Return to paused head ] [ Fork from here ] - recorded · 00:53 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] + recorded · 00:53 │ │ │ │ │┃ 00:53 + Entry 1 │ │ │ │ │ │┃ + ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ + ▲ 00:12 · snapped · 41.0s before head diff --git a/scripts/tests/fixtures/repl-catalog/nested.wide.txt b/scripts/tests/fixtures/repl-catalog/nested.wide.txt index abe9eeafe..f954baa7b 100644 --- a/scripts/tests/fixtures/repl-catalog/nested.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/nested.wide.txt @@ -44,7 +44,8 @@ nested.wide · 200 × 50 · Nested execution, with the Plan scope open - EXECUTION HISTORY LIVE [ Pause ] - recorded · 00:31 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:31 │ │ ┃ 00:31 + Entry 1 │ │ │ ┃ + ──────────◆─────────────●────────────────────────────────●────────────────────────────●───────────────────────────────────────────────────●────·────┃ + notch height is scope depth · digits mark coalesced checkpoints diff --git a/scripts/tests/fixtures/repl-catalog/sessions.wide.txt b/scripts/tests/fixtures/repl-catalog/sessions.wide.txt index 99e1b923a..1457290fe 100644 --- a/scripts/tests/fixtures/repl-catalog/sessions.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/sessions.wide.txt @@ -44,7 +44,8 @@ sessions.wide · 200 × 50 · Three concurrent Agent sessions - EXECUTION HISTORY LIVE [ Pause ] - recorded · 00:49 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] + recorded · 00:49 │ │ │ ┃ 00:49 + Entry 1 │ │ │ │ ┃ + ──────◆────────●────────────────────●─────────────────●─────────────────────────────────●──·──────────────≈─────────────────●─────────────────●─────┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/fixtures/repl-catalog/settled.wide.txt b/scripts/tests/fixtures/repl-catalog/settled.wide.txt index ccec401e2..67368d9bc 100644 --- a/scripts/tests/fixtures/repl-catalog/settled.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/settled.wide.txt @@ -44,7 +44,8 @@ settled.wide · 200 × 50 · A settled entry, the input ready for the next one - EXECUTION HISTORY IDLE - recorded · 01:01 - 00:02 ◆ Entry 1 submitted REPL - 00:05 ● document scope entered Entry 1 › document + EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] + recorded · 01:01 │ │ │ │ │ │ │ │ ┃ 01:01 + Entry 1 │ │ │ │ │ │ │ │ │ ┃ + ─────◆──────●───────────────●─────────────●────────────────────────●─·───────────≈─────────────●─────────────●───●──────●──●────────●──────●─┃ + 4.9s agent wait · compressed diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index aae876839..d33a95c8f 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -104,7 +104,7 @@ describe("a component is a body on a node", () => { return []; }, undefined, - { rect: { x: 0, y: 0, width: 1, height: 1 }, dense: false }, + { rect: { x: 0, y: 0, width: 1, height: 1 }, dense: false, profile: "wide" }, ); walk(node); // A body that held the node could create children, remove itself, set props @@ -141,6 +141,7 @@ describe("a component is a body on a node", () => { attach(child, ({ self }) => [{ kind: "text", value: self.name } as never], undefined, { rect: { x: 0, y: 0, width: 1, height: 1 }, dense: false, + profile: "wide", }); attach( parent, @@ -149,6 +150,7 @@ describe("a component is a body on a node", () => { { rect: { x: 0, y: 0, width: 1, height: 1 }, dense: false, + profile: "wide", }, ); const ops = walk(parent); diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index 641573e74..223465a6f 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -74,6 +74,7 @@ import { } from "../repl-study/screen.ts"; import { initialView, scrollBy } from "../repl-study/store.ts"; import { historyViewFrom } from "../repl-study/view.ts"; +import { placementOf } from "../repl-study/component.ts"; import type { Fixture } from "../repl-study/model.ts"; /** @@ -391,7 +392,7 @@ describe("the history footer", () => { drawer: false, surface: "transcript", }); - const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), placementOf(layout, layout.footer!)); const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), size }); const rows = bandRows(frame.text, size); const byDepth = soleNotchColumns(subject, geometry); @@ -422,7 +423,7 @@ describe("the history footer", () => { drawer: false, surface: "transcript", }); - const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), placementOf(layout, layout.footer!)); const byDepth = soleNotchColumns(subject, geometry); const deepColumn = byDepth.get(3) ?? byDepth.get(2)!; const deepPoint = history.checkpoints.find( @@ -471,7 +472,7 @@ describe("the history footer", () => { drawer: false, surface: "transcript", }); - const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), placementOf(layout, layout.footer!)); const frame = yield* renderFrame({ fixture: subject, view: initialView(subject), @@ -657,7 +658,7 @@ describe("staying operable", () => { surface: "history", }); const rect = layout.footer!; - const geometry = bandGeometry(bandOf(subject), layout, rect); + const geometry = bandGeometry(bandOf(subject), placementOf(layout, rect)); const notches = notchLayout(bandOf(subject), geometry.trackLeft, geometry.trackWidth); const gathered = notches.reduce((total, notch) => total + notch.markers.length, 0); @@ -687,7 +688,7 @@ describe("staying operable", () => { drawer: false, surface: "history", }); - const geometry = bandGeometry(bandOf(subject), layout, layout.footer!); + const geometry = bandGeometry(bandOf(subject), placementOf(layout, layout.footer!)); const notches = notchLayout( bandOf(subject), geometry.trackLeft, From 53159a4ee06872b7c136ca46cc79e05dcaacdfc7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 13:00:02 -0400 Subject: [PATCH 15/57] =?UTF-8?q?=F0=9F=97=84=EF=B8=8F=20Draw=20the=20draw?= =?UTF-8?q?er=20from=20its=20view,=20in=20both=20paths=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third step of the renderer migration. `contextualRegion` is split into `drawerRegion` and `inputRegion`, each taking a view and the box its parent gives it. The component tree and the rectangle path both call them, so the two cannot disagree about the contextual band while both exist; the rectangle path keeps a thin shim that goes when it does. `DrawerView` carries the study's real content — the project form's labelled fields and schema, the review's plan and decisions, the confirmation's preview and actions — rather than a summary of it. **Eight catalog captures changed, for one intentional reason**: the drawer was rendering my simplified list of control labels and now renders the study's own form. `drawer-project.wide` gains the `┃` value gutter and the validation line it always had in #838's frames. A recorded drawer says so *before* the form rather than after it. Appended last, `recorded · read-only` fell outside the band's clip and never reached the screen — the notice that tells you nothing here is actionable has to survive the clip that the rest of the drawer is subject to. No #838 or #839 golden moved. 39 tests, 140 steps, lint and check clean. --- scripts/repl-study/components.ts | 66 +++------- scripts/repl-study/paint.ts | 2 +- scripts/repl-study/render.ts | 87 ++++++++++--- scripts/repl-study/view.ts | 115 +++++++++++------- .../repl-catalog/drawer-confirm.narrow.txt | 11 +- .../repl-catalog/drawer-confirm.wide.txt | 10 +- .../repl-catalog/drawer-historical.narrow.txt | 13 +- .../repl-catalog/drawer-historical.wide.txt | 14 +-- .../repl-catalog/drawer-project.narrow.txt | 12 +- .../repl-catalog/drawer-project.wide.txt | 14 +-- .../repl-catalog/drawer-review.narrow.txt | 12 +- .../repl-catalog/drawer-review.wide.txt | 8 +- scripts/tests/repl-components.test.ts | 12 +- 13 files changed, 213 insertions(+), 163 deletions(-) diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index cd390aa60..e320ca146 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -19,6 +19,8 @@ import type { Body } from "./component.ts"; import { bandRegion, BG, + drawerRegion, + inputRegion, blank, C, clock, @@ -234,24 +236,10 @@ export const bindingsBody: Body = ({ self, data, placement, childr return region(self.id, placement.rect, lines, { bg: BG.bind, children }); }; -export const inputBody: Body = ({ self, data, placement, children }) => { - const width = Math.max(0, placement.rect.width - 2); - const lines: VisualLine[] = [ - { - segments: [ - { text: data.label, color: C.label, width: Math.min(width, 18) }, - { text: data.hint, color: data.run === undefined ? C.hold : C.dim }, - { - text: data.run === undefined ? "[ Run ]" : "[ Run ⌘⏎ ]", - color: data.run === undefined ? C.dim : C.tick, - width: 12, - }, - ], - }, - plain(data.draft === "" ? data.placeholder : data.draft, C.settledText), - ]; - return region(self.id, placement.rect, lines, { bg: BG.input, children }); -}; +export const inputBody: Body = ({ self, data, placement, children }) => [ + ...inputRegion(self.id, data, placement), + ...children, +]; /** * The Execution History band. @@ -273,38 +261,20 @@ export interface HistoryData { } /** - * One suspension's drawer. + * One suspension's drawer, drawn by the shared region. * - * A recorded drawer renders the state it recorded and offers nothing to act on: - * `historical` disables every control, and the body says so rather than drawing - * affordances that would do nothing. + * A recorded drawer keeps its complete presentation and says so; nothing about + * it is actionable, and the tree does not offer its controls for focus. */ -export const drawerBody: Body = ({ self, data, placement, children }) => { - const width = Math.max(0, placement.rect.width - 2); - const lines: VisualLine[] = [plain(data.heading, C.hold)]; - for (const wrapped of wrapText(data.origin, width)) { - lines.push(plain(wrapped, C.dim)); - } - lines.push(blank()); - for (const line of data.lines) { - for (const wrapped of wrapText(line, width)) { - lines.push(plain(wrapped, C.src)); - } - } - lines.push(blank()); - for (const control of data.controls) { - lines.push({ - segments: [ - { text: control.enabled ? " " : "· ", color: C.dim, width: 2 }, - { text: control.label, color: control.enabled ? C.src : C.dim }, - ], - }); - } - if (data.historical) { - lines.push(blank(), plain("recorded · read-only", C.gold)); - } - return region(self.id, placement.rect, lines, { bg: BG.drawer, children }); -}; +export const drawerBody: Body = ({ self, data, placement, children }) => [ + ...drawerRegion(self.id, data.view, placement, data.focus), + ...children, +]; + +export interface DrawerData { + readonly view: DrawerView; + readonly focus?: FocusView; +} /** A structural outlet: it draws nothing and contributes its children unchanged. */ export const outletBody: Body = ({ children }) => [...children]; diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts index 0414a0782..2cc0fd05e 100644 --- a/scripts/repl-study/paint.ts +++ b/scripts/repl-study/paint.ts @@ -83,7 +83,7 @@ function dress(node: Node, view: ReplView, layout: Layout, anchor: number): void if (drawer !== undefined) { // A drawer takes the contextual band; the input keeps its own node and // simply has nowhere to draw while one is open. - attach(node, drawerBody, drawer, placed(layout.contextual, layout)); + attach(node, drawerBody, { view: drawer }, placed(layout.contextual, layout)); return; } } diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 4d4f8352f..eba4069c2 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -16,8 +16,8 @@ import { close, grow, fixed, open, rgba, text } from "@bomb.sh/tty"; import type { Op } from "@bomb.sh/tty"; import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow, TransportMode } from "./model.ts"; -import type { HistoryView, MarkerView } from "./view.ts"; -import { historyViewFrom } from "./view.ts"; +import type { DrawerView, HistoryView, InputView, MarkerView } from "./view.ts"; +import { drawerViewFrom, historyViewFrom } from "./view.ts"; import type { Layout, Rect } from "./layout.ts"; import type { Placement } from "./component.ts"; import { placementOf } from "./component.ts"; @@ -661,26 +661,38 @@ function bindingsRegion(fixture: Fixture, layout: Layout, rect: Rect): Op[] { return region("bindings", rect, lines, { bg: BG.bind }); } -function contextualRegion( - fixture: Fixture, - view: View, - layout: Layout, - rect: Rect, +/** + * One suspension's drawer. + * + * The drawing is the study's. What changed is that it takes a `DrawerView` and + * the box its parent gives it, so the same body serves the component tree and + * the rectangle path while both exist. + */ +export function drawerRegion( + id: string, + drawer: DrawerView, + placement: Placement, focus?: FocusView, ): Op[] { + const rect = placement.rect; const width = Math.max(0, rect.width - 2); // With nothing to say about focus the drawer is drawn exactly as #838 drew // it, which is what keeps a frame that is not about focus byte-identical. const mark = (id: string): string => focus === undefined ? "" : focus.here === id ? `${FOCUS_MARK} ` : " "; - if (fixture.drawer && view.drawerOpen) { - const drawer = fixture.drawer; + { const lines: VisualLine[] = [plain(drawer.heading, C.hold)]; // Which suspended request this is answering is never dropped: a drawer // without its origin is a form with no idea what it belongs to. for (const wrapped of wrapText(drawer.origin, width)) { lines.push(plain(wrapped, C.dim)); } + if (drawer.historical) { + // What this is comes before what it says: a recording offers nothing to + // act on, and a reader should know that before reading the form. It also + // has to survive a band that clips — appended last, it did not. + lines.push(plain("recorded · read-only", C.gold)); + } lines.push(blank()); if (drawer.kind === "project") { for (const wrapped of wrapText(drawer.prompt, width)) { @@ -703,7 +715,7 @@ function contextualRegion( { text: `${mark("control:drawer.project.submit")}${drawer.submit}`, color: C.tick }, ], }); - if (!layout.dense) { + if (!placement.dense) { lines.push(blank(), label(`${mark("control:drawer.project.schema")}schema`)); for (const schema of drawer.schema) { lines.push(plain(schema, C.settledText)); @@ -758,25 +770,66 @@ function contextualRegion( }); lines.push(plain(drawer.hint, C.dim)); } - return region("contextual", rect, lines, { bg: BG.drawer, transition: DRAWER_TRANSITION }); + return region(id, rect, lines, { bg: BG.drawer, transition: DRAWER_TRANSITION }); } +} - const input = fixture.input; +/** The REPL input band, which the drawer takes over while one is open. */ +export function inputRegion(id: string, input: InputView, placement: Placement): Op[] { + const rect = placement.rect; + const width = Math.max(0, rect.width - 2); const lines: VisualLine[] = [ { segments: [ { text: input.label, color: C.label, width: Math.min(width, 18) }, - { text: input.hint, color: input.runEnabled ? C.dim : C.hold }, + { text: input.hint, color: input.run !== undefined ? C.dim : C.hold }, { - text: input.runEnabled ? "[ Run ⌘⏎ ]" : "[ Run ]", - color: input.runEnabled ? C.tick : C.dim, + text: input.run !== undefined ? "[ Run ⌘⏎ ]" : "[ Run ]", + color: input.run !== undefined ? C.tick : C.dim, width: 12, }, ], }, - plain(input.placeholder ?? "", C.settledText), + plain(input.draft === "" ? input.placeholder : input.draft, C.settledText), ]; - return region("contextual", rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION }); + return region(id, rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION }); +} + +/** + * The contextual band as the rectangle path still asks for it. + * + * A shim over the two view-driven regions, so both paths draw the same band + * while both exist. It goes when the rectangle path does. + */ +function contextualRegion( + fixture: Fixture, + view: View, + layout: Layout, + rect: Rect, + focus?: FocusView, +): Op[] { + const placement = placementOf(layout, rect); + if (fixture.drawer && view.drawerOpen) { + return drawerRegion( + "contextual", + drawerViewFrom(fixture.drawer, fixture.readOnly === true), + placement, + focus, + ); + } + return inputRegion( + "contextual", + { + label: fixture.input.label, + hint: fixture.input.hint, + placeholder: fixture.input.placeholder ?? "", + draft: "", + run: fixture.input.runEnabled + ? { id: "control:input.run", label: "Run", enabled: true } + : undefined, + }, + placement, + ); } export function clock(seconds: number): string { diff --git a/scripts/repl-study/view.ts b/scripts/repl-study/view.ts index e464a1902..7f9a3e6cd 100644 --- a/scripts/repl-study/view.ts +++ b/scripts/repl-study/view.ts @@ -102,15 +102,46 @@ export interface ControlView { readonly enabled: boolean; } -export interface DrawerView { - readonly kind: DrawerKind; +/** + * One suspension's drawer, as content rather than as a form. + * + * The shape follows the study's three drawers. `historical` says the drawer is + * a recording: every control is disabled and the components render what was + * recorded rather than an affordance that would do nothing. + */ +export type DrawerView = { readonly heading: string; readonly origin: string; - readonly lines: readonly string[]; readonly controls: readonly ControlView[]; - /** A recorded drawer renders its state and offers nothing to act on. */ readonly historical: boolean; -} +} & ( + | { + readonly kind: "project"; + readonly prompt: string; + readonly fields: readonly { readonly label: string; readonly value: string }[]; + readonly schema: readonly string[]; + readonly validation: string; + readonly submit: string; + } + | { + readonly kind: "review"; + readonly plan: readonly string[]; + readonly more: string; + readonly decisions: readonly { + readonly label: string; + readonly chosen: boolean; + readonly note?: string; + }[]; + readonly submit: string; + } + | { + readonly kind: "confirm"; + readonly prompt: string; + readonly preview: readonly string[]; + readonly actions: readonly { readonly label: string; readonly primary: boolean }[]; + readonly hint: string; + } +); export interface InputView { readonly label: string; @@ -320,7 +351,7 @@ export function project(state: ReplState): ReplView { if (drawer === undefined || drawer.kind !== kind) { return []; } - return [drawerViewOf(drawer, route.inspect)]; + return [drawerViewFrom(drawer, route.inspect)]; }); return { execution: route.execution, @@ -383,63 +414,61 @@ export function project(state: ReplState): ReplView { }; } -function drawerViewOf( +export function drawerViewFrom( drawer: NonNullable["drawer"]>, historical: boolean, ): DrawerView { + const shared = { heading: drawer.heading, origin: drawer.origin, historical }; + const control = (id: string, label: string): ControlView => ({ + id, + label, + enabled: !historical, + }); if (drawer.kind === "project") { return { + ...shared, kind: "project", - heading: drawer.heading, - origin: drawer.origin, - lines: [drawer.prompt, ...drawer.fields.map((f) => `${f.label}: ${f.value}`)], + prompt: drawer.prompt, + fields: drawer.fields, + schema: drawer.schema, + validation: drawer.validation, + submit: drawer.submit, controls: [ - { id: "field:drawer.project.name", label: "Project name", enabled: !historical }, - { id: "field:drawer.project.description", label: "Description", enabled: !historical }, - { - id: "control:drawer.project.schema", - label: "Schema disclosure · ⌥S", - enabled: !historical, - }, - { id: "control:drawer.project.submit", label: "Submit", enabled: !historical }, + control("field:drawer.project.name", "Project name"), + control("field:drawer.project.description", "Description"), + control("control:drawer.project.schema", "Schema disclosure · ⌥S"), + control("control:drawer.project.submit", "Submit"), ], - historical, }; } if (drawer.kind === "review") { return { + ...shared, kind: "review", - heading: drawer.heading, - origin: drawer.origin, - lines: [...drawer.plan, drawer.more], + plan: drawer.plan, + more: drawer.more, + decisions: drawer.decisions, + submit: drawer.submit, controls: [ - { - id: "control:drawer.review.scroll", - label: "Plan review · scroll region", - enabled: !historical, - }, - { id: "control:drawer.review.approve", label: "Approve", enabled: !historical }, - { id: "control:drawer.review.request", label: "Request changes", enabled: !historical }, - { id: "control:drawer.review.stop", label: "Stop", enabled: !historical }, - { id: "control:drawer.review.submit", label: "Submit", enabled: !historical }, + control("control:drawer.review.scroll", "Plan review · scroll region"), + control("control:drawer.review.approve", "Approve"), + control("control:drawer.review.request", "Request changes"), + control("control:drawer.review.stop", "Stop"), + control("control:drawer.review.submit", "Submit"), ], - historical, }; } return { + ...shared, kind: "confirm", - heading: drawer.heading, - origin: drawer.origin, - lines: [drawer.prompt, ...drawer.preview], + prompt: drawer.prompt, + preview: drawer.preview, + actions: drawer.actions, + hint: drawer.hint, controls: [ - { - id: "control:drawer.confirm.preview", - label: "README preview · scroll region", - enabled: !historical, - }, - { id: "control:drawer.confirm.approve", label: "Approve", enabled: !historical }, - { id: "control:drawer.confirm.decline", label: "Decline", enabled: !historical }, + control("control:drawer.confirm.preview", "README preview · scroll region"), + control("control:drawer.confirm.approve", "Approve"), + control("control:drawer.confirm.decline", "Decline"), ], - historical, }; } diff --git a/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt index c6ef2559b..f2ed3a5de 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt @@ -3,10 +3,9 @@ drawer-confirm.narrow · 90 × 28 · The README confirmation drawer suspended at · document scope Create README.md with the content shown above? - # Northstar + │ # Northstar + │ + │ A lightweight workspace for coordinating coding agents. - A lightweight workspace for coordinating coding agents. - - README preview · scroll region - Approve - Decline + [ Approve ] [ Decline ] + ⌘↵ approves · Esc closes the drawer without answering it diff --git a/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt index f72934505..d397aa1e2 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt @@ -34,13 +34,13 @@ drawer-confirm.wide · 200 × 50 · The README confirmation drawer suspended at · document scope Create README.md with the content shown above? - # Northstar + │ # Northstar + │ + │ A lightweight workspace for coordinating coding agents. - A lightweight workspace for coordinating coding agents. + [ Approve ] [ Decline ] + ⌘↵ approves · Esc closes the drawer without answering it - README preview · scroll region - Approve - Decline diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt index 439ec9891..fb3b5a497 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt @@ -2,14 +2,13 @@ drawer-historical.narrow · 90 × 28 · A recorded drawer, rendered without anyt INPUT REQUIRED suspended at · document scope · validated against the Elicit schema + recorded · read-only Enter the project details. - Project name: Northstar - Description: A lightweight workspace for coordinating coding agents. - · Project name - · Description - · Schema disclosure · ⌥S - · Submit + Project name + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. - recorded · read-only + both fields valid Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt index d6a849992..1a1560aec 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt @@ -32,18 +32,18 @@ drawer-historical.wide · 200 × 50 · A recorded drawer, rendered without anyth INPUT REQUIRED suspended at · document scope · validated against the Elicit schema + recorded · read-only Enter the project details. - Project name: Northstar - Description: A lightweight workspace for coordinating coding agents. - · Project name - · Description - · Schema disclosure · ⌥S - · Submit + Project name + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. - recorded · read-only + both fields valid Submit ⌘↵ + schema EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] recorded · 00:53 │ │ │ │ │┃ 00:53 Entry 1 │ │ │ │ │ │┃ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt index 4420a4f5f..615ef63e6 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt @@ -4,10 +4,10 @@ drawer-project.narrow · 90 × 28 · The project Elicit drawer schema Enter the project details. - Project name: Northstar - Description: A lightweight workspace for coordinating coding agents. - Project name - Description - Schema disclosure · ⌥S - Submit + Project name + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. + + both fields valid Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt index 7b4a8430b..eb1e8f027 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt @@ -34,16 +34,16 @@ drawer-project.wide · 200 × 50 · The project Elicit drawer suspended at · document scope · validated against the Elicit schema Enter the project details. - Project name: Northstar - Description: A lightweight workspace for coordinating coding agents. - - Project name - Description - Schema disclosure · ⌥S - Submit + Project name + ┃ Northstar + Description + ┃ A lightweight workspace for coordinating coding agents. + both fields valid Submit ⌘↵ + schema + { EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 Entry 1 │ │ │ │ ┃ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt index 647df6a74..6f92b1427 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt @@ -7,11 +7,11 @@ drawer-review.narrow · 90 × 28 · The plan-review Elicit drawer Provide the project name and a one-sentence description. - Enter the project details. + Enter the project details. ▸ 53 more lines · ⌥↓ scrolls the Plan - Plan review · scroll region - Approve - Request changes - Stop - Submit + (•) Approve + ( ) Request changes + ( ) Stop + + Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt index 1be01b321..d86ef00e5 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt @@ -38,12 +38,12 @@ drawer-review.wide · 200 × 50 · The plan-review Elicit drawer Provide the project name and a one-sentence description. - Enter the project details. + Enter the project details. ▸ 53 more lines · ⌥↓ scrolls the Plan - Plan review · scroll region - Approve - Request changes + (•) Approve + ( ) Request changes + ( ) Stop EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:31 │ │ ┃ 00:31 Entry 1 │ │ │ ┃ diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index d33a95c8f..588ad9420 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -214,9 +214,8 @@ describe("the component catalog", () => { "wide", ); expect(frame.text).toContain("recorded · read-only"); - // A disabled control is shown as recorded state rather than as an - // affordance that would do nothing. - expect(frame.text).toContain("· Submit"); + // Shown as the state it recorded, never as an affordance that would do + // nothing when pressed. expect(frame.text).not.toContain("[ Submit ]"); }); @@ -267,9 +266,10 @@ describe("a recorded drawer keeps its presentation and loses its actionability", CATALOG.find((one) => one.id === "drawer-historical")!, "wide", ); - // The complete recorded presentation is still drawn… - for (const control of ["Project name", "Description", "Schema disclosure", "Submit"]) { - expect(frame.text).toContain(control); + // The complete recorded presentation is still drawn — the study's own form, + // with its labelled fields, its validation line and its schema. + for (const shown of ["Project name", "Northstar", "Description", "schema", "Submit"]) { + expect(frame.text).toContain(shown); } expect(frame.text).toContain("recorded · read-only"); // …and none of it is in the ring, so none of it can be focused. From 517ae31fcdaa5ed4461cd94cc6e37a8f759faa3c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 13:03:01 -0400 Subject: [PATCH 16/57] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20Make=20the=20chro?= =?UTF-8?q?me=20part=20of=20the=20tree=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth step of the renderer migration. The surface bar, the header crumb, the pane separators, the focus marker and the `F1` overlay are mounted nodes now, so rendering *order* is the tree's rather than a sequence written out inside one function. They take no focus, so the ring is unchanged: a container draws and is never a target. Drawers mount before the trailing chrome, so the marker and the overlay still land on top of what they describe. The too-small refusal is a node too. Below the supported minimum the panes are not dressed at all — there is nothing for them to be inside — which is the same refusal #838 established, expressed as a tree rather than as an early return. **Eighteen catalog captures changed, for one intentional reason**: the catalog was drawing the panes without the composition around them. It now has the header crumb and the separators at wide, and the surface bar at narrow — the accepted composition, not a partial one. No #838 or #839 golden moved. 39 tests, 140 steps, lint and check clean. --- scripts/repl-study/components.ts | 44 +++++++++ scripts/repl-study/paint.ts | 62 ++++++++++++- scripts/repl-study/render.ts | 49 ++++++---- scripts/repl-study/tree.ts | 13 ++- .../repl-catalog/bindings-document.narrow.txt | 2 +- .../repl-catalog/bindings-document.wide.txt | 90 +++++++++---------- .../repl-catalog/bindings-plan.narrow.txt | 2 +- .../repl-catalog/bindings-plan.wide.txt | 90 +++++++++---------- .../repl-catalog/drawer-confirm.wide.txt | 90 +++++++++---------- .../repl-catalog/drawer-historical.wide.txt | 90 +++++++++---------- .../repl-catalog/drawer-project.wide.txt | 90 +++++++++---------- .../repl-catalog/drawer-review.wide.txt | 90 +++++++++---------- .../fixtures/repl-catalog/empty.narrow.txt | 2 +- .../fixtures/repl-catalog/empty.wide.txt | 90 +++++++++---------- .../repl-catalog/inspecting.narrow.txt | 2 +- .../fixtures/repl-catalog/inspecting.wide.txt | 90 +++++++++---------- .../fixtures/repl-catalog/nested.narrow.txt | 2 +- .../fixtures/repl-catalog/nested.wide.txt | 90 +++++++++---------- .../fixtures/repl-catalog/sessions.narrow.txt | 2 +- .../fixtures/repl-catalog/sessions.wide.txt | 90 +++++++++---------- .../fixtures/repl-catalog/settled.narrow.txt | 2 +- .../fixtures/repl-catalog/settled.wide.txt | 90 +++++++++---------- 22 files changed, 647 insertions(+), 525 deletions(-) diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index e320ca146..ecf99951b 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -20,7 +20,12 @@ import { bandRegion, BG, drawerRegion, + focusMapRegion, + focusMarkerOps, inputRegion, + rule, + surfaceBarRegion, + tooSmallRegion, blank, C, clock, @@ -32,6 +37,7 @@ import { wrapText, } from "./render.ts"; import type { FocusView, VisualLine } from "./render.ts"; +import type { Layout, Rect, SurfaceName } from "./layout.ts"; import type { Mutation } from "./mutations.ts"; import type { Motion } from "./playback.ts"; import type { @@ -276,6 +282,44 @@ export interface DrawerData { readonly focus?: FocusView; } +/** Narrow only: the one row naming the surface you are on. */ +export const surfaceBarBody: Body<{ + readonly crumb: string; + readonly badge?: string; + readonly surface: SurfaceName; +}> = ({ self, data, placement, children }) => [ + ...surfaceBarRegion(self.id, data.crumb, data.badge, data.surface, placement.rect), + ...children, +]; + +/** The separators between the composed panes. */ +export const rulesBody: Body = ({ self, data, children }) => [ + ...data.flatMap((separator, at) => + rule(`${self.id}.${at}`, separator, separator.width === 1 ? "│" : "─"), + ), + ...children, +]; + +/** Where focus is, as a glyph that survives a monochrome terminal. */ +export const focusMarkerBody: Body<{ readonly layout: Layout; readonly focus?: FocusView }> = ({ + self, + data, + children, +}) => [...focusMarkerOps(self.id, data.layout, data.focus), ...children]; + +/** The numbered focus map, when it is asked for. */ +export const focusMapBody: Body<{ readonly layout: Layout; readonly focus?: FocusView }> = ({ + self, + data, + children, +}) => [ + ...(data.focus?.overlay === true ? focusMapRegion(self.id, data.layout, data.focus) : []), + ...children, +]; + +/** The refusal a terminal below the supported minimum gets instead of a screen. */ +export const refusalBody: Body = ({ self, data }) => tooSmallRegion(self.id, data); + /** A structural outlet: it draws nothing and contributes its children unchanged. */ export const outletBody: Body = ({ children }) => [...children]; diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts index 2cc0fd05e..1c8a21ef1 100644 --- a/scripts/repl-study/paint.ts +++ b/scripts/repl-study/paint.ts @@ -20,6 +20,11 @@ import type { Placement } from "./component.ts"; import { bindingsBody, drawerBody, + focusMapBody, + focusMarkerBody, + refusalBody, + rulesBody, + surfaceBarBody, headerBody, historyBody, inputBody, @@ -31,6 +36,8 @@ import { import type { Layout, Rect } from "./layout.ts"; import type { Node } from "./vendor/freedom/upstream/index.ts"; import type { ReplView } from "./view.ts"; +import type { FocusView } from "./render.ts"; +import type { SurfaceName } from "./layout.ts"; /** A node that is mounted but not composed at this profile draws nothing. */ const NOWHERE: Rect = { x: 0, y: 0, width: 0, height: 0 }; @@ -46,8 +53,43 @@ function placed(rect: Rect | undefined, layout: Layout): Placement { * alternative — attaching once and mutating a captured reference — would make * "the data a component rendered" a thing two places could answer. */ -function dress(node: Node, view: ReplView, layout: Layout, anchor: number): void { +function dress(node: Node, request: PaintRequest): void { + const { view, layout, anchor } = request; const name = node.name; + if (name === "chrome:surface-bar") { + attach( + node, + surfaceBarBody, + { + crumb: view.crumb, + badge: view.badge, + surface: surfaceOf(view), + }, + placed(layout.surfaceBar, layout), + ); + return; + } + if (name === "chrome:rules") { + attach(node, rulesBody, layout.separators, placed(layout.screen, layout)); + return; + } + if (name === "chrome:focus-marker") { + attach(node, focusMarkerBody, { layout, focus: request.focus }, placed(layout.screen, layout)); + return; + } + if (name === "chrome:focus-map") { + attach(node, focusMapBody, { layout, focus: request.focus }, placed(layout.screen, layout)); + return; + } + if (name === "chrome:header") { + attach( + node, + headerBody, + { crumb: view.crumb, badge: view.badge }, + placed(layout.header, layout), + ); + return; + } if (name === "region:sessions") { attach(node, sessionsBody, view.sessions, placed(layout.sidebar, layout)); return; @@ -116,15 +158,29 @@ export interface PaintRequest { readonly layout: Layout; /** The transcript window, which the renderer clips rather than scrolls. */ readonly anchor: number; + readonly focus?: FocusView; +} + +/** Which of the four routed surfaces the narrow bar names. */ +function surfaceOf(view: ReplView): SurfaceName { + return view.surface === "input" ? "transcript" : view.surface; } /** Hand every mounted node its data, then render the tree. */ export function paint(request: PaintRequest): Op[] { - const { root, view, layout, anchor } = request; + const { root, layout } = request; attach(root, rootBody, undefined, placementOf(layout, layout.screen)); + if (layout.profile === "too-small") { + // Below the minimum the interface is refused rather than shrunk, so the + // panes are not dressed at all — there is nothing for them to be inside. + for (const child of root.children) { + attach(child, refusalBody, layout, placementOf(layout, layout.screen)); + return walk(child); + } + } const visit = (node: Node): void => { for (const child of node.children) { - dress(child, view, layout, anchor); + dress(child, request); visit(child); } }; diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index eba4069c2..0f3aa5dd2 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -18,7 +18,7 @@ import type { Op } from "@bomb.sh/tty"; import type { Checkpoint, Entry, Fixture, Phase, TranscriptRow, TransportMode } from "./model.ts"; import type { DrawerView, HistoryView, InputView, MarkerView } from "./view.ts"; import { drawerViewFrom, historyViewFrom } from "./view.ts"; -import type { Layout, Rect } from "./layout.ts"; +import type { Layout, Rect, SurfaceName } from "./layout.ts"; import type { Placement } from "./component.ts"; import { placementOf } from "./component.ts"; import { MINIMUM } from "./layout.ts"; @@ -297,7 +297,7 @@ function regionRect(layout: Layout, identity: string): Rect | undefined { return undefined; } -function focusMarkerOps(layout: Layout, focus: FocusView | undefined): Op[] { +export function focusMarkerOps(id: string, layout: Layout, focus: FocusView | undefined): Op[] { if (focus === undefined) { return []; } @@ -306,7 +306,7 @@ function focusMarkerOps(layout: Layout, focus: FocusView | undefined): Op[] { return []; } return [ - open("focus-marker", { + open(id, { layout: { width: fixed(1), height: fixed(1) }, floating: { x: rect.x, y: rect.y, attachTo: "root" }, }), @@ -332,7 +332,7 @@ const TRANSPORT_WORDS: Record = { * study frame 12 numbers a dimmed `Continue` and says Tab skips it, so the map * has to show what the ring does not. */ -function focusMapRegion(layout: Layout, focus: FocusView): Op[] { +export function focusMapRegion(id: string, layout: Layout, focus: FocusView): Op[] { const ordered = focus.map; const width = Math.min(34, Math.max(18, Math.round(layout.cols * 0.24))); const height = Math.min(layout.rows, ordered.length + 2); @@ -348,7 +348,7 @@ function focusMapRegion(layout: Layout, focus: FocusView): Op[] { ], }); } - return region("focus-map", rect, lines, { bg: BG.drawer }); + return region(id, rect, lines, { bg: BG.drawer }); } export function blank(): VisualLine { @@ -1237,25 +1237,28 @@ function headerRegion(fixture: Fixture, rect: Rect): Op[] { return region("header", rect, lines, { bg: BG.center }); } -function surfaceBarRegion(fixture: Fixture, view: View, rect: Rect): Op[] { +export function surfaceBarRegion( + id: string, + crumb: string, + badge: string | undefined, + surface: SurfaceName, + rect: Rect, +): Op[] { const names = { sessions: "SESSIONS", transcript: "TRANSCRIPT", bindings: "BINDINGS", history: "EXECUTION HISTORY", }; - const at = ["sessions", "transcript", "bindings", "history"].indexOf(view.surface) + 1; + const at = ["sessions", "transcript", "bindings", "history"].indexOf(surface) + 1; return region( - "surface-bar", + id, rect, [ { segments: [ - { text: `${names[view.surface]} · ${at} / 4`, color: C.intro, width: 27 }, - { - text: fixture.badge ?? fixture.crumb, - color: fixture.badge === undefined ? C.dim : C.gold, - }, + { text: `${names[surface]} · ${at} / 4`, color: C.intro, width: 27 }, + { text: badge ?? crumb, color: badge === undefined ? C.dim : C.gold }, { text: "Tab ▸", color: C.label, width: 7 }, ], }, @@ -1264,7 +1267,7 @@ function surfaceBarRegion(fixture: Fixture, view: View, rect: Rect): Op[] { ); } -function tooSmallRegion(layout: Layout): Op[] { +export function tooSmallRegion(id: string, layout: Layout): Op[] { const lines: VisualLine[] = [ plain("Terminal too small", C.out), plain( @@ -1273,7 +1276,7 @@ function tooSmallRegion(layout: Layout): Op[] { ), plain("resize to continue", C.dim), ]; - return region("too-small", layout.screen, lines, { + return region(id, layout.screen, lines, { bg: BG.app, padding: { left: 1, right: 1, top: 1 }, }); @@ -1297,12 +1300,20 @@ export function renderScreen(request: ScreenRequest): Op[] { ]; if (layout.profile === "too-small") { - ops.push(...tooSmallRegion(layout), close()); + ops.push(...tooSmallRegion("too-small", layout), close()); return ops; } if (layout.surfaceBar) { - ops.push(...surfaceBarRegion(fixture, view, layout.surfaceBar)); + ops.push( + ...surfaceBarRegion( + "surface-bar", + fixture.crumb, + fixture.badge, + view.surface, + layout.surfaceBar, + ), + ); } if (layout.header) { ops.push(...headerRegion(fixture, layout.header)); @@ -1358,9 +1369,9 @@ export function renderScreen(request: ScreenRequest): Op[] { } // Focus is drawn last, over the regions it describes, because a marker under // the thing it marks is a marker nobody sees. - ops.push(...focusMarkerOps(layout, focus)); + ops.push(...focusMarkerOps("focus-marker", layout, focus)); if (focus?.overlay === true) { - ops.push(...focusMapRegion(layout, focus)); + ops.push(...focusMapRegion("focus-map", layout, focus)); } ops.push(close()); return ops; diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 5162866d1..17aaa3cc0 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -117,6 +117,12 @@ export function useReplTree(state: ReplState): Operation { return { *[Symbol.iterator]() { const root = yield* useRoot(); + // Chrome the composition draws around the panes. These are nodes so that + // rendering order is the tree's, not a sequence written out in one + // function — but they take no focus, so the ring is unchanged. + for (const name of ["chrome:surface-bar", "chrome:header"]) { + root.node.createChild(name).set("container", true); + } const regions = new Map(); for (const region of ROUTE_SURFACES) { const node = root.node.createChild(`region:${region}`); @@ -124,6 +130,10 @@ export function useReplTree(state: ReplState): Operation { recordPath(node, node.name); regions.set(region, node); } + // Drawn after the panes, so they land on top of what they describe. + for (const name of ["chrome:rules", "chrome:focus-marker", "chrome:focus-map"]) { + root.node.createChild(name).set("container", true); + } useFocus(root.node); let drawers: Mounted[] = []; @@ -291,7 +301,8 @@ export function useReplTree(state: ReplState): Operation { if (!isDrawerKind(kind)) { continue; } - const node = root.node.createChild(`drawer:${kind}`); + const before = [...root.node.children].find((child) => child.name === "chrome:rules"); + const node = root.node.createChild(`drawer:${kind}`, before ? { before } : undefined); node.set("container", true); recordPath(node, node.name); // The drawer's controls live in a body panel, so a key bound for one diff --git a/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt b/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt index 75993c27e..98b20e3ad 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt @@ -1,5 +1,5 @@ bindings-document.narrow · 90 × 28 · Bindings at the document scope - + BINDINGS · 3 / 4 REPL › Entry 1 › document · suspended Tab ▸ BINDINGS document scope diff --git a/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt index 622fc1cdb..520ddcbef 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt @@ -1,49 +1,49 @@ bindings-document.wide · 200 × 50 · Bindings at the document scope - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ● running · 48.9s ↳ document scope suspended BINDINGS - SESSIONS · 3 document scope - chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor - document readme - │ plan-a91f7c ▶ ENTER markdown · 3 lines - ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar - │ ● WAITING - review-b72e1d │ │ Enter the project details. - ● responding reviewer · turn 1 · streaming │ ● WAITING - streaming · background update · selection u… │ ▲ suspended · answer in the drawer below - - implement-c31d2e - · queued implementer · no turn yet - - - - - - - - - - - - - - - - - - - - - - - - - - DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] - - - + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 Entry 1 │ │ │ │ ┃ diff --git a/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt b/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt index b6d053375..cc9817e69 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt @@ -1,5 +1,5 @@ bindings-plan.narrow · 90 × 28 · Bindings at the Plan scope - + BINDINGS · 3 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ BINDINGS Plan scope diff --git a/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt index 815b6ce25..3f55a04ba 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt @@ -1,49 +1,49 @@ bindings-plan.wide · 200 × 50 · Bindings at the Plan scope - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ● running · 31.4s ↳ Plan scope open BINDINGS - SESSIONS · 1 Plan scope - chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor - document prompt - │ plan-a91f7c repl:entry-1 · submitted source is immutable while running "Create an XMD program that a… - ✓ completed planner · turn 1 · returned 5… ▶ ENTER project name and a descriptio… - │ Create a project README - │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, syntax - │ reviews its own draft, and returns it for admission into this document scope. prose - │ ● ACTIVE XMD catalog · 47 symbols - │ │ ✓ Read the Prompt · prompt component, control, agent, io - │ │ ✓ Prepare the planning inputs · syntax, inputs - │ │ ✓ Create the first draft · draft inputs - │ │ ▾ Check the draft json - │ │ ● ACTIVE { - │ │ │ ✓ SETTLED surface: "component", - │ │ ● WAITING session: "plan-a91f7c", - │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. budget: 3 - │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be } - │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. - │ │ │ ✓ SETTLED draft - │ │ │ ✓ SETTLED XMD source · 59 lines - │ │ ● WAITING # Create a project README - │ │ ● ACTIVE ENTER │ project name and a descriptio… + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ ENTER markdown · 3 lines - ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar - │ ● WAITING - review-b72e1d │ │ Enter the project details. - ● responding reviewer · turn 1 · streaming │ ● WAITING - streaming · background update · selection u… │ ▲ suspended · answer in the drawer below - - implement-c31d2e - · queued implementer · no turn yet - - - - - - - - - - - - - - - - CONFIRMATION REQUIRED - suspended at · document scope - - Create README.md with the content shown above? - │ # Northstar - │ - │ A lightweight workspace for coordinating coding agents. - - [ Approve ] [ Decline ] - ⌘↵ approves · Esc closes the drawer without answering it - - - - + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ CONFIRMATION REQUIRED + │ suspended at · document scope + │ + │ Create README.md with the content shown above? + │ │ # Northstar + │ │ + │ │ A lightweight workspace for coordinating coding agents. + │ + │ [ Approve ] [ Decline ] + │ ⌘↵ approves · Esc closes the drawer without answering it + │ + │ + │ + │ EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 Entry 1 │ │ │ │ ┃ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt index 1a1560aec..5b1c900bc 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt @@ -1,49 +1,49 @@ drawer-historical.wide · 200 × 50 · A recorded drawer, rendered without anything to act on - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ● running · 53.0s ↳ reconstructed · read-only BINDINGS - ENTRY 1 · CREATE PROJECT README Plan scope · as recorded - inspecting recorded history · read-only reconstructed from the journal · no live action is possible here - Plan prompt - 00:02 ◆ Entry 1 submitted ● ACTIVE "Create an XMD program that a… - 00:05 ● document scope entered │ ✓ Read the Prompt · prompt - 00:12 ● Plan entered │ ▾ Prepare the planning inputs - 00:18 ● planning inputs prepared │ ✓ SETTLED - 00:29 ● planning Agent response admitted │ ● ACTIVE - 00:30 ● draft checked │ XMD catalog · 47 symbols · component, control, agent, io - 00:41 ● review returned Approve - 00:47 ● Plan replaced by returned program - 00:49 ● project Elicit requested - 00:52 ● project Elicit answered - 00:53 ● confirmation Elicit requested - - ▸ 26 internal records - - SELECTED CHECKPOINT - Plan entered - 00:12 elapsed · Entry 1 › document › Plan - · scope.enter Plan - · inputs.bound content - · component.resolved Plan.md - - - - - INPUT REQUIRED - suspended at · document scope · validated against the Elicit schema - recorded · read-only - - Enter the project details. - - Project name - ┃ Northstar - Description - ┃ A lightweight workspace for coordinating coding agents. - - both fields valid Submit ⌘↵ - - schema + XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS + ENTRY 1 · CREATE PROJECT README │ │ Plan scope · as recorded + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here │ + │ Plan │ prompt + 00:02 ◆ Entry 1 submitted │ ● ACTIVE │ "Create an XMD program that a… + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt │ + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs │ + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › document › Plan │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ recorded · read-only + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] recorded · 00:53 │ │ │ │ │┃ 00:53 Entry 1 │ │ │ │ │ │┃ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt index eb1e8f027..304f9d6b2 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt @@ -1,49 +1,49 @@ drawer-project.wide · 200 × 50 · The project Elicit drawer - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ● running · 48.9s ↳ document scope suspended BINDINGS - SESSIONS · 3 document scope - chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor - document readme - │ plan-a91f7c ▶ ENTER markdown · 3 lines - ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar - │ ● WAITING - review-b72e1d │ │ Enter the project details. - ● responding reviewer · turn 1 · streaming │ ● WAITING - streaming · background update · selection u… │ ▲ suspended · answer in the drawer below - - implement-c31d2e - · queued implementer · no turn yet - - - - - - - - - - - - - - - - INPUT REQUIRED - suspended at · document scope · validated against the Elicit schema - - Enter the project details. - - Project name - ┃ Northstar - Description - ┃ A lightweight workspace for coordinating coding agents. - - both fields valid Submit ⌘↵ - - schema - { + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ INPUT REQUIRED + │ suspended at · document scope · validated against the Elicit schema + │ + │ Enter the project details. + │ + │ Project name + │ ┃ Northstar + │ Description + │ ┃ A lightweight workspace for coordinating coding agents. + │ + │ both fields valid Submit ⌘↵ + │ + │ schema + │ { EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 Entry 1 │ │ │ │ ┃ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt index d86ef00e5..4df7e6f0e 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt @@ -1,49 +1,49 @@ drawer-review.wide · 200 × 50 · The plan-review Elicit drawer - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ● running · 31.4s ↳ Plan scope open BINDINGS - SESSIONS · 1 Plan scope - chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor - document prompt - │ plan-a91f7c repl:entry-1 · submitted source is immutable while running "Create an XMD program that a… - ✓ completed planner · turn 1 · returned 5… ▶ ENTER project name and a descriptio… - │ Create a project README - │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, syntax - │ reviews its own draft, and returns it for admission into this document scope. prose - │ ● ACTIVE XMD catalog · 47 symbols - │ │ ✓ Read the Prompt · prompt component, control, agent, io - │ │ ✓ Prepare the planning inputs · syntax, inputs - │ │ ✓ Create the first draft · draft inputs - │ │ ▾ Check the draft json - │ │ ● ACTIVE { - │ │ │ ✓ SETTLED surface: "component", - │ │ ● WAITING session: "plan-a91f7c", - │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. budget: 3 - │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be } - │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. - │ │ │ ✓ SETTLED draft - │ │ │ ✓ SETTLED XMD source · 59 lines - │ │ ● WAITING # Create a project README - │ │ ● ACTIVE · Plan scope · 59 lines returned - - # Create a project README - - Provide the project name and a one-sentence description. - - - Enter the project details. - ▸ 53 more lines · ⌥↓ scrolls the Plan - - (•) Approve - ( ) Request changes - ( ) Stop + XMD REPL │ REPL › Entry 1 › document › Plan · active + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + SESSIONS · 1 │ │ Plan scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ prompt + │ plan-a91f7c │ repl:entry-1 · submitted source is immutable while running │ "Create an XMD program that a… + ✓ completed planner · turn 1 · returned 5… │ ▶ ENTER │ project name and a descriptio… + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ · Plan scope · 59 lines returned + │ + │ # Create a project README + │ + │ Provide the project name and a one-sentence description. + │ + │ + │ Enter the project details. + │ ▸ 53 more lines · ⌥↓ scrolls the Plan + │ + │ (•) Approve + │ ( ) Request changes + │ ( ) Stop EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:31 │ │ ┃ 00:31 Entry 1 │ │ │ ┃ diff --git a/scripts/tests/fixtures/repl-catalog/empty.narrow.txt b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt index 18bb796a9..7403ca82d 100644 --- a/scripts/tests/fixtures/repl-catalog/empty.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt @@ -1,5 +1,5 @@ empty.narrow · 90 × 28 · An empty REPL, before anything has run - + TRANSCRIPT · 2 / 4 REPL Tab ▸ TRANSCRIPT No executions yet. diff --git a/scripts/tests/fixtures/repl-catalog/empty.wide.txt b/scripts/tests/fixtures/repl-catalog/empty.wide.txt index 3c237d93c..f6b7d2986 100644 --- a/scripts/tests/fixtures/repl-catalog/empty.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/empty.wide.txt @@ -1,48 +1,48 @@ empty.wide · 200 × 50 · An empty REPL, before anything has run - XMD REPL - - SESSION JOURNAL STATE - TRANSCRIPT BINDINGS - No sessions yet REPL scope - No executions yet. - Agent sessions appear here as executions open No REPL bindings yet - them. Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the Values named with as appear - They persist after an entry settles. bindings it published. here for the active scope. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - REPL INPUT ⇧⏎ newline [ Run ] - Enter XMD or invoke a document… - - + XMD REPL │ REPL + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ TRANSCRIPT │ BINDINGS + No sessions yet │ │ REPL scope + │ No executions yet. │ + Agent sessions appear here as executions open │ │ No REPL bindings yet + them. │ Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the │ Values named with as appear + They persist after an entry settles. │ bindings it published. │ here for the active scope. + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ⇧⏎ newline [ Run ] + │ Enter XMD or invoke a document… + │ + │ EXECUTION HISTORY IDLE [ Pause ] No recorded execution … diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt index cb8db4a34..4f8771a79 100644 --- a/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt @@ -1,5 +1,5 @@ inspecting.narrow · 90 × 28 · Historical inspection, reconstructed and read-only - + EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] 00:53 ││ ││┃ 00:53 Entry 1 ││ │ ││┃ diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt index e30217303..d85924a7f 100644 --- a/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt @@ -1,49 +1,49 @@ inspecting.wide · 200 × 50 · Historical inspection, reconstructed and read-only - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ● running · 53.0s ↳ reconstructed · read-only BINDINGS - ENTRY 1 · CREATE PROJECT README Plan scope · as recorded - inspecting recorded history · read-only reconstructed from the journal · no live action is possible here - Plan prompt - 00:02 ◆ Entry 1 submitted ● ACTIVE "Create an XMD program that a… - 00:05 ● document scope entered │ ✓ Read the Prompt · prompt - 00:12 ● Plan entered │ ▾ Prepare the planning inputs - 00:18 ● planning inputs prepared │ ✓ SETTLED - 00:29 ● planning Agent response admitted │ ● ACTIVE - 00:30 ● draft checked │ XMD catalog · 47 symbols · component, control, agent, io - 00:41 ● review returned Approve - 00:47 ● Plan replaced by returned program - 00:49 ● project Elicit requested - 00:52 ● project Elicit answered - 00:53 ● confirmation Elicit requested - - ▸ 26 internal records - - SELECTED CHECKPOINT - Plan entered - 00:12 elapsed · Entry 1 › document › Plan - · scope.enter Plan - · inputs.bound content - · component.resolved Plan.md - - - - - - - - - - - - - - - DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] - - - + XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS + ENTRY 1 · CREATE PROJECT README │ │ Plan scope · as recorded + inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here │ + │ Plan │ prompt + 00:02 ◆ Entry 1 submitted │ ● ACTIVE │ "Create an XMD program that a… + 00:05 ● document scope entered │ │ ✓ Read the Prompt · prompt │ + 00:12 ● Plan entered │ │ ▾ Prepare the planning inputs │ + 00:18 ● planning inputs prepared │ │ ✓ SETTLED │ + 00:29 ● planning Agent response admitted │ │ ● ACTIVE │ + 00:30 ● draft checked │ │ XMD catalog · 47 symbols · component, control, agent, io │ + 00:41 ● review returned Approve │ │ + 00:47 ● Plan replaced by returned program │ │ + 00:49 ● project Elicit requested │ │ + 00:52 ● project Elicit answered │ │ + 00:53 ● confirmation Elicit requested │ │ + │ │ + ▸ 26 internal records │ │ + │ │ + SELECTED CHECKPOINT │ │ + Plan entered │ │ + 00:12 elapsed · Entry 1 › document › Plan │ │ + · scope.enter Plan │ │ + · inputs.bound content │ │ + · component.resolved Plan.md │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 suspended · inspecting recorded history [ Run ] + │ + │ + │ EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] recorded · 00:53 │ │ │ │ │┃ 00:53 Entry 1 │ │ │ │ │ │┃ diff --git a/scripts/tests/fixtures/repl-catalog/nested.narrow.txt b/scripts/tests/fixtures/repl-catalog/nested.narrow.txt index f5a8d1286..462c45b05 100644 --- a/scripts/tests/fixtures/repl-catalog/nested.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/nested.narrow.txt @@ -1,5 +1,5 @@ nested.narrow · 90 × 28 · Nested execution, with the Plan scope open - + TRANSCRIPT · 2 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ Entry 1 ● running · 31.4s ↳ Plan scope open ← opened from Entry 1 · live execution projection, not an editor diff --git a/scripts/tests/fixtures/repl-catalog/nested.wide.txt b/scripts/tests/fixtures/repl-catalog/nested.wide.txt index f954baa7b..c8fde160e 100644 --- a/scripts/tests/fixtures/repl-catalog/nested.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/nested.wide.txt @@ -1,49 +1,49 @@ nested.wide · 200 × 50 · Nested execution, with the Plan scope open - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ● running · 31.4s ↳ Plan scope open BINDINGS - SESSIONS · 1 Plan scope - chronological · selection follows you, not ac… ← opened from Entry 1 · live execution projection, not an editor - document prompt - │ plan-a91f7c repl:entry-1 · submitted source is immutable while running "Create an XMD program that a… - ✓ completed planner · turn 1 · returned 5… ▶ ENTER project name and a descriptio… - │ Create a project README - │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, syntax - │ reviews its own draft, and returns it for admission into this document scope. prose - │ ● ACTIVE XMD catalog · 47 symbols - │ │ ✓ Read the Prompt · prompt component, control, agent, io - │ │ ✓ Prepare the planning inputs · syntax, inputs - │ │ ✓ Create the first draft · draft inputs - │ │ ▾ Check the draft json - │ │ ● ACTIVE { - │ │ │ ✓ SETTLED surface: "component", - │ │ ● WAITING session: "plan-a91f7c", - │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. budget: 3 - │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be } - │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. - │ │ │ ✓ SETTLED draft - │ │ │ ✓ SETTLED XMD source · 59 lines - │ │ ● WAITING # Create a project README - │ │ ● ACTIVE ENTER │ project name and a descriptio… + │ │ Create a project README │ + │ │ Provide the project name and a one-sentence description. The Plan component drafts the program that asks for them, │ syntax + │ │ reviews its own draft, and returns it for admission into this document scope. │ prose + │ │ ● ACTIVE │ XMD catalog · 47 symbols + │ │ │ ✓ Read the Prompt · prompt │ component, control, agent, io + │ │ │ ✓ Prepare the planning inputs · syntax, inputs │ + │ │ │ ✓ Create the first draft · draft │ inputs + │ │ │ ▾ Check the draft │ json + │ │ │ ● ACTIVE │ { + │ │ │ │ ✓ SETTLED │ surface: "component", + │ │ │ ● WAITING │ session: "plan-a91f7c", + │ │ │ │ Review the generated Plan and choose Approve, Request changes or Stop. │ budget: 3 + │ │ │ │ The reviewer has the draft, the schema it was checked against, and the capabilities the document would be │ } + │ │ │ │ granted if the Plan is admitted. Nothing it returns runs until this scope admits it. │ + │ │ │ │ ✓ SETTLED │ draft + │ │ │ │ ✓ SETTLED │ XMD source · 59 lines + │ │ │ ● WAITING │ # Create a project README + │ │ │ ● ACTIVE │ ENTER markdown · 3 lines - ✓ completed planner · turn 1 · returned 5… │ ▾ Ask for the project details # Northstar - │ ● WAITING - review-b72e1d │ │ Enter the project details. - ● responding reviewer · turn 1 · streaming │ ● WAITING - streaming · background update · selection u… │ ▲ suspended · answer in the drawer below - - implement-c31d2e - · queued implementer · no turn yet - - - - - - - - - - - - - - - - - - - - - - - - - - DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] - - - + XMD REPL │ REPL › Entry 1 › document · suspended + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + SESSIONS · 3 │ │ document scope + chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ + │ document │ readme + │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines + ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar + │ │ ● WAITING │ + review-b72e1d │ │ │ Enter the project details. │ + ● responding reviewer · turn 1 · streaming │ │ ● WAITING │ + streaming · background update · selection u… │ │ ▲ suspended · answer in the drawer below │ + │ │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ DRAFT · ENTRY 2 Run unavailable while Entry 1 is active [ Run ] + │ + │ + │ EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 Entry 1 │ │ │ │ ┃ diff --git a/scripts/tests/fixtures/repl-catalog/settled.narrow.txt b/scripts/tests/fixtures/repl-catalog/settled.narrow.txt index 076c66b38..286b06095 100644 --- a/scripts/tests/fixtures/repl-catalog/settled.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/settled.narrow.txt @@ -1,5 +1,5 @@ settled.narrow · 90 × 28 · A settled entry, the input ready for the next one - + TRANSCRIPT · 2 / 4 REPL · Entry 1 settled Tab ▸ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines Create a project README diff --git a/scripts/tests/fixtures/repl-catalog/settled.wide.txt b/scripts/tests/fixtures/repl-catalog/settled.wide.txt index 67368d9bc..f8adbb72f 100644 --- a/scripts/tests/fixtures/repl-catalog/settled.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/settled.wide.txt @@ -1,49 +1,49 @@ settled.wide · 200 × 50 · A settled entry, the input ready for the next one - XMD REPL - - SESSION JOURNAL STATE - Entry 1 ✓ completed · 41.2s ▸ source · 8 lines BINDINGS - SESSIONS · 3 REPL scope - persist after settling Create a project README - Provide the project name and a one-sentence description. Entry 1 published none - │ plan-a91f7c │ MARKDOWN Values named with as appear - ✓ completed planner · turn 1 · returned 5… │ # Northstar here for the active scope. - │ - review-b72e1d │ A lightweight workspace for coordinating coding agents. - ● responding reviewer · turn 1 · streaming │ README.md · 63 bytes · +3 lines - streaming · background update · selection u… README.md was created for Northstar. - no REPL bindings published · 1 file written - implement-c31d2e - · queued implementer · no turn yet - - - - - - - - - - - - - - - - - - - - - - - - - - REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] - - - + XMD REPL │ REPL · Entry 1 settled + │ + SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS + SESSIONS · 3 │ │ REPL scope + persist after settling │ Create a project README │ + │ Provide the project name and a one-sentence description. │ Entry 1 published none + │ plan-a91f7c │ │ MARKDOWN │ Values named with as appear + ✓ completed planner · turn 1 · returned 5… │ │ # Northstar │ here for the active scope. + │ │ │ + review-b72e1d │ │ A lightweight workspace for coordinating coding agents. │ + ● responding reviewer · turn 1 · streaming │ │ README.md · 63 bytes · +3 lines │ + streaming · background update · selection u… │ README.md was created for Northstar. │ + │ no REPL bindings published · 1 file written │ + implement-c31d2e │ │ + · queued implementer · no turn yet │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │ + │ + │ EXECUTION HISTORY │ ┃ SETTLED IDLE [ Pause ] recorded · 01:01 │ │ │ │ │ │ │ │ ┃ 01:01 Entry 1 │ │ │ │ │ │ │ │ │ ┃ From d602b985baaa24259c4cc574cc28be8b11f2f2c3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 13:15:23 -0400 Subject: [PATCH 17/57] =?UTF-8?q?=F0=9F=8C=B3=20Render=20every=20frame=20b?= =?UTF-8?q?y=20walking=20the=20mounted=20tree=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer migration is complete: `renderScreen` and the region functions only it called are **deleted**, and there is one composition path. Every frame — the captures, the catalog, the playbacks, the journey and the interactive harness — is drawn by walking the mounted Freedom tree. A frame is drawn by a mounted composition, so a caller that wants frames mounts one first. `playFrames`, the journey and the host mount one per moment and reuse it, which is what makes a transition a change to a tree rather than a new tree. **Every one of #838's twenty-six captures is byte-identical.** That was the measure of whether this was a migration or a rewrite, and four differences found along the way were each a real defect rather than a reason to move a golden: - the Run affordance was tied to a non-empty draft, which is a focus-ring rule and not a rendering one — #838 draws `[ Run ⌘⏎ ]` whenever the fixture offers it, and whether Tab may land on it stays the tree's question; - a routed narrow capture was projected as the transcript rather than as the surface it routes to; - the transcript's arrival animation never reached the component; - an open drawer owns the contextual band from the *first* frame of the transition — its height is what interpolates, and guarding the drawer on progress made the band jump instead of grow. Regions are addressed by node id, so evidence that asks about a role — "is the footer ever covered?" — gets the id that actually rendered it rather than guessing a name. `stale-frame` and `drawer-covers-footer` moved onto the tree with it. Two catalog captures changed: the empty REPL's Run affordance, for the reason above. The whole animated demonstration is re-proved, not only the components: every moment in order, both kinds of motion, the renderer rebuilt when it exhausts its measurement cache, a real pseudo-terminal playing start to finish with nobody at the keyboard, and the journey cancelled part way through. --- scripts/repl-study/README.md | 2 +- scripts/repl-study/capture.ts | 117 ++++++- scripts/repl-study/catalog.ts | 2 +- scripts/repl-study/components.ts | 31 +- scripts/repl-study/host.ts | 20 +- scripts/repl-study/paint.ts | 77 ++++- scripts/repl-study/render.ts | 319 ------------------ scripts/repl-study/view.ts | 81 ++++- .../fixtures/repl-catalog/empty.narrow.txt | 2 +- .../fixtures/repl-catalog/empty.wide.txt | 2 +- scripts/tests/repl-components.test.ts | 2 +- scripts/tests/repl-study.test.ts | 2 + 12 files changed, 291 insertions(+), 366 deletions(-) diff --git a/scripts/repl-study/README.md b/scripts/repl-study/README.md index d8d3a3f2c..9696f013d 100644 --- a/scripts/repl-study/README.md +++ b/scripts/repl-study/README.md @@ -195,7 +195,7 @@ makes a capture legible as evidence against the frame it reproduces. | `store.ts` | `ReplState`, its reducer, `hydrate()` and `projection()` | | `frames.ts` | the focus study's fourteen frames, as addressable states | | `layout.ts` | the profile, and every region's rectangle in cells | -| `render.ts` | those rectangles and that fixture, as `@bomb.sh/tty` operations | +| `render.ts` | the study's colours, glyphs and line arithmetic, as `@bomb.sh/tty` operations | | `screen.ts` | a terminal's cells, reconstructed from the bytes, so a frame can be read back | | `host.ts` | the only module that touches the terminal: modes, raw input, signals, restoration | | `capture.ts` | one frame, away from a terminal, in bytes and in cells | diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 29dd3fb9b..587173fce 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -20,8 +20,16 @@ import type { Motion, Playback } from "./playback.ts"; import type { Fixture } from "./model.ts"; import type { Profile, SurfaceName } from "./layout.ts"; import { layoutFor } from "./layout.ts"; -import { renderScreen } from "./render.ts"; import type { FocusView } from "./render.ts"; +import { paint } from "./paint.ts"; +import { projectFixture } from "./view.ts"; +import type { ReplView } from "./view.ts"; +import { useReplTree } from "./tree.ts"; +import { enterRoute } from "./drive.ts"; +import { hydrate } from "./store.ts"; +import { journalThrough, markerShowing } from "./journal.ts"; +import { formatRoute } from "./route.ts"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; import { applyAnsi, createGrid, gridText } from "./screen.ts"; import { initialView } from "./store.ts"; import type { View } from "./store.ts"; @@ -70,9 +78,60 @@ const MEASURED = [ "too-small", ]; +/** + * One mounted composition: the tree a frame is rendered by, and its view. + * + * A frame is drawn by walking a mounted tree, so a caller that wants frames + * mounts one first. `playFrames` and the journey mount one and reuse it, which + * is also what makes a transition a change to a tree rather than a new one. + */ +export interface Composition { + readonly root: Node; + readonly view: ReplView; +} + +export function useComposition( + subject: Fixture, + view: View, + surface: SurfaceName = view.surface, +): Operation { + return { + *[Symbol.iterator]() { + const open = subject.drawer !== undefined && view.drawerOpen; + const url = formatRoute({ + execution: "e1", + surface, + scopes: [], + drawers: open && subject.drawer !== undefined ? [subject.drawer.kind] : [], + inspect: false, + draft: "", + }); + const state = hydrate(url, journalThrough(markerShowing(subject.name))); + const tree = yield* useReplTree(state); + yield* enterRoute(tree, state); + return { + root: tree.root.node, + view: projectFixture(subject, { + execution: "e1", + surface, + scopes: [], + drawerOpen: open, + inspect: false, + draft: "", + transport: subject.history.transport, + running: subject.entry?.state === "running", + selectedAt: subject.history.checkpoints[view.checkpoint]?.at, + }), + }; + }, + }; +} + export interface FrameRequest { readonly fixture: Fixture; readonly view: View; + /** The mounted composition this frame is drawn by. */ + readonly composition: Composition; readonly size: Size; readonly mutation?: Mutation; readonly surface?: SurfaceName; @@ -94,9 +153,14 @@ export function* useTerm(size: Size): Operation { } /** Render one frame into a fresh terminal, which is always a complete repaint. */ -export function* renderFrame(request: FrameRequest): Operation { +export function* renderFrame(request: Omit): Operation { const term = yield* useTerm(request.size); - return renderInto(term, request); + const composition = yield* useComposition( + request.fixture, + request.view, + request.surface ?? request.view.surface, + ); + return renderInto(term, { ...request, composition }); } /** @@ -115,9 +179,7 @@ export class RendererCapacityError extends Error { export function renderInto(term: Term, request: FrameRequest): Frame { const { view, size, mutation } = request; - // A frame drawn from state the harness has already left behind. The renderer - // cannot tell the difference — only a reader, or a golden, can. - const subject = mutation === "stale-frame" ? fixture("empty") : request.fixture; + const subject = request.fixture; const motion = mutation === "restore-mid-animation" && request.motion === undefined ? // Reconstruction must land on a state, never halfway through a transition. @@ -135,8 +197,32 @@ export function renderInto(term: Term, request: FrameRequest): Frame { surface: request.surface ?? view.surface, mutation, }); + // A frame drawn from state the harness has already left behind. The renderer + // cannot tell the difference — only a reader, or a golden, can. + const shown = + mutation === "stale-frame" + ? projectFixture(fixture("empty"), { + execution: "e1", + surface: "transcript", + scopes: [], + drawerOpen: false, + inspect: false, + draft: "", + transport: "idle", + running: false, + }) + : request.composition.view; + const painted = paint({ + root: request.composition.root, + view: shown, + layout, + anchor: view.anchor, + focus: request.focus, + mutation, + motion, + }); const result = term.render( - renderScreen({ fixture: subject, view, layout, mutation, motion, focus: request.focus }), + painted.ops, request.deltaSeconds === undefined ? {} : { deltaTime: request.deltaSeconds }, ); if (result.errors.length > 0) { @@ -152,7 +238,8 @@ export function renderInto(term: Term, request: FrameRequest): Frame { const grid = applyAnsi(createGrid(size.cols, size.rows), ansi); const bounds: Record = {}; for (const id of MEASURED) { - bounds[id] = result.info.get(id)?.bounds; + const rendered = painted.ids[id]; + bounds[id] = rendered === undefined ? undefined : result.info.get(rendered)?.bounds; } return { ansi, text: gridText(grid), animating: result.animating, bounds }; } @@ -175,6 +262,7 @@ export function* playFrames( const limit = options.limit ?? 200; const subject = fixture(playback.to); const view = initialView(subject); + const composition = yield* useComposition(subject, view); const term = yield* useTerm(size); const frames: Frame[] = []; // A frame in the middle of a transition is a handful of changed cells, not a @@ -187,6 +275,7 @@ export function* playFrames( const frame = renderInto(term, { fixture: subject, view, + composition, size, motion, deltaSeconds: index === 0 ? 0 : frameMs / 1000, @@ -225,14 +314,24 @@ export function* journeyFrames( ): Operation { const frameMs = options.frameMs ?? 16; let term = yield* useTerm(size); + const compositions = new Map(); let rebuilds = 0; const screen = createGrid(size.cols, size.rows); const frames: JourneyFrame[] = []; for (const planned of journeyPlan(JOURNEY, frameMs)) { const subject = fixture(planned.fixture); + const view = initialView(subject); + // One composition per moment, reused across that moment's frames: a + // transition is a change to a mounted tree, never a new one. + let composition = compositions.get(planned.fixture); + if (composition === undefined) { + composition = yield* useComposition(subject, view); + compositions.set(planned.fixture, composition); + } const request: FrameRequest = { fixture: subject, - view: initialView(subject), + view, + composition, size, motion: planned.motion, deltaSeconds: planned.deltaMs / 1000, diff --git a/scripts/repl-study/catalog.ts b/scripts/repl-study/catalog.ts index ac71d7a72..8e59c03ff 100644 --- a/scripts/repl-study/catalog.ts +++ b/scripts/repl-study/catalog.ts @@ -129,7 +129,7 @@ export function renderCatalog(subject: CatalogEntry, profile: Profile): Operatio view: project(state), layout: layoutOf(state, size), anchor: 0, - }), + }).ops, { deltaTime: 0 }, ); if (result.errors.length > 0) { diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index ecf99951b..359f01355 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -166,6 +166,9 @@ export interface TranscriptData { readonly view: TranscriptView; /** The window over a long transcript, which the renderer clips rather than scrolls. */ readonly anchor: number; + readonly mutation?: Mutation; + /** Present only while a playback is running between two moments. */ + readonly motion?: Motion; } export const transcriptBody: Body = ({ self, data, placement, children }) => { @@ -200,13 +203,29 @@ export const transcriptBody: Body = ({ self, data, placement, ch const body = transcriptLines({ ...entry, sourceLines: 0, rows: data.view.rows }, width); const capacity = Math.max(0, rect.height - lines.length); - const windowed = body.slice(data.anchor, data.anchor + Math.max(0, capacity - 1)); + const windowed = + data.mutation === "clip-long-transcript" + ? body + : body.slice(data.anchor, data.anchor + Math.max(0, capacity - 1)); + + // While a playback runs, the target's transcript arrives a few rows at a + // time. This is the application's own interpolation; the renderer is not + // animating anything here. + const arriving = data.motion !== undefined && !data.motion.done; + if (arriving) { + const shown = Math.max(1, Math.ceil(data.motion!.reveal * windowed.length)); + lines.push(...windowed.slice(0, shown), plain("…", C.dim)); + return region(self.id, rect, lines, { bg: BG.center, children }); + } + lines.push(...windowed); - const remaining = body.length - data.anchor - windowed.length; - if (remaining > 0) { - lines.push(plain(`▸ ${remaining} more lines · ↑↓ PgUp PgDn`, C.dim)); - } else if (data.anchor > 0) { - lines.push(plain(`▴ ${data.anchor} earlier lines · ↑ scrolls back`, C.dim)); + if (data.mutation !== "clip-long-transcript") { + const remaining = body.length - data.anchor - windowed.length; + if (remaining > 0) { + lines.push(plain(`▸ ${remaining} more lines · ↑↓ PgUp PgDn`, C.dim)); + } else if (data.anchor > 0) { + lines.push(plain(`▴ ${data.anchor} earlier lines · ↑ scrolls back`, C.dim)); + } } return region(self.id, rect, lines, { bg: BG.center, children }); }; diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 4f25de2fa..5938be1ad 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -30,7 +30,8 @@ import { drive, enterRoute } from "./drive.ts"; import type { HarnessEvent, ReplState, View } from "./store.ts"; import { journalThrough, markerShowing } from "./journal.ts"; import { formatRoute } from "./route.ts"; -import { RendererCapacityError, useTerm } from "./capture.ts"; +import { RendererCapacityError, useComposition, useTerm } from "./capture.ts"; +import type { Composition } from "./capture.ts"; import { renderInto } from "./capture.ts"; import type { Mutation } from "./mutations.ts"; import { @@ -203,6 +204,7 @@ interface Painted { function draw( term: Term, state: HarnessState, + composition: Composition, write: (bytes: Uint8Array) => void, mutation?: Mutation, motion?: Motion, @@ -214,6 +216,7 @@ function draw( const frame = renderInto(term, { fixture: state.fixture, view: state.view, + composition, size: { cols: state.cols, rows: state.rows }, mutation, motion, @@ -407,6 +410,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { let settled = false; let held: string | undefined; let lastPainted = false; + const compositions = new Map(); const currentSegment = (): Segment | undefined => journey === undefined ? undefined : journey[segmentIndex]; @@ -508,9 +512,16 @@ export function* runInteractive(options: InteractiveOptions): Operation { map: overlayOf(tree), overlay: repl.overlay, }; + // The moment on screen has its own mounted composition, kept for as long + // as that moment is shown. + let composition = compositions.get(state.fixture.name); + if (composition === undefined) { + composition = yield* useComposition(state.fixture, state.view); + compositions.set(state.fixture.name, composition); + } let painted: Painted; try { - painted = draw(term, state, write, options.mutation, motion, deltaMs, focus); + painted = draw(term, state, composition, write, options.mutation, motion, deltaMs, focus); } catch (error) { if (!(error instanceof RendererCapacityError)) { throw error; @@ -519,7 +530,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { // wide terminal will do. A new one starts that cache again and repaints // the whole screen, so the person watching sees nothing but a frame. term = yield* useTerm(measured); - painted = draw(term, state, write, options.mutation, motion, 0, focus); + painted = draw(term, state, composition, write, options.mutation, motion, 0, focus); } frames += 1; options.trace?.push({ @@ -719,7 +730,8 @@ export function* runReplay(options: ReplayOptions): Operation { if (options.mutation !== "skip-resize-update") { term.update({ width: state.cols, height: state.rows }); } - draw(term, state, write, options.mutation); + const composition = yield* useComposition(state.fixture, state.view); + draw(term, state, composition, write, options.mutation); drawn += 1; if (options.failAfter !== undefined && drawn >= options.failAfter) { throw new Error("the harness failed while drawing a frame"); diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts index 1c8a21ef1..b2ea9f026 100644 --- a/scripts/repl-study/paint.ts +++ b/scripts/repl-study/paint.ts @@ -38,6 +38,8 @@ import type { Node } from "./vendor/freedom/upstream/index.ts"; import type { ReplView } from "./view.ts"; import type { FocusView } from "./render.ts"; import type { SurfaceName } from "./layout.ts"; +import type { Mutation } from "./mutations.ts"; +import type { Motion } from "./playback.ts"; /** A node that is mounted but not composed at this profile draws nothing. */ const NOWHERE: Rect = { x: 0, y: 0, width: 0, height: 0 }; @@ -98,7 +100,7 @@ function dress(node: Node, request: PaintRequest): void { attach( node, transcriptBody, - { view: view.transcript, anchor }, + { view: view.transcript, anchor, mutation: request.mutation, motion: request.motion }, placed(layout.transcript, layout), ); return; @@ -110,6 +112,10 @@ function dress(node: Node, request: PaintRequest): void { if (name === "region:input") { // While a drawer is open it owns the contextual band, so the input has // nowhere to draw — the parent decides placement, not the child. + // An open drawer owns the band from the first frame of the transition. Its + // *height* is what the transition interpolates — the drawer starts in the + // input's four rows and grows — which is why the layout, not this, decides + // how tall it is. const taken = view.contextual.drawers.length > 0; attach( node, @@ -125,7 +131,18 @@ function dress(node: Node, request: PaintRequest): void { if (drawer !== undefined) { // A drawer takes the contextual band; the input keeps its own node and // simply has nowhere to draw while one is open. - attach(node, drawerBody, { view: drawer }, placed(layout.contextual, layout)); + // The control lets the drawer take the rows the band owns. It is drawn + // after the footer, so extending it is all it takes to cover what the + // study says is never covered. + const covering = + request.mutation === "drawer-covers-footer" && + layout.contextual !== undefined && + layout.footer !== undefined; + const rect = + covering && layout.contextual !== undefined && layout.footer !== undefined + ? { ...layout.contextual, height: layout.contextual.height + layout.footer.height } + : layout.contextual; + attach(node, drawerBody, { view: drawer, focus: request.focus }, placed(rect, layout)); return; } } @@ -134,7 +151,17 @@ function dress(node: Node, request: PaintRequest): void { // is that trap's way out, not a second Execution History. const pane = node.parent?.parent === undefined; if (pane) { - attach(node, historyBody, { view: view.history }, placed(layout.footer, layout)); + attach( + node, + historyBody, + { + view: view.history, + mutation: request.mutation, + motion: request.motion, + focus: request.focus, + }, + placed(layout.footer, layout), + ); return; } } @@ -159,6 +186,9 @@ export interface PaintRequest { /** The transcript window, which the renderer clips rather than scrolls. */ readonly anchor: number; readonly focus?: FocusView; + readonly mutation?: Mutation; + /** Present only while a playback is running between two moments. */ + readonly motion?: Motion; } /** Which of the four routed surfaces the narrow bar names. */ @@ -166,24 +196,59 @@ function surfaceOf(view: ReplView): SurfaceName { return view.surface === "input" ? "transcript" : view.surface; } +/** + * What each measured region was addressed by. + * + * The tree addresses components by node id, because two nodes may share a + * semantic name. Evidence asks about regions by role — "is the footer ever + * covered?" — so the mapping from role to the id actually rendered is reported + * rather than guessed. + */ +export type RenderedIds = Readonly>; + +export interface Painted { + readonly ops: Op[]; + readonly ids: RenderedIds; +} + +const ROLES: Readonly> = { + "region:sessions": "sidebar", + "region:transcript": "transcript", + "region:bindings": "bindings", + "region:input": "contextual", + "region:history": "footer", + "chrome:surface-bar": "surface-bar", + "chrome:header": "header", +}; + /** Hand every mounted node its data, then render the tree. */ -export function paint(request: PaintRequest): Op[] { +export function paint(request: PaintRequest): Painted { const { root, layout } = request; + const ids: Record = { root: root.id }; attach(root, rootBody, undefined, placementOf(layout, layout.screen)); if (layout.profile === "too-small") { // Below the minimum the interface is refused rather than shrunk, so the // panes are not dressed at all — there is nothing for them to be inside. for (const child of root.children) { attach(child, refusalBody, layout, placementOf(layout, layout.screen)); - return walk(child); + return { ops: walk(child), ids: { ...ids, "too-small": child.id } }; } } const visit = (node: Node): void => { for (const child of node.children) { dress(child, request); + const role = ROLES[child.name]; + if (role !== undefined && ids[role] === undefined) { + ids[role] = child.id; + } + if (child.name.startsWith("drawer:")) { + // An open drawer owns the contextual band, so it is what "contextual" + // names while it is there. + ids.contextual = child.id; + } visit(child); } }; visit(root); - return walk(root); + return { ops: walk(root), ids }; } diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 0f3aa5dd2..792819c8e 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -489,178 +489,6 @@ function entryHeader(entry: Entry): VisualLine { }; } -function transcriptRegion( - fixture: Fixture, - view: View, - rect: Rect, - mutation?: Mutation, - motion?: Motion, -): Op[] { - const width = Math.max(0, rect.width - 2); - const lines: VisualLine[] = []; - if (!fixture.entry) { - lines.push(label("TRANSCRIPT"), blank(), plain("No executions yet.", C.dim), blank()); - for (const wrapped of wrapText( - "Submitted blocks append here as immutable entries. Each entry keeps its source, its rendered output, and the bindings it published.", - width, - )) { - lines.push(plain(wrapped, C.dim)); - } - return region("transcript", rect, lines, { bg: BG.center }); - } - - lines.push(entryHeader(fixture.entry), blank()); - const body = transcriptLines(fixture.entry, width); - const capacity = Math.max(0, rect.height - lines.length); - const windowed = - mutation === "clip-long-transcript" - ? body - : body.slice(view.anchor, view.anchor + Math.max(0, capacity - 1)); - - // While a playback runs, the target's transcript arrives a few rows at a - // time. This is the application's own interpolation: the renderer is not - // animating anything here, and when the motion settles every row is present. - const arriving = motion !== undefined && !motion.done; - if (arriving) { - const shown = Math.max(1, Math.ceil(motion.reveal * windowed.length)); - lines.push(...windowed.slice(0, shown)); - lines.push(plain("…", C.dim)); - return region("transcript", rect, lines, { bg: BG.center }); - } - - lines.push(...windowed); - if (mutation !== "clip-long-transcript") { - const remaining = body.length - view.anchor - windowed.length; - if (remaining > 0) { - lines.push(plain(`▸ ${remaining} more lines · ↑↓ PgUp PgDn`, C.dim)); - } else if (view.anchor > 0) { - lines.push(plain(`▴ ${view.anchor} earlier lines · ↑ scrolls back`, C.dim)); - } - } - return region("transcript", rect, lines, { bg: BG.center }); -} - -function sidebarRegion(fixture: Fixture, view: View, layout: Layout, rect: Rect): Op[] { - const lines: VisualLine[] = []; - const tabs = fixture.sidebar.tab; - if (!layout.dense && layout.profile === "wide") { - lines.push(plain("XMD REPL", C.out), blank()); - } - lines.push( - { - segments: [ - { text: "SESSION", color: tabs === "sessions" ? C.intro : C.label, width: 10 }, - { text: "JOURNAL", color: tabs === "journal" ? C.intro : C.label, width: 10 }, - { text: "STATE", color: tabs === "state" ? C.intro : C.label, width: 8 }, - ], - }, - blank(), - ); - - if (fixture.sidebar.heading !== undefined) { - lines.push(label(fixture.sidebar.heading)); - } - if (fixture.sidebar.subheading !== undefined && !layout.dense) { - lines.push(plain(fixture.sidebar.subheading, C.dim)); - } - lines.push(blank()); - - if (tabs === "journal") { - const selected = view.checkpoint; - fixture.history.checkpoints.forEach((point, index) => { - const on = index === selected; - const later = selected >= 0 && index > selected; - lines.push({ - segments: [ - { text: clock(point.at), color: on ? C.gold : C.dim, width: 6 }, - { - text: point.kind === "entry" ? "◆" : "●", - color: on ? C.gold : later ? C.dim : C.active, - width: 2, - }, - { text: point.label, color: on ? C.out : later ? C.dim : C.src }, - ], - }); - }); - lines.push(blank(), plain("▸ 26 internal records", C.dim)); - const chosen = fixture.history.checkpoints[selected]; - if (chosen) { - lines.push( - blank(), - label("SELECTED CHECKPOINT"), - plain(chosen.label, C.out), - plain(`${clock(chosen.at)} elapsed · ${chosen.scope}`, C.dim), - ); - for (const record of chosen.records) { - lines.push(plain(`· ${record}`, C.dim)); - } - } - return region("sidebar", rect, lines, { bg: BG.side }); - } - - if (fixture.sessions.length === 0) { - for (const placeholder of fixture.sidebar.placeholder ?? []) { - for (const wrapped of wrapText(placeholder, Math.max(1, rect.width - 2))) { - lines.push(plain(wrapped, C.dim)); - } - } - return region("sidebar", rect, lines, { bg: BG.side }); - } - - for (const session of fixture.sessions) { - lines.push({ - segments: [ - { text: session.selected ? "│ " : " ", color: C.active, width: 2 }, - { text: session.id, color: session.selected ? C.out : C.name }, - ], - }); - lines.push({ - segments: [ - { text: " ", width: 2 }, - { - text: session.label, - color: - session.state === "active" ? C.active : session.state === "completed" ? C.tick : C.dim, - width: 14, - }, - { text: layout.dense ? session.agent : `${session.agent} · ${session.turn}`, color: C.dim }, - ], - }); - if (session.note !== undefined && !layout.dense) { - lines.push(plain(` ${session.note}`, C.dim)); - } - lines.push(blank()); - } - return region("sidebar", rect, lines, { bg: BG.side }); -} - -function bindingsRegion(fixture: Fixture, layout: Layout, rect: Rect): Op[] { - const lines: VisualLine[] = [ - label("BINDINGS"), - plain(fixture.bindings.scopeName, C.dim), - blank(), - ]; - if (fixture.bindings.bindings.length === 0) { - for (const placeholder of fixture.bindings.placeholder ?? []) { - for (const wrapped of wrapText(placeholder, Math.max(1, rect.width - 2))) { - lines.push(plain(wrapped, C.dim)); - } - } - return region("bindings", rect, lines, { bg: BG.bind }); - } - for (const binding of fixture.bindings.bindings) { - lines.push(plain(binding.name, C.name)); - if (binding.note !== undefined && !layout.dense) { - lines.push(plain(binding.note, C.dim)); - } - for (const value of binding.lines) { - lines.push(plain(value, C.settledText)); - } - lines.push(blank()); - } - return region("bindings", rect, lines, { bg: BG.bind }); -} - /** * One suspension's drawer. * @@ -795,43 +623,6 @@ export function inputRegion(id: string, input: InputView, placement: Placement): return region(id, rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION }); } -/** - * The contextual band as the rectangle path still asks for it. - * - * A shim over the two view-driven regions, so both paths draw the same band - * while both exist. It goes when the rectangle path does. - */ -function contextualRegion( - fixture: Fixture, - view: View, - layout: Layout, - rect: Rect, - focus?: FocusView, -): Op[] { - const placement = placementOf(layout, rect); - if (fixture.drawer && view.drawerOpen) { - return drawerRegion( - "contextual", - drawerViewFrom(fixture.drawer, fixture.readOnly === true), - placement, - focus, - ); - } - return inputRegion( - "contextual", - { - label: fixture.input.label, - hint: fixture.input.hint, - placeholder: fixture.input.placeholder ?? "", - draft: "", - run: fixture.input.runEnabled - ? { id: "control:input.run", label: "Run", enabled: true } - : undefined, - }, - placement, - ); -} - export function clock(seconds: number): string { const minutes = Math.floor(Math.max(0, seconds) / 60); const rest = Math.floor(Math.max(0, seconds) % 60); @@ -1222,21 +1013,6 @@ export function runsOf(glyphs: readonly string[], colors: readonly number[]): Se return segments; } -function headerRegion(fixture: Fixture, rect: Rect): Op[] { - const lines: VisualLine[] = [ - { - segments: [ - { text: fixture.crumb, color: C.label }, - ...(fixture.badge === undefined - ? [] - : [{ text: fixture.badge, color: C.gold, width: [...fixture.badge].length + 2 }]), - ], - }, - blank(), - ]; - return region("header", rect, lines, { bg: BG.center }); -} - export function surfaceBarRegion( id: string, crumb: string, @@ -1281,98 +1057,3 @@ export function tooSmallRegion(id: string, layout: Layout): Op[] { padding: { left: 1, right: 1, top: 1 }, }); } - -export interface ScreenRequest { - readonly fixture: Fixture; - readonly view: View; - readonly layout: Layout; - readonly mutation?: Mutation; - /** Present only while a playback is running between two fixtures. */ - readonly motion?: Motion; - /** Where focus is, and what the overlay would number. */ - readonly focus?: FocusView; -} - -export function renderScreen(request: ScreenRequest): Op[] { - const { fixture, view, layout, mutation, motion, focus } = request; - const ops: Op[] = [ - open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: BG.app }), - ]; - - if (layout.profile === "too-small") { - ops.push(...tooSmallRegion("too-small", layout), close()); - return ops; - } - - if (layout.surfaceBar) { - ops.push( - ...surfaceBarRegion( - "surface-bar", - fixture.crumb, - fixture.badge, - view.surface, - layout.surfaceBar, - ), - ); - } - if (layout.header) { - ops.push(...headerRegion(fixture, layout.header)); - } - if (layout.sidebar) { - ops.push(...sidebarRegion(fixture, view, layout, layout.sidebar)); - } - if (layout.transcript) { - ops.push(...transcriptRegion(fixture, view, layout.transcript, mutation, motion)); - } - if (layout.bindings) { - ops.push(...bindingsRegion(fixture, layout, layout.bindings)); - } - const covering = - mutation === "drawer-covers-footer" && layout.footer !== undefined && view.drawerOpen; - if (layout.contextual && !covering) { - ops.push(...contextualRegion(fixture, view, layout, layout.contextual, focus)); - } - if (layout.footer) { - // The rectangle path draws the band from the same projection the component - // tree does, so the two cannot disagree while both exist. - ops.push( - ...bandRegion( - "footer", - historyViewFrom( - fixture, - fixture.history.transport, - [], - fixture.history.checkpoints[view.checkpoint]?.at, - ), - placementOf(layout, layout.footer), - mutation, - motion, - focus, - ), - ); - } - if (layout.contextual && covering) { - // Drawn last, so it lands on top of the band the study says is never - // covered — which is the point of this control. - ops.push( - ...contextualRegion( - fixture, - view, - layout, - { ...layout.contextual, height: layout.contextual.height + layout.footer!.height }, - focus, - ), - ); - } - for (const [index, separator] of layout.separators.entries()) { - ops.push(...rule(`rule.${index}`, separator, separator.width === 1 ? "│" : "─")); - } - // Focus is drawn last, over the regions it describes, because a marker under - // the thing it marks is a marker nobody sees. - ops.push(...focusMarkerOps("focus-marker", layout, focus)); - if (focus?.overlay === true) { - ops.push(...focusMapRegion("focus-map", layout, focus)); - } - ops.push(close()); - return ops; -} diff --git a/scripts/repl-study/view.ts b/scripts/repl-study/view.ts index 7f9a3e6cd..64356b7a1 100644 --- a/scripts/repl-study/view.ts +++ b/scripts/repl-study/view.ts @@ -263,8 +263,8 @@ function markersOf(state: ReplState): MarkerView[] { * router addresses and what the scrubber's notch height follows. An invisible * helper scope produces no level here, and therefore no marker and no nesting. */ -function scopesOf(state: ReplState): ScopeView[] { - const rows = fixtureFor(state).entry?.rows ?? []; +function scopesFrom(subject: ReturnType): ScopeView[] { + const rows = subject.entry?.rows ?? []; // Built mutably and frozen on the way out: a view is immutable to everything // that receives it, and this is the one place that is true by construction // rather than by everyone remembering. @@ -311,9 +311,8 @@ function scopesOf(state: ReplState): ScopeView[] { return opened.map(settle); } -function controlsOf(state: ReplState): ControlView[] { - const transport = state.moment.transport; - if (state.moment.entry === "none") { +function controlsFor(transport: Moment["transport"], recorded: boolean): ControlView[] { + if (!recorded) { return []; } if (transport === "live") { @@ -342,17 +341,65 @@ function controlsOf(state: ReplState): ControlView[] { * can reach the journal, the store, the tree or the terminal. */ export function project(state: ReplState): ReplView { - const subject = fixtureFor(state); - const route: Route = state.route; - const markers = markersOf(state); - const runnable = state.moment.entry !== "running" && route.draft !== ""; - const drawers = route.drawers.flatMap((kind) => { - const drawer = subject.drawer; - if (drawer === undefined || drawer.kind !== kind) { - return []; - } - return [drawerViewFrom(drawer, route.inspect)]; + return projectFixture(fixtureFor(state), { + execution: state.route.execution, + surface: state.route.surface, + scopes: state.route.scopes, + drawerOpen: state.route.drawers.length > 0, + inspect: state.route.inspect, + draft: state.route.draft, + transport: state.moment.transport, + running: state.moment.entry === "running", + selectedAt: undefined, }); +} + +/** + * What a projection needs beyond the fixture itself. + * + * Kept explicit so the same projector serves a live state and a fixture that + * was never routed to — the #838 captures are of a moment, not of a location, + * and one projector for both is what keeps them the same interface. + */ +export interface ProjectionInputs { + readonly execution: string; + readonly surface: RouteSurface; + readonly scopes: readonly string[]; + readonly drawerOpen: boolean; + readonly inspect: boolean; + readonly draft: string; + readonly transport: Moment["transport"]; + readonly running: boolean; + /** Overrides the fixture's own selected second, for a capture that chose one. */ + readonly selectedAt?: number; +} + +export function projectFixture( + subject: ReturnType, + inputs: ProjectionInputs, +): ReplView { + const route = { + execution: inputs.execution, + surface: inputs.surface, + scopes: inputs.scopes, + inspect: inputs.inspect, + draft: inputs.draft, + }; + const history = historyViewFrom( + subject, + inputs.transport, + controlsFor(inputs.transport, inputs.running || subject.entry !== undefined), + inputs.selectedAt, + ); + const markers = history.markers; + // The button is drawn whenever the fixture offers it. Whether `Run` joins the + // focus ring is a different question, asked of the tree: #838 draws the + // affordance, #839 decides when Tab may land on it. + const runnable = subject.input.runEnabled; + const drawers = + inputs.drawerOpen && subject.drawer !== undefined + ? [drawerViewFrom(subject.drawer, inputs.inspect)] + : []; return { execution: route.execution, surface: route.surface, @@ -386,7 +433,7 @@ export function project(state: ReplState): ReplView { scopeNote: subject.entry.scopeNote, }, rows: subject.entry?.rows ?? [], - scopes: scopesOf(state), + scopes: scopesFrom(subject), placeholder: subject.entry === undefined ? [ @@ -410,7 +457,7 @@ export function project(state: ReplState): ReplView { run: runnable ? { id: "control:input.run", label: "Run", enabled: true } : undefined, }, }, - history: historyViewFrom(subject, state.moment.transport, controlsOf(state)), + history, }; } diff --git a/scripts/tests/fixtures/repl-catalog/empty.narrow.txt b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt index 7403ca82d..1494d60aa 100644 --- a/scripts/tests/fixtures/repl-catalog/empty.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt @@ -24,5 +24,5 @@ empty.narrow · 90 × 28 · An empty REPL, before anything has run - REPL INPUT ⇧⏎ newline [ Run ] + REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-catalog/empty.wide.txt b/scripts/tests/fixtures/repl-catalog/empty.wide.txt index f6b7d2986..e391d0204 100644 --- a/scripts/tests/fixtures/repl-catalog/empty.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/empty.wide.txt @@ -40,7 +40,7 @@ empty.wide · 200 × 50 · An empty REPL, before anything has run │ │ │ │ │ │ - │ REPL INPUT ⇧⏎ newline [ Run ] + │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] │ Enter XMD or invoke a document… │ │ diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index 588ad9420..c5e0d5286 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -43,7 +43,7 @@ function* mounted( const view = project(state); const term = yield* useTerm(WIDE); const result = term.render( - paint({ root: tree.root.node, view, layout: layoutOf(state, WIDE), anchor: 0 }), + paint({ root: tree.root.node, view, layout: layoutOf(state, WIDE), anchor: 0 }).ops, { deltaTime: 0 }, ); expect(result.errors).toEqual([]); diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index 223465a6f..7a4b7306f 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -33,6 +33,7 @@ import { PROFILE_SIZES, renderFrame, renderInto, + useComposition, useTerm, writeCaptures, } from "../repl-study/capture.ts"; @@ -562,6 +563,7 @@ describe("resize", () => { const frame = renderInto(term, { fixture: subject, view: initialView(subject), + composition: yield* useComposition(subject, initialView(subject)), size: told ? step.size : PROFILE_SIZES.wide, mutation: told ? undefined : "skip-resize-update", }); From 77d2f40fd14ef4c7b044ca3a15c0be00741e3f62 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 13:41:04 -0400 Subject: [PATCH 18/57] =?UTF-8?q?=F0=9F=A7=A9=20Render,=20focus=20and=20ta?= =?UTF-8?q?rget=20input=20from=20one=20mounted=20tree=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two architectural blockers from review, both reproduced first. **`runInteractive()` mounted two trees.** One for focus and input, another for rendering, each internally consistent and each looking right on its own — `same object? false`. `composeInto()` now takes the tree it composes into and brings it to the moment being shown; nothing calls `useReplTree()` a second time. `useComposition()` remains for a caller that owns the whole composition — a capture, a playback, the replay — where mounting one tree is exactly right. `second-tree` is the control, and it fails the identity assertion. **`paint.ts` dispatched presentation globally by node name** while holding the whole `ReplView` and the whole layout. That is the flat-registry shape again: one place outside the tree decided what every node renders. Presentation is the tree's now — the root is a parent, and it hands each of its own direct children that child's own view subtree and the placement it allows. `paint` is the walk and a map from the roles evidence asks about to the ids that drew them. Evidence added: rendering, focus, input targeting and the overlay all resolve to the same root *object*; a parent's reconciliation keeps unchanged children; and a render body receives exactly `self`, `data`, `placement` and `children` — its own data, never the root view, and never a node it could mutate topology with. No #838 or #839 golden moved, and no catalog capture moved. 40 tests, 144 steps, lint, check and diff clean. --- scripts/repl-study/capture.ts | 65 ++++++- scripts/repl-study/catalog.ts | 7 +- scripts/repl-study/host.ts | 17 +- scripts/repl-study/mutations.ts | 2 + scripts/repl-study/paint.ts | 248 ++++---------------------- scripts/repl-study/tree.ts | 159 +++++++++++++++++ scripts/tests/repl-components.test.ts | 107 ++++++++++- scripts/tests/repl-study.test.ts | 9 +- 8 files changed, 363 insertions(+), 251 deletions(-) diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 587173fce..f2417574e 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -25,6 +25,7 @@ import { paint } from "./paint.ts"; import { projectFixture } from "./view.ts"; import type { ReplView } from "./view.ts"; import { useReplTree } from "./tree.ts"; +import type { ReplTree } from "./tree.ts"; import { enterRoute } from "./drive.ts"; import { hydrate } from "./store.ts"; import { journalThrough, markerShowing } from "./journal.ts"; @@ -86,17 +87,34 @@ const MEASURED = [ * is also what makes a transition a change to a tree rather than a new one. */ export interface Composition { + /** The one mounted tree this frame is rendered by. */ + readonly tree: ReplTree; readonly root: Node; readonly view: ReplView; } -export function useComposition( +/** + * Compose a moment **into an already mounted tree**. + * + * The root is supplied rather than created. One mounted tree answers rendering, + * focus, scoped input and the overlay; a second tree mounted for rendering + * alone would be the parallel hierarchy this architecture exists to remove, and + * nothing would notice because both would look right on their own. + */ +export function composeInto( + tree: ReplTree, subject: Fixture, view: View, surface: SurfaceName = view.surface, + mutation?: Mutation, ): Operation { return { *[Symbol.iterator]() { + if (mutation === "second-tree") { + // The control: render from a tree of its own. Each tree looks right on + // its own, which is exactly why nothing notices without this check. + return yield* useComposition(subject, view, surface); + } const open = subject.drawer !== undefined && view.drawerOpen; const url = formatRoute({ execution: "e1", @@ -107,9 +125,12 @@ export function useComposition( draft: "", }); const state = hydrate(url, journalThrough(markerShowing(subject.name))); - const tree = yield* useReplTree(state); + // The tree is brought to this moment rather than replaced by one that + // already describes it. + yield* tree.sync(state); yield* enterRoute(tree, state); return { + tree, root: tree.root.node, view: projectFixture(subject, { execution: "e1", @@ -213,13 +234,11 @@ export function renderInto(term: Term, request: FrameRequest): Frame { }) : request.composition.view; const painted = paint({ - root: request.composition.root, + tree: request.composition.tree, view: shown, layout, anchor: view.anchor, - focus: request.focus, - mutation, - motion, + options: { focus: request.focus, mutation, motion }, }); const result = term.render( painted.ops, @@ -492,3 +511,37 @@ export function* captureFocus(): Operation { } return captures; } + +/** + * A moment, with a tree of its own. + * + * For a caller that owns the whole composition — a capture, a playback — where + * mounting one tree is exactly right. A caller that already has a tree uses + * `composeInto` so that one tree keeps answering everything. + */ +export function useComposition( + subject: Fixture, + view: View, + surface: SurfaceName = view.surface, +): Operation { + return { + *[Symbol.iterator]() { + const state = hydrate( + formatRoute({ + execution: "e1", + surface, + scopes: [], + drawers: + subject.drawer !== undefined && view.drawerOpen && subject.drawer !== undefined + ? [subject.drawer.kind] + : [], + inspect: false, + draft: "", + }), + journalThrough(markerShowing(subject.name)), + ); + const tree = yield* useReplTree(state); + return yield* composeInto(tree, subject, view, surface); + }, + }; +} diff --git a/scripts/repl-study/catalog.ts b/scripts/repl-study/catalog.ts index 8e59c03ff..f54021e5c 100644 --- a/scripts/repl-study/catalog.ts +++ b/scripts/repl-study/catalog.ts @@ -124,12 +124,7 @@ export function renderCatalog(subject: CatalogEntry, profile: Profile): Operatio yield* enterRoute(tree, state); const term = yield* useTerm(size); const result = term.render( - paint({ - root: tree.root.node, - view: project(state), - layout: layoutOf(state, size), - anchor: 0, - }).ops, + paint({ tree, view: project(state), layout: layoutOf(state, size) }).ops, { deltaTime: 0 }, ); if (result.errors.length > 0) { diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 5938be1ad..e3dd5dd75 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -30,7 +30,7 @@ import { drive, enterRoute } from "./drive.ts"; import type { HarnessEvent, ReplState, View } from "./store.ts"; import { journalThrough, markerShowing } from "./journal.ts"; import { formatRoute } from "./route.ts"; -import { RendererCapacityError, useComposition, useTerm } from "./capture.ts"; +import { composeInto, RendererCapacityError, useComposition, useTerm } from "./capture.ts"; import type { Composition } from "./capture.ts"; import { renderInto } from "./capture.ts"; import type { Mutation } from "./mutations.ts"; @@ -410,7 +410,6 @@ export function* runInteractive(options: InteractiveOptions): Operation { let settled = false; let held: string | undefined; let lastPainted = false; - const compositions = new Map(); const currentSegment = (): Segment | undefined => journey === undefined ? undefined : journey[segmentIndex]; @@ -512,13 +511,11 @@ export function* runInteractive(options: InteractiveOptions): Operation { map: overlayOf(tree), overlay: repl.overlay, }; - // The moment on screen has its own mounted composition, kept for as long - // as that moment is shown. - let composition = compositions.get(state.fixture.name); - if (composition === undefined) { - composition = yield* useComposition(state.fixture, state.view); - compositions.set(state.fixture.name, composition); - } + // The moment on screen is composed **into the harness's one tree**, so + // rendering, focus, scoped input and the overlay all come off the same + // mounted object. A second tree for rendering would look right on its own + // and be a parallel hierarchy. + const composition = yield* composeInto(tree, state.fixture, state.view); let painted: Painted; try { painted = draw(term, state, composition, write, options.mutation, motion, deltaMs, focus); @@ -730,6 +727,8 @@ export function* runReplay(options: ReplayOptions): Operation { if (options.mutation !== "skip-resize-update") { term.update({ width: state.cols, height: state.rows }); } + // The replay owns its whole composition, so mounting one tree here is + // exactly right — there is no other tree for it to be a second of. const composition = yield* useComposition(state.fixture, state.view); draw(term, state, composition, write, options.mutation); drawn += 1; diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index 4a0142f37..fb1572e45 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -32,6 +32,8 @@ export const MUTATIONS = [ "steal-focus-on-background", /** Rebuild the whole tree on every sync instead of reconciling it. */ "rebuild-tree-each-sync", + /** Render from a second mounted tree instead of the one focus and input use. */ + "second-tree", /** Leave a replaced control wherever it was appended, losing canonical order. */ "append-replacements", /** Close a drawer without removing its branch, so its controls survive. */ diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts index b2ea9f026..65306c96e 100644 --- a/scripts/repl-study/paint.ts +++ b/scripts/repl-study/paint.ts @@ -1,209 +1,21 @@ /** - * One downward pass: every mounted node is handed its own slice of the view. + * Render the mounted tree, and report which id drew each measured role. * - * This is the "data down" half. The projector above the tree produces one - * immutable `ReplView`; this walks the mounted nodes and gives each the subtree - * it owns together with the box its parent allows it. Nothing is rebuilt — a - * node that was already mounted keeps its identity, its focus, its middleware - * and its generator-local state, and only the data it renders changes. - * - * Rendering is then `walk()` over that same tree, each parent wrapping what its - * children already produced. Rendering, focus order, the scoped input path and - * the `F1` overlay therefore all come off one structure, which is the whole - * claim #840 makes. + * Presentation is the tree's: each parent hands its own children their data and + * placement. What is left here is the walk, and a map from the roles evidence + * asks about — "is the footer ever covered?" — to the ids that actually + * rendered them, because a component is addressed by its node's id and two + * nodes may share a semantic name. */ import type { Op } from "@bomb.sh/tty"; -import { attach, placementOf, walk } from "./component.ts"; -import type { Placement } from "./component.ts"; -import { - bindingsBody, - drawerBody, - focusMapBody, - focusMarkerBody, - refusalBody, - rulesBody, - surfaceBarBody, - headerBody, - historyBody, - inputBody, - outletBody, - rootBody, - sessionsBody, - transcriptBody, -} from "./components.ts"; -import type { Layout, Rect } from "./layout.ts"; +import { walk } from "./component.ts"; +import type { Layout } from "./layout.ts"; import type { Node } from "./vendor/freedom/upstream/index.ts"; +import type { PresentOptions, ReplTree } from "./tree.ts"; import type { ReplView } from "./view.ts"; -import type { FocusView } from "./render.ts"; -import type { SurfaceName } from "./layout.ts"; -import type { Mutation } from "./mutations.ts"; -import type { Motion } from "./playback.ts"; - -/** A node that is mounted but not composed at this profile draws nothing. */ -const NOWHERE: Rect = { x: 0, y: 0, width: 0, height: 0 }; - -function placed(rect: Rect | undefined, layout: Layout): Placement { - return placementOf(layout, rect ?? NOWHERE); -} - -/** - * Give one node the body and the data it owns. - * - * Attaching is idempotent and keeps the node, so this runs every frame: the - * alternative — attaching once and mutating a captured reference — would make - * "the data a component rendered" a thing two places could answer. - */ -function dress(node: Node, request: PaintRequest): void { - const { view, layout, anchor } = request; - const name = node.name; - if (name === "chrome:surface-bar") { - attach( - node, - surfaceBarBody, - { - crumb: view.crumb, - badge: view.badge, - surface: surfaceOf(view), - }, - placed(layout.surfaceBar, layout), - ); - return; - } - if (name === "chrome:rules") { - attach(node, rulesBody, layout.separators, placed(layout.screen, layout)); - return; - } - if (name === "chrome:focus-marker") { - attach(node, focusMarkerBody, { layout, focus: request.focus }, placed(layout.screen, layout)); - return; - } - if (name === "chrome:focus-map") { - attach(node, focusMapBody, { layout, focus: request.focus }, placed(layout.screen, layout)); - return; - } - if (name === "chrome:header") { - attach( - node, - headerBody, - { crumb: view.crumb, badge: view.badge }, - placed(layout.header, layout), - ); - return; - } - if (name === "region:sessions") { - attach(node, sessionsBody, view.sessions, placed(layout.sidebar, layout)); - return; - } - if (name === "region:transcript") { - attach( - node, - transcriptBody, - { view: view.transcript, anchor, mutation: request.mutation, motion: request.motion }, - placed(layout.transcript, layout), - ); - return; - } - if (name === "region:bindings") { - attach(node, bindingsBody, view.bindings, placed(layout.bindings, layout)); - return; - } - if (name === "region:input") { - // While a drawer is open it owns the contextual band, so the input has - // nowhere to draw — the parent decides placement, not the child. - // An open drawer owns the band from the first frame of the transition. Its - // *height* is what the transition interpolates — the drawer starts in the - // input's four rows and grows — which is why the layout, not this, decides - // how tall it is. - const taken = view.contextual.drawers.length > 0; - attach( - node, - inputBody, - view.contextual.input, - placed(taken ? undefined : layout.contextual, layout), - ); - return; - } - if (name.startsWith("drawer:")) { - const kind = name.slice("drawer:".length); - const drawer = view.contextual.drawers.find((candidate) => candidate.kind === kind); - if (drawer !== undefined) { - // A drawer takes the contextual band; the input keeps its own node and - // simply has nowhere to draw while one is open. - // The control lets the drawer take the rows the band owns. It is drawn - // after the footer, so extending it is all it takes to cover what the - // study says is never covered. - const covering = - request.mutation === "drawer-covers-footer" && - layout.contextual !== undefined && - layout.footer !== undefined; - const rect = - covering && layout.contextual !== undefined && layout.footer !== undefined - ? { ...layout.contextual, height: layout.contextual.height + layout.footer.height } - : layout.contextual; - attach(node, drawerBody, { view: drawer, focus: request.focus }, placed(rect, layout)); - return; - } - } - if (name === "region:history") { - // Only the pane draws the band. The identically-named node inside a drawer - // is that trap's way out, not a second Execution History. - const pane = node.parent?.parent === undefined; - if (pane) { - attach( - node, - historyBody, - { - view: view.history, - mutation: request.mutation, - motion: request.motion, - focus: request.focus, - }, - placed(layout.footer, layout), - ); - return; - } - } - if (name === "header") { - attach( - node, - headerBody, - { crumb: view.crumb, badge: view.badge }, - placed(layout.header, layout), - ); - return; - } - // Panels, drawers and controls are structural for now: they own ancestry, - // focus and input, and contribute their children's operations unchanged. - attach(node, outletBody, undefined, placed(undefined, layout)); -} - -export interface PaintRequest { - readonly root: Node; - readonly view: ReplView; - readonly layout: Layout; - /** The transcript window, which the renderer clips rather than scrolls. */ - readonly anchor: number; - readonly focus?: FocusView; - readonly mutation?: Mutation; - /** Present only while a playback is running between two moments. */ - readonly motion?: Motion; -} - -/** Which of the four routed surfaces the narrow bar names. */ -function surfaceOf(view: ReplView): SurfaceName { - return view.surface === "input" ? "transcript" : view.surface; -} -/** - * What each measured region was addressed by. - * - * The tree addresses components by node id, because two nodes may share a - * semantic name. Evidence asks about regions by role — "is the footer ever - * covered?" — so the mapping from role to the id actually rendered is reported - * rather than guessed. - */ export type RenderedIds = Readonly>; export interface Painted { @@ -221,34 +33,34 @@ const ROLES: Readonly> = { "chrome:header": "header", }; -/** Hand every mounted node its data, then render the tree. */ +export interface PaintRequest { + readonly tree: ReplTree; + readonly view: ReplView; + readonly layout: Layout; + readonly anchor?: number; + readonly options?: PresentOptions; +} + export function paint(request: PaintRequest): Painted { - const { root, layout } = request; + const { tree, view, layout } = request; + tree.present(view, layout, { anchor: request.anchor ?? 0, ...request.options }); + const root: Node = tree.root.node; const ids: Record = { root: root.id }; - attach(root, rootBody, undefined, placementOf(layout, layout.screen)); if (layout.profile === "too-small") { - // Below the minimum the interface is refused rather than shrunk, so the - // panes are not dressed at all — there is nothing for them to be inside. for (const child of root.children) { - attach(child, refusalBody, layout, placementOf(layout, layout.screen)); return { ops: walk(child), ids: { ...ids, "too-small": child.id } }; } } - const visit = (node: Node): void => { - for (const child of node.children) { - dress(child, request); - const role = ROLES[child.name]; - if (role !== undefined && ids[role] === undefined) { - ids[role] = child.id; - } - if (child.name.startsWith("drawer:")) { - // An open drawer owns the contextual band, so it is what "contextual" - // names while it is there. - ids.contextual = child.id; - } - visit(child); + for (const child of root.children) { + const role = ROLES[child.name]; + if (role !== undefined && ids[role] === undefined) { + ids[role] = child.id; } - }; - visit(root); + if (child.name.startsWith("drawer:")) { + // An open drawer owns the contextual band, so it is what "contextual" + // names while it is there. + ids.contextual = child.id; + } + } return { ops: walk(root), ids }; } diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 17aaa3cc0..984bbd4f5 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -37,6 +37,28 @@ import type { Node, PopFocus, Root } from "./vendor/freedom/upstream/index.ts"; import { drawerTargets, labelFor } from "./surfaces.ts"; import { recordPath } from "./keys.ts"; +import { attach, placementOf } from "./component.ts"; +import type { Placement } from "./component.ts"; +import { + bindingsBody, + drawerBody, + focusMapBody, + focusMarkerBody, + headerBody, + historyBody, + inputBody, + outletBody, + refusalBody, + rootBody, + rulesBody, + sessionsBody, + surfaceBarBody, + transcriptBody, +} from "./components.ts"; +import type { FocusView } from "./render.ts"; +import type { Motion } from "./playback.ts"; +import type { ReplView } from "./view.ts"; +import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; import type { ReplState } from "./store.ts"; import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; @@ -89,6 +111,15 @@ export interface ReplTree { readonly root: Root; /** Bring the interface into line with a state, mounting and removing branches. */ sync(state: ReplState, mutation?: Mutation): Operation; + /** + * Hand every direct child its own view subtree and its placement. + * + * The root is a parent, so this is the root doing what every parent does. It + * reaches its *own* children and no further: a drawer presents its own + * contents, and no node's data is chosen by something walking the whole tree + * from outside. + */ + present(view: ReplView, layout: Layout, options?: PresentOptions): void; /** Where focus is, asked of the tree. */ focused(): Node; advance(): void; @@ -99,6 +130,13 @@ export interface ReplTree { chain(): Node[]; } +export interface PresentOptions { + readonly anchor?: number; + readonly mutation?: Mutation; + readonly motion?: Motion; + readonly focus?: FocusView; +} + interface Mounted { readonly node: Node; readonly pop?: PopFocus; @@ -347,6 +385,21 @@ export function useReplTree(state: ReplState): Operation { advance(root.node); } }, + present(view, layout, options = {}) { + const place = (rect: Rect | undefined): Placement => placementOf(layout, rect); + attach(root.node, rootBody, undefined, place(layout.screen)); + if (layout.profile === "too-small") { + // Below the minimum the interface is refused rather than shrunk, so + // the panes are not presented at all. + for (const child of root.node.children) { + attach(child, refusalBody, layout, place(layout.screen)); + return; + } + } + for (const child of root.node.children) { + presentChild(child, view, layout, place, options); + } + }, focused: () => current(root.node), advance: () => advance(root.node), retreat: () => retreat(root.node), @@ -455,3 +508,109 @@ export function overlayOf(tree: ReplTree, mutation?: Mutation): readonly Overlay number: at + 1, })); } + +/** + * One direct child of the root, given the data it owns. + * + * Each branch below this presents its own contents: the drawer draws its form + * from its own `DrawerView`, and nothing here reaches past a child to choose + * what a grandchild renders. + */ +function presentChild( + child: Node, + view: ReplView, + layout: Layout, + place: (rect: Rect | undefined) => Placement, + options: PresentOptions, +): void { + const name = child.name; + if (name === "region:sessions") { + attach(child, sessionsBody, view.sessions, place(layout.sidebar)); + return; + } + if (name === "region:transcript") { + attach( + child, + transcriptBody, + { + view: view.transcript, + anchor: options.anchor ?? 0, + mutation: options.mutation, + motion: options.motion, + }, + place(layout.transcript), + ); + return; + } + if (name === "region:bindings") { + attach(child, bindingsBody, view.bindings, place(layout.bindings)); + return; + } + if (name === "region:input") { + // An open drawer owns the contextual band; the input keeps its node and + // simply has nowhere to draw. + const taken = view.contextual.drawers.length > 0; + attach(child, inputBody, view.contextual.input, place(taken ? undefined : layout.contextual)); + return; + } + if (name === "region:history") { + attach( + child, + historyBody, + { + view: view.history, + mutation: options.mutation, + motion: options.motion, + focus: options.focus, + }, + place(layout.footer), + ); + return; + } + if (name.startsWith("drawer:")) { + const kind = name.slice("drawer:".length); + const drawer = view.contextual.drawers.find((candidate) => candidate.kind === kind); + if (drawer !== undefined) { + const covering = + options.mutation === "drawer-covers-footer" && + layout.contextual !== undefined && + layout.footer !== undefined; + const rect = + covering && layout.contextual !== undefined && layout.footer !== undefined + ? { ...layout.contextual, height: layout.contextual.height + layout.footer.height } + : layout.contextual; + attach(child, drawerBody, { view: drawer, focus: options.focus }, place(rect)); + return; + } + } + if (name === "chrome:surface-bar") { + attach( + child, + surfaceBarBody, + { + crumb: view.crumb, + badge: view.badge, + surface: view.surface === "input" ? "transcript" : view.surface, + }, + place(layout.surfaceBar), + ); + return; + } + if (name === "chrome:header") { + attach(child, headerBody, { crumb: view.crumb, badge: view.badge }, place(layout.header)); + return; + } + if (name === "chrome:rules") { + attach(child, rulesBody, layout.separators, place(layout.screen)); + return; + } + if (name === "chrome:focus-marker") { + attach(child, focusMarkerBody, { layout, focus: options.focus }, place(layout.screen)); + return; + } + if (name === "chrome:focus-map") { + attach(child, focusMapBody, { layout, focus: options.focus }, place(layout.screen)); + return; + } + attach(child, outletBody, undefined, place(undefined)); +} diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index c5e0d5286..1f1b28bdf 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -18,11 +18,14 @@ import { readTextFile } from "@effectionx/fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { attach, walk } from "../repl-study/component.ts"; -import { hydrate, layoutOf } from "../repl-study/store.ts"; +import type { Node } from "../repl-study/vendor/freedom/upstream/index.ts"; +import { hydrate, initialView, layoutOf } from "../repl-study/store.ts"; +import { composeInto } from "../repl-study/capture.ts"; +import { fixture } from "../repl-study/fixtures.ts"; import { journalThrough } from "../repl-study/journal.ts"; import { paint } from "../repl-study/paint.ts"; import { applyAnsi, createGrid, gridText } from "../repl-study/screen.ts"; -import { useReplTree } from "../repl-study/tree.ts"; +import { overlayOf, useReplTree } from "../repl-study/tree.ts"; import { enterRoute } from "../repl-study/drive.ts"; import { sendKey } from "../repl-study/keys.ts"; import { indexOf, project } from "../repl-study/view.ts"; @@ -42,10 +45,9 @@ function* mounted( const tree = yield* useReplTree(state); const view = project(state); const term = yield* useTerm(WIDE); - const result = term.render( - paint({ root: tree.root.node, view, layout: layoutOf(state, WIDE), anchor: 0 }).ops, - { deltaTime: 0 }, - ); + const result = term.render(paint({ tree, view, layout: layoutOf(state, WIDE) }).ops, { + deltaTime: 0, + }); expect(result.errors).toEqual([]); const grid = applyAnsi(createGrid(WIDE.cols, WIDE.rows), Uint8Array.from(result.output)); return { view, screen: gridText(grid), chain: tree.chain().map((node) => node.name) }; @@ -166,8 +168,8 @@ describe("a component is a body on a node", () => { const before = node.id; const view = project(state); const layout = layoutOf(state, WIDE); - paint({ root: tree.root.node, view, layout, anchor: 0 }); - paint({ root: tree.root.node, view, layout, anchor: 12 }); + paint({ tree, view, layout, anchor: 0 }); + paint({ tree, view, layout, anchor: 12 }); const after = tree.chain().find((candidate) => candidate.name === "region:transcript")!; expect(after.id).toBe(before); expect(after).toBe(node); @@ -307,3 +309,92 @@ describe("a recorded drawer keeps its presentation and loses its actionability", expect(tree.chain()).toContain(tree.focused()); }); }); + +describe("one mounted tree answers everything", () => { + function* harness() { + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const tree = yield* useReplTree(state); + yield* enterRoute(tree, state); + const subject = fixture("nested"); + const composition = yield* composeInto(tree, subject, initialView(subject)); + return { tree, composition }; + } + + it("renders, focuses, targets input and numbers the overlay from one root", function* () { + const { tree, composition } = yield* harness(); + // Object identity, not equality: two trees would each be internally + // consistent and both would look right on their own. + expect(composition.tree).toBe(tree); + expect(composition.root).toBe(tree.root.node); + + const rootOf = (node: { parent?: unknown }) => { + let at = node; + while (at.parent) { + at = at.parent as { parent?: unknown }; + } + return at; + }; + expect(rootOf(tree.focused())).toBe(tree.root.node); + for (const target of tree.chain()) { + expect(rootOf(target)).toBe(tree.root.node); + } + const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + expect(delivery.target).toBe(tree.focused().name); + // The overlay is the same tree walked, so every entry names a node in it. + const names = new Set(walkNames(tree.root.node)); + for (const entry of overlayOf(tree)) { + expect({ id: entry.id, inTree: names.has(entry.id) }).toEqual({ id: entry.id, inTree: true }); + } + }); + + it("is rejected when rendering uses a tree of its own", function* () { + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const tree = yield* useReplTree(state); + const subject = fixture("nested"); + const split = yield* composeInto(tree, subject, initialView(subject), undefined, "second-tree"); + expect(split.root).not.toBe(tree.root.node); + expect(split.tree).not.toBe(tree); + }); + + it("keeps unchanged children when a parent reconciles", function* () { + const { tree } = yield* harness(); + const before = new Map(tree.chain().map((node) => [node.name, node] as const)); + const next = hydrate("xmd://repl/e1/bindings/entry-1/document", journalThrough("cp-14")); + yield* tree.sync(next); + for (const [name, node] of before) { + const after = tree.chain().find((candidate) => candidate.name === name); + if (after !== undefined) { + expect({ name, same: after === node }).toEqual({ name, same: true }); + } + } + }); + + it("hands a render body no node and no root view", function* () { + const { tree } = yield* harness(); + const node = tree.root.node.createChild("probe:body"); + let seen: Record = {}; + attach( + node, + (context) => { + seen = { ...context }; + return []; + }, + { only: "mine" }, + { + rect: { x: 0, y: 0, width: 1, height: 1 }, + dense: false, + profile: "wide", + }, + ); + walk(node); + expect(Object.keys(seen).toSorted()).toEqual(["children", "data", "placement", "self"]); + // Its own data, not the projection every other component was built from. + expect(seen.data).toEqual({ only: "mine" }); + expect(Object.keys(seen.self as object).toSorted()).toEqual(["id", "name"]); + }); +}); + +/** Every node's name, for checking the overlay against the tree it came from. */ +function walkNames(node: Node): string[] { + return [node.name, ...[...node.children].flatMap(walkNames)]; +} diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index 7a4b7306f..a946b0ab8 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -802,10 +802,11 @@ describe("the boundary this experiment keeps", () => { it("uses every control it declares", function* () { // A control nobody passes is a claim nobody is checking, so the evidence's - // own source has to mention each one. #838's controls are exercised here - // and #839's next door; the declaration is one list, so the check reads - // both suites rather than letting either half go unclaimed. - const suites = ["./repl-study.test.ts", "./repl-focus.test.ts"]; + // own source has to mention each one. #838's controls are exercised here, + // #839's next door and #840's beside them; the declaration is one list, so + // the check reads every suite rather than letting any of them go + // unclaimed. + const suites = ["./repl-study.test.ts", "./repl-focus.test.ts", "./repl-components.test.ts"]; const sources: string[] = []; for (const suite of suites) { sources.push(yield* readTextFile(fileURLToPath(new URL(suite, import.meta.url)))); From 4123c99bbf2d2204c36cb28ad6988a235ea18a92 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 13:51:07 -0400 Subject: [PATCH 19/57] =?UTF-8?q?=F0=9F=A9=B9=20Stop=20a=20repaint=20decid?= =?UTF-8?q?ing=20where=20the=20person=20is=20(#840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced before anything changed. Composing a moment hydrated a **synthetic state from a fabricated URL**, synced the tree to it and entered its route — on every repaint. Someone who tabbed to the bindings pane had focus dragged back to the transcript by the next frame: ```text focus after Tab : region:bindings focus after repaint : region:transcript ``` In the interactive harness that runs every frame, so focus was unusable and nothing noticed: the composition looked right, and so did the tree. `composeInto()` is projection only, and synchronous. It reads the fixture and returns the view; it mounts nothing, syncs nothing and focuses nothing. Topology belongs to the store's own sync and focus belongs to the person — drawing may read both and change neither. The per-repaint sync was load-bearing for one thing: while the journey projects, the moment on screen is not the store's, and without a sync the drawer branch never mounted, so its declared transition never ran and the study stopped animating. That sync happens once **when the moment changes**, never on a repaint, and never touches focus. **The second-tree oracle was dishonest.** It asserted the control's own construction — that two roots differ — which the control cannot fail. Node ids cannot tell two trees apart either, because each counts from one. The oracle now asks the observable question the honest run also answers: after focus moves, does what rendered agree with where focus is? One tree says `region:bindings`; two trees disagree. No golden moved. 40 tests, 145 steps, lint, check and diff clean. --- scripts/repl-study/capture.ts | 97 +++++++++++++++------------ scripts/repl-study/host.ts | 27 +++++++- scripts/tests/repl-components.test.ts | 56 ++++++++++++++-- 3 files changed, 133 insertions(+), 47 deletions(-) diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index f2417574e..69f619f75 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -96,55 +96,64 @@ export interface Composition { /** * Compose a moment **into an already mounted tree**. * - * The root is supplied rather than created. One mounted tree answers rendering, - * focus, scoped input and the overlay; a second tree mounted for rendering - * alone would be the parallel hierarchy this architecture exists to remove, and - * nothing would notice because both would look right on their own. + * Projection only, and synchronous. It reads the fixture and returns the view; + * it mounts nothing, syncs nothing and focuses nothing. + * + * That is the whole correction. Composing used to hydrate a synthetic state + * from a fabricated URL, sync the tree to it and enter its route — on every + * repaint. A person who tabbed to the bindings pane had focus dragged back to + * the transcript by the next frame, because a repaint was quietly re-deciding + * where they were. Topology belongs to the store's own sync and focus belongs + * to the person; drawing is allowed to read both and change neither. + */ +/** + * A second mounted tree, for the control that renders from one. + * + * Mounted lazily and kept, so the control is a *different* tree rather than a + * fresh one each call — which is what a parallel rendering hierarchy would + * actually be. */ +let foreign: Composition | undefined; + +export function useForeignTree(subject: Fixture, view: View): Operation { + return { + *[Symbol.iterator]() { + foreign = yield* useComposition(subject, view); + return foreign; + }, + }; +} + export function composeInto( tree: ReplTree, subject: Fixture, view: View, surface: SurfaceName = view.surface, mutation?: Mutation, -): Operation { +): Composition { + if (mutation === "second-tree") { + // The control: render from a tree of its own. Each tree is internally + // consistent, which is exactly why nothing notices without an oracle that + // asks whether the ids rendered belong to the tree focus came from. + if (foreign === undefined) { + throw new Error("the second-tree control needs its foreign tree mounted first"); + } + return foreign; + } return { - *[Symbol.iterator]() { - if (mutation === "second-tree") { - // The control: render from a tree of its own. Each tree looks right on - // its own, which is exactly why nothing notices without this check. - return yield* useComposition(subject, view, surface); - } - const open = subject.drawer !== undefined && view.drawerOpen; - const url = formatRoute({ - execution: "e1", - surface, - scopes: [], - drawers: open && subject.drawer !== undefined ? [subject.drawer.kind] : [], - inspect: false, - draft: "", - }); - const state = hydrate(url, journalThrough(markerShowing(subject.name))); - // The tree is brought to this moment rather than replaced by one that - // already describes it. - yield* tree.sync(state); - yield* enterRoute(tree, state); - return { - tree, - root: tree.root.node, - view: projectFixture(subject, { - execution: "e1", - surface, - scopes: [], - drawerOpen: open, - inspect: false, - draft: "", - transport: subject.history.transport, - running: subject.entry?.state === "running", - selectedAt: subject.history.checkpoints[view.checkpoint]?.at, - }), - }; - }, + tree, + root: tree.root.node, + view: projectFixture(subject, { + execution: "e1", + surface, + scopes: [], + drawerOpen: subject.drawer !== undefined && view.drawerOpen, + inspect: false, + draft: "", + transport: subject.history.transport, + running: subject.entry?.state === "running", + selectedAt: subject.history.checkpoints[view.checkpoint]?.at, + }), }; } @@ -541,7 +550,11 @@ export function useComposition( journalThrough(markerShowing(subject.name)), ); const tree = yield* useReplTree(state); - return yield* composeInto(tree, subject, view, surface); + // A caller that owns the whole composition brings its tree to the moment + // once, at mount. A repaint never does this. + yield* tree.sync(state); + yield* enterRoute(tree, state); + return composeInto(tree, subject, view, surface); }, }; } diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index e3dd5dd75..4788e6fe5 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -303,6 +303,21 @@ export function openingState(options: { return { ...routed, overlay: options.focusMap === true }; } +/** The topology one of the six moments needs mounted, without its focus. */ +function momentState(subject: Fixture): ReplState { + return hydrate( + formatRoute({ + execution: "e1", + surface: "transcript", + scopes: [], + drawers: subject.drawer === undefined ? [] : [subject.drawer.kind], + inspect: false, + draft: "", + }), + journalThrough(markerShowing(subject.name)), + ); +} + export interface InteractiveOptions { readonly fixture: FixtureName; readonly mutation?: Mutation; @@ -410,6 +425,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { let settled = false; let held: string | undefined; let lastPainted = false; + let composedFor: string | undefined; const currentSegment = (): Segment | undefined => journey === undefined ? undefined : journey[segmentIndex]; @@ -515,7 +531,16 @@ export function* runInteractive(options: InteractiveOptions): Operation { // rendering, focus, scoped input and the overlay all come off the same // mounted object. A second tree for rendering would look right on its own // and be a parallel hierarchy. - const composition = yield* composeInto(tree, state.fixture, state.view); + // While the journey or a playback is projecting, the moment on screen is + // not the store's, so the tree is brought to that moment's topology — + // once, when the moment changes, never on a repaint. Focus is not + // touched: a repaint may read where the person is and may not decide it. + const projecting = journey !== undefined || playback !== undefined; + if (projecting && composedFor !== state.fixture.name) { + yield* tree.sync(momentState(state.fixture)); + composedFor = state.fixture.name; + } + const composition = composeInto(tree, state.fixture, state.view, undefined, options.mutation); let painted: Painted; try { painted = draw(term, state, composition, write, options.mutation, motion, deltaMs, focus); diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index 1f1b28bdf..8b27b7042 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -20,19 +20,30 @@ import { fileURLToPath } from "node:url"; import { attach, walk } from "../repl-study/component.ts"; import type { Node } from "../repl-study/vendor/freedom/upstream/index.ts"; import { hydrate, initialView, layoutOf } from "../repl-study/store.ts"; -import { composeInto } from "../repl-study/capture.ts"; +import { composeInto, useForeignTree } from "../repl-study/capture.ts"; import { fixture } from "../repl-study/fixtures.ts"; import { journalThrough } from "../repl-study/journal.ts"; import { paint } from "../repl-study/paint.ts"; +import { drive } from "../repl-study/drive.ts"; import { applyAnsi, createGrid, gridText } from "../repl-study/screen.ts"; import { overlayOf, useReplTree } from "../repl-study/tree.ts"; import { enterRoute } from "../repl-study/drive.ts"; import { sendKey } from "../repl-study/keys.ts"; import { indexOf, project } from "../repl-study/view.ts"; import type { ReplView } from "../repl-study/view.ts"; +import type { HarnessEvent } from "../repl-study/store.ts"; +import type { Mutation } from "../repl-study/mutations.ts"; const WIDE = PROFILE_SIZES.wide; +function context(size: typeof WIDE, mutation?: Mutation) { + return { size, mutation, scrollLimit: 40 }; +} + +function key(code: string): HarnessEvent { + return { kind: "key", event: { type: "keydown", key: code, code } }; +} + function* mounted( url: string, head: string | undefined, @@ -316,7 +327,7 @@ describe("one mounted tree answers everything", () => { const tree = yield* useReplTree(state); yield* enterRoute(tree, state); const subject = fixture("nested"); - const composition = yield* composeInto(tree, subject, initialView(subject)); + const composition = composeInto(tree, subject, initialView(subject)); return { tree, composition }; } @@ -350,10 +361,47 @@ describe("one mounted tree answers everything", () => { it("is rejected when rendering uses a tree of its own", function* () { const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); const tree = yield* useReplTree(state); + yield* enterRoute(tree, state); const subject = fixture("nested"); - const split = yield* composeInto(tree, subject, initialView(subject), undefined, "second-tree"); + yield* useForeignTree(subject, initialView(subject)); + + // Move focus, so the two trees have something to disagree about. Node ids + // cannot tell them apart — each tree counts from one — so the oracle is the + // observable question: does what rendered agree with where focus is? + yield* drive(tree, state, key("Tab"), context(WIDE)); + const focused = tree.focused().name; + expect(focused).toBe("region:bindings"); + + const honest = composeInto(tree, subject, initialView(subject)); + const marked = (composed: typeof honest) => + overlayOf(composed.tree).find((entry) => entry.id === composed.tree.focused().name)?.id; + expect(marked(honest)).toBe(focused); + + const split = composeInto(tree, subject, initialView(subject), undefined, "second-tree"); + expect(marked(split)).not.toBe(focused); expect(split.root).not.toBe(tree.root.node); - expect(split.tree).not.toBe(tree); + }); + + it("does not move focus or topology when it repaints", function* () { + // A repaint used to hydrate a synthetic state from a fabricated URL, sync + // the tree to it and enter its route — every frame. Someone who tabbed to + // the bindings pane had focus dragged back to the transcript by the next + // frame, because drawing was re-deciding where they were. + const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); + const tree = yield* useReplTree(state); + yield* enterRoute(tree, state); + const moved = yield* drive(tree, state, key("Tab"), context(WIDE)); + const focused = tree.focused().name; + expect(focused).toBe("region:bindings"); + const topology = walkNames(tree.root.node).join(","); + + const subject = fixture("nested"); + for (let repaint = 0; repaint < 5; repaint += 1) { + composeInto(tree, subject, initialView(subject)); + } + expect(tree.focused().name).toBe(focused); + expect(walkNames(tree.root.node).join(",")).toBe(topology); + expect(moved.state.route.surface).toBe("bindings"); }); it("keeps unchanged children when a parent reconciles", function* () { From ca9db45915051a8f45e1a590e0c2e76957dd5257 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 14:26:53 -0400 Subject: [PATCH 20/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Derive=20focus=20fro?= =?UTF-8?q?m=20the=20tree,=20and=20let=20each=20control=20draw=20its=20own?= =?UTF-8?q?=20marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FocusView` is gone. It was an identity and a numbered map, worked out somewhere else and threaded down through `PresentOptions`, every frame request and the host — a second opinion about focus, free to disagree with the tree. Traversal now derives `"self" | "within" | "outside"` for each node as it walks, and that word is the whole of what a render body is told. It never receives a node. The F1 overlay is the same tree, walked by the root and handed to its child as data. It takes the placement its parent gave it rather than the whole `Layout`. Which control holds focus is now the control's own to say. A field, a button and a transport action are components with a body each; a parent reserves the cell and the focused control draws `▸` in it. The drawer's lines and its gutter cells come out of one pass, so a marker cannot drift off the word it belongs to, and a recorded drawer reserves nothing because none of its controls can ever hold focus. `--capture-focus` drew its frames with a *second* tree: the frame's tree was asked where focus was, and a freshly mounted one drew the picture. That is how a capture could mark a node the rendering tree had never heard of. One tree now answers both. Evidence: all nineteen committed focus captures reproduce byte for byte. The study's and the catalog's captures move for one reason — a frame drawn by walking the tree cannot be silent about focus, because the tree always has some — so the focused region wears `▌` and an open drawer, which traps focus, shows the gutter. A new control hands the renderer the exact `FocusView` #839 used to hand it and compares bytes: there is nowhere left for it to land. --- scripts/repl-study/capture.ts | 23 +- scripts/repl-study/component.ts | 40 +- scripts/repl-study/components.ts | 122 +++-- scripts/repl-study/host.ts | 25 +- scripts/repl-study/paint.ts | 8 +- scripts/repl-study/render.ts | 422 +++++++++++------- scripts/repl-study/tree.ts | 78 +++- .../repl-catalog/bindings-document.narrow.txt | 2 +- .../repl-catalog/bindings-document.wide.txt | 2 +- .../repl-catalog/bindings-plan.narrow.txt | 2 +- .../repl-catalog/bindings-plan.wide.txt | 2 +- .../repl-catalog/drawer-confirm.narrow.txt | 4 +- .../repl-catalog/drawer-confirm.wide.txt | 4 +- .../repl-catalog/drawer-project.narrow.txt | 6 +- .../repl-catalog/drawer-project.wide.txt | 8 +- .../repl-catalog/drawer-review.narrow.txt | 10 +- .../repl-catalog/drawer-review.wide.txt | 8 +- .../fixtures/repl-catalog/empty.narrow.txt | 2 +- .../fixtures/repl-catalog/empty.wide.txt | 2 +- .../repl-catalog/inspecting.narrow.txt | 2 +- .../fixtures/repl-catalog/inspecting.wide.txt | 2 +- .../fixtures/repl-catalog/nested.narrow.txt | 2 +- .../fixtures/repl-catalog/nested.wide.txt | 2 +- .../fixtures/repl-catalog/sessions.narrow.txt | 2 +- .../fixtures/repl-catalog/sessions.wide.txt | 2 +- .../fixtures/repl-catalog/settled.narrow.txt | 2 +- .../fixtures/repl-catalog/settled.wide.txt | 2 +- .../fixtures/repl-study/drawer.medium.txt | 6 +- .../repl-study/drawer.narrow.sessions.txt | 2 +- .../fixtures/repl-study/drawer.narrow.txt | 6 +- .../tests/fixtures/repl-study/drawer.wide.txt | 8 +- .../fixtures/repl-study/empty.medium.txt | 2 +- .../fixtures/repl-study/empty.narrow.txt | 2 +- .../tests/fixtures/repl-study/empty.wide.txt | 2 +- .../fixtures/repl-study/generated.medium.txt | 2 +- .../fixtures/repl-study/generated.narrow.txt | 2 +- .../fixtures/repl-study/generated.wide.txt | 2 +- .../fixtures/repl-study/nested.medium.txt | 2 +- .../repl-study/nested.narrow.bindings.txt | 2 +- .../fixtures/repl-study/nested.narrow.txt | 2 +- .../tests/fixtures/repl-study/nested.wide.txt | 2 +- .../fixtures/repl-study/paused.medium.txt | 2 +- .../repl-study/paused.narrow.history.txt | 2 +- .../fixtures/repl-study/paused.narrow.txt | 2 +- .../tests/fixtures/repl-study/paused.wide.txt | 2 +- .../play.generated-drawer.midpoint.txt | 8 +- .../play.generated-drawer.settled.txt | 8 +- .../fixtures/repl-study/settled.medium.txt | 2 +- .../fixtures/repl-study/settled.narrow.txt | 2 +- .../fixtures/repl-study/settled.wide.txt | 2 +- scripts/tests/repl-components.test.ts | 20 +- scripts/tests/repl-focus.test.ts | 91 +++- 52 files changed, 638 insertions(+), 331 deletions(-) diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 69f619f75..ac44eac50 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -20,7 +20,6 @@ import type { Motion, Playback } from "./playback.ts"; import type { Fixture } from "./model.ts"; import type { Profile, SurfaceName } from "./layout.ts"; import { layoutFor } from "./layout.ts"; -import type { FocusView } from "./render.ts"; import { paint } from "./paint.ts"; import { projectFixture } from "./view.ts"; import type { ReplView } from "./view.ts"; @@ -36,7 +35,6 @@ import { initialView } from "./store.ts"; import type { View } from "./store.ts"; import { fixtureFor, viewOf } from "./store.ts"; import { FRAMES, useFrame } from "./frames.ts"; -import { overlayOf } from "./tree.ts"; import type { Mutation } from "./mutations.ts"; export interface Size { @@ -167,8 +165,13 @@ export interface FrameRequest { readonly surface?: SurfaceName; /** Present only while a playback is running between two fixtures. */ readonly motion?: Motion; - /** Where focus is. Left out, the frame says nothing about focus at all. */ - readonly focus?: FocusView; + /** + * Ordinary UI state: whether the numbered overlay is drawn. + * + * Nothing here says where focus is. There is no longer anywhere to say it: + * the tree owns focus, and a frame is drawn by walking the tree. + */ + readonly overlay?: boolean; /** * Seconds since the previous frame, which is the unit the renderer measures * transitions in. Leaving it out hands the renderer its own monotonic clock; @@ -247,7 +250,7 @@ export function renderInto(term: Term, request: FrameRequest): Frame { view: shown, layout, anchor: view.anchor, - options: { focus: request.focus, mutation, motion }, + options: { overlay: request.overlay, mutation, motion }, }); const result = term.render( painted.ops, @@ -509,11 +512,17 @@ export function* captureFocus(): Operation { const profiles: Profile[] = NARROW_FRAMES.includes(subject.id) ? ["wide", "narrow"] : ["wide"]; for (const profile of profiles) { const size = PROFILE_SIZES[profile]; - const frame = yield* renderFrame({ + const term = yield* useTerm(size); + // The frame's own tree draws the frame. It used to be told where focus + // was and then rendered by a second tree mounted for the occasion, which + // is how a capture could show focus on a node the rendering tree had + // never heard of. One tree answers both. + const frame = renderInto(term, { fixture: fixtureFor(state), view: viewOf(state), size, - focus: { here: tree.focused().name, map: overlayOf(tree), overlay: true }, + overlay: true, + composition: composeInto(tree, fixtureFor(state), viewOf(state)), }); captures.push({ name: `frame-${subject.id}.${profile}`, profile, size, frame }); } diff --git a/scripts/repl-study/component.ts b/scripts/repl-study/component.ts index 0dc8b07e1..e65118d14 100644 --- a/scripts/repl-study/component.ts +++ b/scripts/repl-study/component.ts @@ -76,8 +76,21 @@ export interface Surface { readonly name: string; } +/** + * Where focus is, relative to this node and nothing else. + * + * `self` is the focused node. `within` is an ancestor of it. `outside` is + * everything else. A body is told no more than this: which *descendant* holds + * focus is not a question a component may ask, because answering it would let a + * parent draw a child's state and the child would stop owning its own + * presentation. + */ +export type FocusRelation = "self" | "within" | "outside"; + export interface BodyContext { readonly self: Surface; + /** Where focus is relative to this node, derived while walking the tree. */ + readonly focus: FocusRelation; /** This component's own immutable view subtree. */ readonly data: Data; readonly placement: Placement; @@ -88,7 +101,7 @@ export interface BodyContext { export type Body = (context: BodyContext) => Op[]; interface Attached { - readonly render: (node: Node, children: readonly Op[]) => Op[]; + readonly render: (node: Node, children: readonly Op[], focus: FocusRelation) => Op[]; } const bodyKey = createNodeData("xmd:repl:body"); @@ -116,7 +129,8 @@ export function attach( let where = placement; const self: Surface = { id: node.id, name: node.name === "" ? "root" : node.name }; node.data.set(bodyKey, { - render: (_node, children) => body({ self, data: current, placement: where, children }), + render: (_node, children, focus) => + body({ self, data: current, placement: where, children, focus }), }); return (next, to) => { current = next; @@ -135,11 +149,27 @@ export function hasBody(node: Node): boolean { * is what lets a purely structural node — a routing outlet, a focus root — exist * without drawing anything. */ -export function walk(node: Node): Op[] { +export function walk(node: Node, focused?: Node): Op[] { const children: Op[] = []; + let holds = false; for (const child of node.children) { - children.push(...walk(child)); + children.push(...walk(child, focused)); + if (focused !== undefined && contains(child, focused)) { + holds = true; + } } + const relation: FocusRelation = + focused === undefined ? "outside" : node === focused ? "self" : holds ? "within" : "outside"; const attached = node.data.get(bodyKey); - return attached ? attached.render(node, children) : children; + return attached ? attached.render(node, children, relation) : children; +} + +/** True where `node` is `target` or one of its ancestors. */ +function contains(node: Node, target: Node): boolean { + for (let at: Node | undefined = target; at; at = at.parent) { + if (at === node) { + return true; + } + } + return false; } diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index 359f01355..bd2ea3edf 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -21,7 +21,7 @@ import { BG, drawerRegion, focusMapRegion, - focusMarkerOps, + focusMark, inputRegion, rule, surfaceBarRegion, @@ -36,7 +36,8 @@ import { transcriptLines, wrapText, } from "./render.ts"; -import type { FocusView, VisualLine } from "./render.ts"; +import type { VisualLine } from "./render.ts"; +import type { OverlayEntry } from "./tree.ts"; import type { Layout, Rect, SurfaceName } from "./layout.ts"; import type { Mutation } from "./mutations.ts"; import type { Motion } from "./playback.ts"; @@ -70,7 +71,7 @@ export const headerBody: Body> = ({ self, data { bg: BG.center }, ); -export const sessionsBody: Body = ({ self, data, placement, children }) => { +export const sessionsBody: Body = ({ self, data, placement, children, focus }) => { const lines: VisualLine[] = []; if (!placement.dense) { lines.push(plain("XMD REPL", C.out), blank()); @@ -120,7 +121,11 @@ export const sessionsBody: Body = ({ self, data, placement, childr lines.push(plain(`· ${record}`, C.dim)); } } - return region(self.id, placement.rect, lines, { bg: BG.side, children }); + return region(self.id, placement.rect, lines, { + bg: BG.side, + children, + focused: focus === "self", + }); } if (data.sessions.length === 0) { @@ -129,7 +134,11 @@ export const sessionsBody: Body = ({ self, data, placement, childr lines.push(plain(wrapped, C.dim)); } } - return region(self.id, placement.rect, lines, { bg: BG.side, children }); + return region(self.id, placement.rect, lines, { + bg: BG.side, + children, + focused: focus === "self", + }); } for (const session of data.sessions) { @@ -159,7 +168,11 @@ export const sessionsBody: Body = ({ self, data, placement, childr } lines.push(blank()); } - return region(self.id, placement.rect, lines, { bg: BG.side, children }); + return region(self.id, placement.rect, lines, { + bg: BG.side, + children, + focused: focus === "self", + }); }; export interface TranscriptData { @@ -171,7 +184,13 @@ export interface TranscriptData { readonly motion?: Motion; } -export const transcriptBody: Body = ({ self, data, placement, children }) => { +export const transcriptBody: Body = ({ + self, + data, + placement, + children, + focus, +}) => { const rect = placement.rect; const width = Math.max(0, rect.width - 2); const lines: VisualLine[] = []; @@ -183,7 +202,7 @@ export const transcriptBody: Body = ({ self, data, placement, ch lines.push(plain(wrapped, C.dim)); } } - return region(self.id, rect, lines, { bg: BG.center, children }); + return region(self.id, rect, lines, { bg: BG.center, children, focused: focus === "self" }); } const running = entry.state === "running"; lines.push( @@ -215,7 +234,7 @@ export const transcriptBody: Body = ({ self, data, placement, ch if (arriving) { const shown = Math.max(1, Math.ceil(data.motion!.reveal * windowed.length)); lines.push(...windowed.slice(0, shown), plain("…", C.dim)); - return region(self.id, rect, lines, { bg: BG.center, children }); + return region(self.id, rect, lines, { bg: BG.center, children, focused: focus === "self" }); } lines.push(...windowed); @@ -227,10 +246,10 @@ export const transcriptBody: Body = ({ self, data, placement, ch lines.push(plain(`▴ ${data.anchor} earlier lines · ↑ scrolls back`, C.dim)); } } - return region(self.id, rect, lines, { bg: BG.center, children }); + return region(self.id, rect, lines, { bg: BG.center, children, focused: focus === "self" }); }; -export const bindingsBody: Body = ({ self, data, placement, children }) => { +export const bindingsBody: Body = ({ self, data, placement, children, focus }) => { const lines: VisualLine[] = [label("BINDINGS"), plain(data.scopeName, C.dim), blank()]; if (data.bindings.length === 0) { for (const placeholder of data.placeholder) { @@ -238,7 +257,11 @@ export const bindingsBody: Body = ({ self, data, placement, childr lines.push(plain(wrapped, C.dim)); } } - return region(self.id, placement.rect, lines, { bg: BG.bind, children }); + return region(self.id, placement.rect, lines, { + bg: BG.bind, + children, + focused: focus === "self", + }); } for (const binding of data.bindings) { if (isRedacted(binding)) { @@ -258,13 +281,20 @@ export const bindingsBody: Body = ({ self, data, placement, childr } lines.push(blank()); } - return region(self.id, placement.rect, lines, { bg: BG.bind, children }); + return region(self.id, placement.rect, lines, { + bg: BG.bind, + children, + focused: focus === "self", + }); }; -export const inputBody: Body = ({ self, data, placement, children }) => [ - ...inputRegion(self.id, data, placement), - ...children, -]; +export const inputBody: Body = ({ + self, + data, + placement, + children, + focus, +}) => [...inputRegion(self.id, data, placement, focus === "self"), ...children]; /** * The Execution History band. @@ -273,8 +303,8 @@ export const inputBody: Body = ({ self, data, placement * its own stem, a selection is gold with a caret — and what changed is who asks * for it: the band is a component handed its own view and its own box. */ -export const historyBody: Body = ({ self, data, placement, children }) => [ - ...bandRegion(self.id, data.view, placement, data.mutation, data.motion, data.focus), +export const historyBody: Body = ({ self, data, placement, children, focus }) => [ + ...bandRegion(self.id, data.view, placement, data.mutation, data.motion, focus === "self"), ...children, ]; @@ -282,7 +312,6 @@ export interface HistoryData { readonly view: HistoryView; readonly mutation?: Mutation; readonly motion?: Motion; - readonly focus?: FocusView; } /** @@ -291,14 +320,32 @@ export interface HistoryData { * A recorded drawer keeps its complete presentation and says so; nothing about * it is actionable, and the tree does not offer its controls for focus. */ -export const drawerBody: Body = ({ self, data, placement, children }) => [ - ...drawerRegion(self.id, data.view, placement, data.focus), +export const drawerBody: Body = ({ self, data, placement, children, focus }) => [ + // The gutter is reserved while focus is anywhere inside this drawer, which is + // all this node knows and all it needs: the glyph in it is drawn by whichever + // control is focused, not by the form around it. + ...drawerRegion(self.id, data.view, placement, focus === "self", focus !== "outside"), + ...children, +]; + +/** + * One focusable control: a field, a button, a transport action. + * + * It draws exactly one thing — its own focus marker, in the cell its parent + * reserved for it — because which control holds focus is the control's own to + * say. For a parent to draw this it would have to be told which of its children + * was focused, and that is the knowledge tree traversal deliberately withholds. + * + * A control whose parent reserved no cell draws nothing, which is how the + * `Run` affordance behaves: the input band spends no column on a gutter. + */ +export const controlBody: Body = ({ self, placement, children, focus }) => [ + ...(focus === "self" ? focusMark(`${self.id}.mark`, placement.rect) : []), ...children, ]; export interface DrawerData { readonly view: DrawerView; - readonly focus?: FocusView; } /** Narrow only: the one row naming the surface you are on. */ @@ -319,23 +366,24 @@ export const rulesBody: Body = ({ self, data, children }) => [ ...children, ]; -/** Where focus is, as a glyph that survives a monochrome terminal. */ -export const focusMarkerBody: Body<{ readonly layout: Layout; readonly focus?: FocusView }> = ({ - self, - data, - children, -}) => [...focusMarkerOps(self.id, data.layout, data.focus), ...children]; - -/** The numbered focus map, when it is asked for. */ -export const focusMapBody: Body<{ readonly layout: Layout; readonly focus?: FocusView }> = ({ - self, - data, - children, -}) => [ - ...(data.focus?.overlay === true ? focusMapRegion(self.id, data.layout, data.focus) : []), +/** + * The numbered focus map. + * + * Its entries are derived by walking the mounted tree — by its parent, which is + * the only thing that can see the tree — and its box is the placement its + * parent gave it. It is handed no layout and no application focus state. + */ +export const focusMapBody: Body = ({ self, data, placement, children }) => [ + ...(data.visible ? focusMapRegion(self.id, data.entries, placement) : []), ...children, ]; +export interface FocusMapData { + readonly entries: readonly OverlayEntry[]; + /** Ordinary UI state: whether F1 has been pressed. Nothing about focus. */ + readonly visible: boolean; +} + /** The refusal a terminal below the supported minimum gets instead of a screen. */ export const refusalBody: Body = ({ self, data }) => tooSmallRegion(self.id, data); diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 4788e6fe5..921902289 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -22,10 +22,9 @@ import { fixture, fixtures } from "./fixtures.ts"; import type { Fixture, FixtureName } from "./model.ts"; import { SURFACES } from "./layout.ts"; import { transcriptLines } from "./render.ts"; -import type { FocusView } from "./render.ts"; import { initialView } from "./store.ts"; import { asKey, fixtureFor, hydrate, reduce, viewOf } from "./store.ts"; -import { overlayOf, useReplTree } from "./tree.ts"; +import { useReplTree } from "./tree.ts"; import { drive, enterRoute } from "./drive.ts"; import type { HarnessEvent, ReplState, View } from "./store.ts"; import { journalThrough, markerShowing } from "./journal.ts"; @@ -209,7 +208,7 @@ function draw( mutation?: Mutation, motion?: Motion, deltaMs = 0, - focus?: FocusView, + overlay?: boolean, ): Painted { // One render path for the harness and for the captures, so what a person sees // in a terminal and what a golden records cannot drift apart. @@ -220,7 +219,7 @@ function draw( size: { cols: state.cols, rows: state.rows }, mutation, motion, - focus, + overlay, // The harness counts in milliseconds and the renderer in seconds. The // conversion happens here, once, at the only place the two meet. deltaSeconds: deltaMs / 1000, @@ -522,11 +521,6 @@ export function* runInteractive(options: InteractiveOptions): Operation { const repeated = journey !== undefined && segment?.kind === "hold" && held === label; if (!repeated) { const measured = { cols: state.cols, rows: state.rows }; - const focus: FocusView = { - here: tree.focused().name, - map: overlayOf(tree), - overlay: repl.overlay, - }; // The moment on screen is composed **into the harness's one tree**, so // rendering, focus, scoped input and the overlay all come off the same // mounted object. A second tree for rendering would look right on its own @@ -543,7 +537,16 @@ export function* runInteractive(options: InteractiveOptions): Operation { const composition = composeInto(tree, state.fixture, state.view, undefined, options.mutation); let painted: Painted; try { - painted = draw(term, state, composition, write, options.mutation, motion, deltaMs, focus); + painted = draw( + term, + state, + composition, + write, + options.mutation, + motion, + deltaMs, + repl.overlay, + ); } catch (error) { if (!(error instanceof RendererCapacityError)) { throw error; @@ -552,7 +555,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { // wide terminal will do. A new one starts that cache again and repaints // the whole screen, so the person watching sees nothing but a frame. term = yield* useTerm(measured); - painted = draw(term, state, composition, write, options.mutation, motion, 0, focus); + painted = draw(term, state, composition, write, options.mutation, motion, 0, repl.overlay); } frames += 1; options.trace?.push({ diff --git a/scripts/repl-study/paint.ts b/scripts/repl-study/paint.ts index 65306c96e..812c598e1 100644 --- a/scripts/repl-study/paint.ts +++ b/scripts/repl-study/paint.ts @@ -45,10 +45,14 @@ export function paint(request: PaintRequest): Painted { const { tree, view, layout } = request; tree.present(view, layout, { anchor: request.anchor ?? 0, ...request.options }); const root: Node = tree.root.node; + // Focus is asked of the tree once, here, and handed to the walk. A body then + // learns only where focus is relative to itself, which is the whole of what + // it may know. + const focused = tree.focused(); const ids: Record = { root: root.id }; if (layout.profile === "too-small") { for (const child of root.children) { - return { ops: walk(child), ids: { ...ids, "too-small": child.id } }; + return { ops: walk(child, focused), ids: { ...ids, "too-small": child.id } }; } } for (const child of root.children) { @@ -62,5 +66,5 @@ export function paint(request: PaintRequest): Painted { ids.contextual = child.id; } } - return { ops: walk(root), ids }; + return { ops: walk(root, focused), ids }; } diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 792819c8e..ccae59b07 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -149,6 +149,13 @@ function lineOps(id: string, width: number, line: VisualLine): Op[] { export interface RegionOptions { readonly bg?: number; + /** + * Draw this region's own focus marker. + * + * The component that *is* focused draws it, from its own node-relative + * relation. Nothing tells a parent which of its children is focused. + */ + readonly focused?: boolean; /** * What this region's children already rendered. * @@ -209,6 +216,18 @@ export function region( }); ops.push(...(options.children ?? [])); ops.push(close()); + if (options.focused === true) { + // A glyph rather than a colour, so focus survives a monochrome terminal and + // a committed `.txt` capture. + ops.push( + open(`${id}.focus`, { + layout: { width: fixed(1), height: fixed(1) }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + }), + text("▌", { color: C.focus }), + close(), + ); + } return ops; } @@ -256,53 +275,26 @@ const DRAWER_TRANSITION = { properties: ["height", "y"], } as const; -/** - * What the renderer is told about focus. - * - * It is handed the answer rather than asked to work one out: `focus.ts` derives - * the registry and the map every frame, and drawing is not a place where a - * second opinion about where focus is may be formed. - */ -export interface FocusView { - /** The name of the node the tree reports as focused. */ - readonly here: string; - /** The live tree, walked and numbered. Nothing here is a second registry. */ - readonly map: readonly OverlayEntry[]; - readonly overlay: boolean; +/** One numbered entry the overlay draws, derived from the mounted tree. */ +export interface OverlayItem { + readonly id: string; + readonly label: string; + readonly enabled: boolean; + readonly number: number; + readonly focused: boolean; } -/** The glyph a focused region wears, so focus survives a monochrome terminal. */ -const FOCUS_GLYPH = "\u258c"; - -/** The glyph beside a focused control or field. */ +/** The glyph beside the focused entry. */ const FOCUS_MARK = "\u25b8"; -/** Where the region carrying one identity was composed, if it is on screen. */ -function regionRect(layout: Layout, identity: string): Rect | undefined { - if (identity === "region:sessions") { - return layout.sidebar; - } - if (identity === "region:transcript") { - return layout.transcript; - } - if (identity === "region:bindings") { - return layout.bindings; - } - if (identity === "region:input") { - return layout.contextual; - } - if (identity === "region:history") { - return layout.footer; - } - return undefined; -} - -export function focusMarkerOps(id: string, layout: Layout, focus: FocusView | undefined): Op[] { - if (focus === undefined) { - return []; - } - const rect = regionRect(layout, focus.here); - if (rect === undefined) { +/** + * One control's own focus marker, in the single cell its parent reserved. + * + * Drawn by the control, never by its parent: it is the one thing a component + * says about focus, and it says it only about itself. + */ +export function focusMark(id: string, rect: Rect): Op[] { + if (rect.width <= 0 || rect.height <= 0) { return []; } return [ @@ -310,19 +302,11 @@ export function focusMarkerOps(id: string, layout: Layout, focus: FocusView | un layout: { width: fixed(1), height: fixed(1) }, floating: { x: rect.x, y: rect.y, attachTo: "root" }, }), - text(FOCUS_GLYPH, { color: C.focus }), + text(FOCUS_MARK, { color: C.focus }), close(), ]; } -/** The word the footer draws for a transport control, keyed by its identity. */ -const TRANSPORT_WORDS: Record = { - "control:transport.pause": ["Pause"], - "control:transport.continue": ["Continue"], - "control:transport.return-head": ["Return to paused head", "Return"], - "control:transport.fork": ["Fork from here", "Fork"], -}; - /** * The numbered focus map, as a legend rather than as floating callouts. * @@ -331,18 +315,25 @@ const TRANSPORT_WORDS: Record = { * same numbers, in the same order, with a disabled target dimmed and present — * study frame 12 numbers a dimmed `Continue` and says Tab skips it, so the map * has to show what the ring does not. + * + * Its entries are derived from the mounted tree by its parent, and its box is + * the placement its parent gave it. It is handed no layout and no application + * focus state. */ -export function focusMapRegion(id: string, layout: Layout, focus: FocusView): Op[] { - const ordered = focus.map; - const width = Math.min(34, Math.max(18, Math.round(layout.cols * 0.24))); - const height = Math.min(layout.rows, ordered.length + 2); - const rect = { x: Math.max(0, layout.cols - width - 1), y: 1, width, height }; +export function focusMapRegion( + id: string, + ordered: readonly OverlayItem[], + placement: Placement, +): Op[] { + const screen = placement.rect; + const width = Math.min(34, Math.max(18, Math.round(screen.width * 0.24))); + const height = Math.min(screen.height, ordered.length + 2); + const rect = { x: Math.max(0, screen.width - width - 1), y: 1, width, height }; const lines: VisualLine[] = [label("FOCUS MAP · F1")]; for (const target of ordered) { - const on = target.id === focus.here; lines.push({ segments: [ - { text: on ? `${FOCUS_MARK} ` : " ", color: C.focus, width: 2 }, + { text: target.focused ? `${FOCUS_MARK} ` : " ", color: C.focus, width: 2 }, { text: `${target.number}`, color: target.enabled ? C.out : C.dim, width: 3 }, { text: target.label, color: target.enabled ? C.src : C.dim }, ], @@ -496,114 +487,201 @@ function entryHeader(entry: Entry): VisualLine { * the box its parent gives it, so the same body serves the component tree and * the rectangle path while both exist. */ -export function drawerRegion( - id: string, +/** One 1×1 cell a control owns, to draw its own focus marker in. */ +export interface Slot { + readonly id: string; + readonly rect: Rect; +} + +/** + * The drawer's lines, and the gutter cell each of its controls owns. + * + * Both come out of one pass. A slot computed apart from the line it sits in + * would be a second layout free to disagree with the first, and the marker + * would drift off the word it belongs to. + * + * The drawer draws the text and *reserves* the gutter; it never fills it. Which + * control holds focus is the control's own to say, so the glyph is drawn by the + * control, from its own relation to focus. The gutter appears only while focus + * is somewhere inside this drawer, which is a fact about this node and its own + * subtree — and it is why a drawer nothing is focused in is drawn exactly as + * #838 drew it. + */ +function drawerContent( drawer: DrawerView, placement: Placement, - focus?: FocusView, -): Op[] { + gutter: boolean, +): { readonly lines: VisualLine[]; readonly slots: Slot[] } { const rect = placement.rect; const width = Math.max(0, rect.width - 2); - // With nothing to say about focus the drawer is drawn exactly as #838 drew - // it, which is what keeps a frame that is not about focus byte-identical. - const mark = (id: string): string => - focus === undefined ? "" : focus.here === id ? `${FOCUS_MARK} ` : " "; - { - const lines: VisualLine[] = [plain(drawer.heading, C.hold)]; - // Which suspended request this is answering is never dropped: a drawer - // without its origin is a form with no idea what it belongs to. - for (const wrapped of wrapText(drawer.origin, width)) { - lines.push(plain(wrapped, C.dim)); + const lines: VisualLine[] = []; + const slots: Slot[] = []; + // A recorded drawer offers nothing to act on, so it spends no column on a + // gutter: focus is inside it — on the way out — but none of its controls can + // ever hold it, and a gutter would promise one that could. + const reserve = gutter && !drawer.historical; + /** Reserve the gutter on the line about to be pushed, at `column` within it. */ + const mark = (id: string, column: number): string => { + if (!reserve) { + return ""; } - if (drawer.historical) { - // What this is comes before what it says: a recording offers nothing to - // act on, and a reader should know that before reading the form. It also - // has to survive a band that clips — appended last, it did not. - lines.push(plain("recorded · read-only", C.gold)); + // Only a line this box actually draws gets a cell. The region clips its own + // text; a marker floats above the screen and is clipped by nothing, so a + // cell on a line the drawer has no room for would draw over whatever is + // there instead — which is what a band shrunk to the closed drawer's height + // does during the frame before one opens. + const y = rect.y + lines.length; + if (lines.length < rect.height && column + 1 < rect.width) { + slots.push({ id, rect: { x: rect.x + 1 + column, y, width: 1, height: 1 } }); + } + return " "; + }; + lines.push(plain(drawer.heading, C.hold)); + // Which suspended request this is answering is never dropped: a drawer + // without its origin is a form with no idea what it belongs to. + for (const wrapped of wrapText(drawer.origin, width)) { + lines.push(plain(wrapped, C.dim)); + } + if (drawer.historical) { + // What this is comes before what it says: a recording offers nothing to + // act on, and a reader should know that before reading the form. It also + // has to survive a band that clips — appended last, it did not. + lines.push(plain("recorded · read-only", C.gold)); + } + lines.push(blank()); + if (drawer.kind === "project") { + for (const wrapped of wrapText(drawer.prompt, width)) { + lines.push(plain(wrapped, C.src)); } lines.push(blank()); - if (drawer.kind === "project") { - for (const wrapped of wrapText(drawer.prompt, width)) { - lines.push(plain(wrapped, C.src)); - } - lines.push(blank()); - for (const field of drawer.fields) { - const id = `field:drawer.project.${field.label === "Project name" ? "name" : "description"}`; - lines.push(label(`${mark(id)}${field.label}`)); - lines.push({ - segments: [ - { text: "┃ ", color: C.rule, width: 2 }, - { text: field.value, color: C.out }, - ], - }); - } - lines.push(blank(), { + for (const field of drawer.fields) { + const id = `field:drawer.project.${field.label === "Project name" ? "name" : "description"}`; + lines.push(label(`${mark(id, 0)}${field.label}`)); + lines.push({ segments: [ - { text: drawer.validation, color: C.dim, width: Math.min(width, 20) }, - { text: `${mark("control:drawer.project.submit")}${drawer.submit}`, color: C.tick }, + { text: "┃ ", color: C.rule, width: 2 }, + { text: field.value, color: C.out }, ], }); - if (!placement.dense) { - lines.push(blank(), label(`${mark("control:drawer.project.schema")}schema`)); - for (const schema of drawer.schema) { - lines.push(plain(schema, C.settledText)); - } - } } - if (drawer.kind === "review") { - lines.push(plain(`${mark("control:drawer.review.scroll")}${drawer.plan[0] ?? ""}`, C.src)); - for (const planLine of drawer.plan.slice(1)) { - lines.push(plain(planLine, C.src)); + lines.push(blank()); + const validationWidth = Math.min(width, 20); + lines.push({ + segments: [ + { text: drawer.validation, color: C.dim, width: validationWidth }, + { + text: `${mark("control:drawer.project.submit", validationWidth)}${drawer.submit}`, + color: C.tick, + }, + ], + }); + if (!placement.dense) { + lines.push(blank()); + lines.push(label(`${mark("control:drawer.project.schema", 0)}schema`)); + for (const schema of drawer.schema) { + lines.push(plain(schema, C.settledText)); } - lines.push(plain(drawer.more, C.dim), blank()); - const decided = ["approve", "request", "stop"]; - drawer.decisions.forEach((decision, index) => { - lines.push({ - segments: [ - { - text: decision.chosen ? "(•) " : "( ) ", - color: decision.chosen ? C.tick : C.label, - width: 4, - }, - { - text: `${mark(`control:drawer.review.${decided[index] ?? index}`)}${decision.label}`, - color: decision.chosen ? C.out : C.src, - }, - ], - }); + } + } + if (drawer.kind === "review") { + lines.push(plain(`${mark("control:drawer.review.scroll", 0)}${drawer.plan[0] ?? ""}`, C.src)); + for (const planLine of drawer.plan.slice(1)) { + lines.push(plain(planLine, C.src)); + } + lines.push(plain(drawer.more, C.dim), blank()); + const decided = ["approve", "request", "stop"]; + drawer.decisions.forEach((decision, index) => { + lines.push({ + segments: [ + { + text: decision.chosen ? "(•) " : "( ) ", + color: decision.chosen ? C.tick : C.label, + width: 4, + }, + { + text: `${mark(`control:drawer.review.${decided[index] ?? index}`, 4)}${decision.label}`, + color: decision.chosen ? C.out : C.src, + }, + ], }); - lines.push(blank(), plain(`${mark("control:drawer.review.submit")}${drawer.submit}`, C.tick)); + }); + lines.push(blank()); + lines.push(plain(`${mark("control:drawer.review.submit", 0)}${drawer.submit}`, C.tick)); + } + if (drawer.kind === "confirm") { + for (const wrapped of wrapText(drawer.prompt, width)) { + lines.push(plain(wrapped, C.src)); } - if (drawer.kind === "confirm") { - for (const wrapped of wrapText(drawer.prompt, width)) { - lines.push(plain(wrapped, C.src)); - } - drawer.preview.forEach((preview, index) => { - lines.push({ - segments: [ - { text: "│ ", color: C.rule, width: 2 }, - { - text: index === 0 ? `${mark("control:drawer.confirm.preview")}${preview}` : preview, - color: C.src, - }, - ], - }); + drawer.preview.forEach((preview, index) => { + lines.push({ + segments: [ + { text: "│ ", color: C.rule, width: 2 }, + { + text: index === 0 ? `${mark("control:drawer.confirm.preview", 2)}${preview}` : preview, + color: C.src, + }, + ], }); - lines.push(blank(), { - segments: drawer.actions.map((action) => ({ - text: `[ ${mark(`control:drawer.confirm.${action.label.toLowerCase()}`)}${action.label} ]`, + }); + lines.push(blank()); + let column = 0; + lines.push({ + segments: drawer.actions.map((action) => { + const at = column; + const segmentWidth = action.label.length + 6 + (reserve ? 2 : 0); + column += segmentWidth; + return { + text: `[ ${mark( + `control:drawer.confirm.${action.label.toLowerCase()}`, + at + 2, + )}${action.label} ]`, color: action.primary ? C.tick : C.label, - width: action.label.length + 6 + (focus === undefined ? 0 : 2), - })), - }); - lines.push(plain(drawer.hint, C.dim)); - } - return region(id, rect, lines, { bg: BG.drawer, transition: DRAWER_TRANSITION }); + width: segmentWidth, + }; + }), + }); + lines.push(plain(drawer.hint, C.dim)); } + return { lines, slots }; +} + +/** Where each of this drawer's controls may draw its own marker. */ +export function drawerSlots( + drawer: DrawerView, + placement: Placement, + gutter: boolean, +): readonly Slot[] { + return drawerContent(drawer, placement, gutter).slots; +} + +/** + * One suspension's drawer. + * + * The drawing is the study's. What changed is that it takes a `DrawerView` and + * the box its parent gives it, so the same body serves the component tree and + * the rectangle path while both exist. + */ +export function drawerRegion( + id: string, + drawer: DrawerView, + placement: Placement, + focused = false, + gutter = false, +): Op[] { + return region(id, placement.rect, drawerContent(drawer, placement, gutter).lines, { + bg: BG.drawer, + transition: DRAWER_TRANSITION, + focused, + }); } /** The REPL input band, which the drawer takes over while one is open. */ -export function inputRegion(id: string, input: InputView, placement: Placement): Op[] { +export function inputRegion( + id: string, + input: InputView, + placement: Placement, + focused = false, +): Op[] { const rect = placement.rect; const width = Math.max(0, rect.width - 2); const lines: VisualLine[] = [ @@ -620,7 +698,7 @@ export function inputRegion(id: string, input: InputView, placement: Placement): }, plain(input.draft === "" ? input.placeholder : input.draft, C.settledText), ]; - return region(id, rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION }); + return region(id, rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION, focused }); } export function clock(seconds: number): string { @@ -761,6 +839,34 @@ export function bandGeometry(history: HistoryView, placement: Placement): BandGe }; } +/** + * The cell each transport control may draw its own marker in. + * + * One per control, in the band's own order — the same order the tree mounts + * them in, because both read the same transport mode. The marker replaces the + * space inside the bracket rather than widening it: the track's room is + * computed from that string, and a focused control that shortened the track + * would make focus a layout decision. + */ +export function transportSlots(history: HistoryView, placement: Placement): readonly Rect[] { + const rect = placement.rect; + const geometry = bandGeometry(history, placement); + const right = [...geometry.right]; + const slots: Rect[] = []; + let column = Math.max(0, geometry.inner - right.length) + [...geometry.transport.word].length + 2; + for (const control of geometry.transport.controls) { + const cell = column + 1; + slots.push( + cell < geometry.inner + ? { x: rect.x + 1 + cell, y: rect.y, width: 1, height: 1 } + : { x: 0, y: 0, width: 0, height: 0 }, + ); + // `[ ` + the word + ` ]`, then the space that joins it to the next one. + column += [...control].length + 5; + } + return slots; +} + /** * How tall the notch for one scope depth is. * @@ -802,7 +908,7 @@ export function bandRegion( placement: Placement, mutation?: Mutation, motion?: Motion, - focus?: FocusView, + focused = false, ): Op[] { const rect = placement.rect; // While a playback runs, the head is where the application says it is; the @@ -814,7 +920,7 @@ export function bandRegion( // The marker replaces the space inside the bracket rather than widening it: // the track's room is computed from this string, and a focused control that // shortened the track would make focus a layout decision. - const right = markTransport(geometry.right, focus); + const right = geometry.right; const grid: string[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => " ")); const colors: number[][] = BAND_ROWS.map(() => Array.from({ length: inner }, () => C.dim)); @@ -976,21 +1082,7 @@ export function bandRegion( } } - return region(id, rect, lines, { bg: BG.footer, padding: { left: 1, right: 1 } }); -} - -/** `[ Continue ]` becomes `[▸Continue ]` — the same width, one glyph louder. */ -function markTransport(right: string, focus: FocusView | undefined): string { - if (focus === undefined) { - return right; - } - for (const word of TRANSPORT_WORDS[focus.here] ?? []) { - const bracketed = `[ ${word} ]`; - if (right.includes(bracketed)) { - return right.replace(bracketed, `[${FOCUS_MARK}${word} ]`); - } - } - return right; + return region(id, rect, lines, { bg: BG.footer, padding: { left: 1, right: 1 }, focused }); } /** Keep each cell's colour when a grid row becomes segments. */ diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 984bbd4f5..54f19759b 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -41,9 +41,9 @@ import { attach, placementOf } from "./component.ts"; import type { Placement } from "./component.ts"; import { bindingsBody, + controlBody, drawerBody, focusMapBody, - focusMarkerBody, headerBody, historyBody, inputBody, @@ -55,9 +55,9 @@ import { surfaceBarBody, transcriptBody, } from "./components.ts"; -import type { FocusView } from "./render.ts"; import type { Motion } from "./playback.ts"; import type { ReplView } from "./view.ts"; +import { drawerSlots, transportSlots } from "./render.ts"; import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; import type { ReplState } from "./store.ts"; @@ -134,7 +134,8 @@ export interface PresentOptions { readonly anchor?: number; readonly mutation?: Mutation; readonly motion?: Motion; - readonly focus?: FocusView; + /** Ordinary UI state: whether F1 has been pressed. Nothing about focus. */ + readonly overlay?: boolean; } interface Mounted { @@ -169,7 +170,7 @@ export function useReplTree(state: ReplState): Operation { regions.set(region, node); } // Drawn after the panes, so they land on top of what they describe. - for (const name of ["chrome:rules", "chrome:focus-marker", "chrome:focus-map"]) { + for (const name of ["chrome:rules", "chrome:focus-map"]) { root.node.createChild(name).set("container", true); } useFocus(root.node); @@ -396,8 +397,15 @@ export function useReplTree(state: ReplState): Operation { return; } } + // The overlay is the tree, walked — by the root, which is the only + // thing that can see it. Its child is handed the result. + const overlay = overlayOf(tree, options.mutation); + // Asked of the tree once. It never reaches a body: it is used here to + // work out which of a parent's children get a marker cell reserved, + // and a body learns only where focus is relative to itself. + const here = current(root.node); for (const child of root.node.children) { - presentChild(child, view, layout, place, options); + presentChild(child, view, layout, place, options, overlay, here); } }, focused: () => current(root.node), @@ -475,6 +483,8 @@ export interface OverlayEntry { /** False where the node is drawn and numbered but cannot take focus. */ readonly enabled: boolean; readonly number: number; + /** Whether this is the node the tree currently reports as focused. */ + readonly focused: boolean; } /** @@ -495,17 +505,20 @@ export function overlayOf(tree: ReplTree, mutation?: Mutation): readonly Overlay label: labelFor(`region:${region}`), enabled: true, number: at + 1, + focused: false, })); } const nodes = tree.map(); const regions = nodes.filter((node) => node.name.startsWith("region:")); const rest = nodes.filter((node) => !node.name.startsWith("region:")); const ordered = regions.length === ROUTE_SURFACES.length ? [...regions, ...rest] : nodes; + const here = tree.focused(); return ordered.map((node, at) => ({ id: node.name, label: labelFor(node.name), enabled: isFocusable(node), number: at + 1, + focused: node === here, })); } @@ -522,6 +535,8 @@ function presentChild( layout: Layout, place: (rect: Rect | undefined) => Placement, options: PresentOptions, + overlay: readonly OverlayEntry[], + here: Node, ): void { const name = child.name; if (name === "region:sessions") { @@ -551,9 +566,14 @@ function presentChild( // simply has nowhere to draw. const taken = view.contextual.drawers.length > 0; attach(child, inputBody, view.contextual.input, place(taken ? undefined : layout.contextual)); + // `Run` is a control of this band, and the band reserves no cell for it. + for (const control of child.children) { + attach(control, controlBody, undefined, place(undefined)); + } return; } if (name === "region:history") { + const placement = place(layout.footer); attach( child, historyBody, @@ -561,10 +581,16 @@ function presentChild( view: view.history, mutation: options.mutation, motion: options.motion, - focus: options.focus, }, - place(layout.footer), + placement, ); + // The band knows where it wrote each bracket, so it is the band that says + // where its controls may draw. They are mounted in the band's own order, + // because both come from the same transport mode. + const cells = transportSlots(view.history, placement); + [...child.children].forEach((control, at) => { + attach(control, controlBody, undefined, place(cells[at])); + }); return; } if (name.startsWith("drawer:")) { @@ -579,7 +605,19 @@ function presentChild( covering && layout.contextual !== undefined && layout.footer !== undefined ? { ...layout.contextual, height: layout.contextual.height + layout.footer.height } : layout.contextual; - attach(child, drawerBody, { view: drawer, focus: options.focus }, place(rect)); + const placement = place(rect); + attach(child, drawerBody, { view: drawer }, placement); + // The form laid its own gutter out, so the form says which cell each of + // its controls owns. Whether there is a gutter at all is the same + // question the drawer's body asks of itself: is focus inside me? + const cells = new Map( + drawerSlots(drawer, placement, holds(child, here)).map((slot) => [slot.id, slot.rect]), + ); + for (const panel of child.children) { + for (const control of panel.children) { + attach(control, controlBody, undefined, place(cells.get(control.name))); + } + } return; } } @@ -604,13 +642,27 @@ function presentChild( attach(child, rulesBody, layout.separators, place(layout.screen)); return; } - if (name === "chrome:focus-marker") { - attach(child, focusMarkerBody, { layout, focus: options.focus }, place(layout.screen)); - return; - } if (name === "chrome:focus-map") { - attach(child, focusMapBody, { layout, focus: options.focus }, place(layout.screen)); + attach( + child, + focusMapBody, + { + entries: overlay, + visible: options.overlay === true, + }, + place(layout.screen), + ); return; } attach(child, outletBody, undefined, place(undefined)); } + +/** True where `target` is `node` or sits somewhere beneath it. */ +function holds(node: Node, target: Node): boolean { + for (let at: Node | undefined = target; at; at = at.parent) { + if (at === node) { + return true; + } + } + return false; +} diff --git a/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt b/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt index 98b20e3ad..1431ade72 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-document.narrow.txt @@ -1,6 +1,6 @@ bindings-document.narrow · 90 × 28 · Bindings at the document scope BINDINGS · 3 / 4 REPL › Entry 1 › document · suspended Tab ▸ - BINDINGS +▌BINDINGS document scope readme diff --git a/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt index 520ddcbef..812ac9891 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-document.wide.txt @@ -2,7 +2,7 @@ bindings-document.wide · 200 × 50 · Bindings at the document scope XMD REPL │ REPL › Entry 1 › document · suspended │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS + │ Entry 1 ● running · 48.9s ↳ document scope suspended │▌BINDINGS SESSIONS · 3 │ │ document scope chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ │ document │ readme diff --git a/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt b/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt index cc9817e69..12d29306c 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-plan.narrow.txt @@ -1,6 +1,6 @@ bindings-plan.narrow · 90 × 28 · Bindings at the Plan scope BINDINGS · 3 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ - BINDINGS +▌BINDINGS Plan scope prompt diff --git a/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt index 3f55a04ba..ada627ef9 100644 --- a/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/bindings-plan.wide.txt @@ -2,7 +2,7 @@ bindings-plan.wide · 200 × 50 · Bindings at the Plan scope XMD REPL │ REPL › Entry 1 › document › Plan · active │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + │ Entry 1 ● running · 31.4s ↳ Plan scope open │▌BINDINGS SESSIONS · 1 │ │ Plan scope chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ │ document │ prompt diff --git a/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt index f2ed3a5de..46f85a0c4 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-confirm.narrow.txt @@ -3,9 +3,9 @@ drawer-confirm.narrow · 90 × 28 · The README confirmation drawer suspended at · document scope Create README.md with the content shown above? - │ # Northstar + │ ▸ # Northstar │ │ A lightweight workspace for coordinating coding agents. - [ Approve ] [ Decline ] + [ Approve ] [ Decline ] ⌘↵ approves · Esc closes the drawer without answering it diff --git a/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt index e2ff72837..5240b779f 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-confirm.wide.txt @@ -34,11 +34,11 @@ drawer-confirm.wide · 200 × 50 · The README confirmation drawer │ suspended at · document scope │ │ Create README.md with the content shown above? - │ │ # Northstar + │ │ ▸ # Northstar │ │ │ │ A lightweight workspace for coordinating coding agents. │ - │ [ Approve ] [ Decline ] + │ [ Approve ] [ Decline ] │ ⌘↵ approves · Esc closes the drawer without answering it │ │ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt index 615ef63e6..e04c1de3f 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.narrow.txt @@ -5,9 +5,9 @@ drawer-project.narrow · 90 × 28 · The project Elicit drawer Enter the project details. - Project name + ▸ Project name ┃ Northstar - Description + Description ┃ A lightweight workspace for coordinating coding agents. - both fields valid Submit ⌘↵ + both fields valid Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt index 304f9d6b2..203a9c66a 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-project.wide.txt @@ -35,14 +35,14 @@ drawer-project.wide · 200 × 50 · The project Elicit drawer │ │ Enter the project details. │ - │ Project name + │ ▸ Project name │ ┃ Northstar - │ Description + │ Description │ ┃ A lightweight workspace for coordinating coding agents. │ - │ both fields valid Submit ⌘↵ + │ both fields valid Submit ⌘↵ │ - │ schema + │ schema │ { EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt index 6f92b1427..34f799f33 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.narrow.txt @@ -2,7 +2,7 @@ drawer-review.narrow · 90 × 28 · The plan-review Elicit drawer REVIEW REQUIRED suspended at · Plan scope · 59 lines returned - # Create a project README + ▸ # Create a project README Provide the project name and a one-sentence description. @@ -10,8 +10,8 @@ drawer-review.narrow · 90 × 28 · The plan-review Elicit drawer Enter the project details. ▸ 53 more lines · ⌥↓ scrolls the Plan - (•) Approve - ( ) Request changes - ( ) Stop + (•) Approve + ( ) Request changes + ( ) Stop - Submit ⌘↵ + Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt index 4df7e6f0e..2067881f3 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-review.wide.txt @@ -33,7 +33,7 @@ drawer-review.wide · 200 × 50 · The plan-review Elicit drawer │ REVIEW REQUIRED │ suspended at · Plan scope · 59 lines returned │ - │ # Create a project README + │ ▸ # Create a project README │ │ Provide the project name and a one-sentence description. │ @@ -41,9 +41,9 @@ drawer-review.wide · 200 × 50 · The plan-review Elicit drawer │ Enter the project details. │ ▸ 53 more lines · ⌥↓ scrolls the Plan │ - │ (•) Approve - │ ( ) Request changes - │ ( ) Stop + │ (•) Approve + │ ( ) Request changes + │ ( ) Stop EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:31 │ │ ┃ 00:31 Entry 1 │ │ │ ┃ diff --git a/scripts/tests/fixtures/repl-catalog/empty.narrow.txt b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt index 1494d60aa..01f9afd68 100644 --- a/scripts/tests/fixtures/repl-catalog/empty.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/empty.narrow.txt @@ -24,5 +24,5 @@ empty.narrow · 90 × 28 · An empty REPL, before anything has run - REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] +▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] Enter XMD or invoke a document… diff --git a/scripts/tests/fixtures/repl-catalog/empty.wide.txt b/scripts/tests/fixtures/repl-catalog/empty.wide.txt index e391d0204..5d7f18e9f 100644 --- a/scripts/tests/fixtures/repl-catalog/empty.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/empty.wide.txt @@ -40,7 +40,7 @@ empty.wide · 200 × 50 · An empty REPL, before anything has run │ │ │ │ │ │ - │ REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] + │▌REPL INPUT ⇧⏎ newline [ Run ⌘⏎ ] │ Enter XMD or invoke a document… │ │ diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt index 4f8771a79..fda6f00cd 100644 --- a/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/inspecting.narrow.txt @@ -1,6 +1,6 @@ inspecting.narrow · 90 × 28 · Historical inspection, reconstructed and read-only EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ - HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] +▌HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] 00:53 ││ ││┃ 00:53 Entry 1 ││ │ ││┃ ─◆●─●●───2─≈●─●●┃ diff --git a/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt index d85924a7f..83617091c 100644 --- a/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/inspecting.wide.txt @@ -44,7 +44,7 @@ inspecting.wide · 200 × 50 · Historical inspection, reconstructed and read-on │ │ │ - EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] +▌EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] recorded · 00:53 │ │ │ │ │┃ 00:53 Entry 1 │ │ │ │ │ │┃ ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ diff --git a/scripts/tests/fixtures/repl-catalog/nested.narrow.txt b/scripts/tests/fixtures/repl-catalog/nested.narrow.txt index 462c45b05..03655ad5e 100644 --- a/scripts/tests/fixtures/repl-catalog/nested.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/nested.narrow.txt @@ -1,6 +1,6 @@ nested.narrow · 90 × 28 · Nested execution, with the Plan scope open TRANSCRIPT · 2 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ - Entry 1 ● running · 31.4s ↳ Plan scope open +▌Entry 1 ● running · 31.4s ↳ Plan scope open ← opened from Entry 1 · live execution projection, not an editor document diff --git a/scripts/tests/fixtures/repl-catalog/nested.wide.txt b/scripts/tests/fixtures/repl-catalog/nested.wide.txt index c8fde160e..a1c956534 100644 --- a/scripts/tests/fixtures/repl-catalog/nested.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/nested.wide.txt @@ -2,7 +2,7 @@ nested.wide · 200 × 50 · Nested execution, with the Plan scope open XMD REPL │ REPL › Entry 1 › document › Plan · active │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + │▌Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS SESSIONS · 1 │ │ Plan scope chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ │ document │ prompt diff --git a/scripts/tests/fixtures/repl-catalog/sessions.narrow.txt b/scripts/tests/fixtures/repl-catalog/sessions.narrow.txt index 3866d51e0..65cc03f71 100644 --- a/scripts/tests/fixtures/repl-catalog/sessions.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/sessions.narrow.txt @@ -1,6 +1,6 @@ sessions.narrow · 90 × 28 · Three concurrent Agent sessions SESSIONS · 1 / 4 REPL › Entry 1 › document · suspended Tab ▸ - SESSION JOURNAL STATE +▌SESSION JOURNAL STATE SESSIONS · 3 diff --git a/scripts/tests/fixtures/repl-catalog/sessions.wide.txt b/scripts/tests/fixtures/repl-catalog/sessions.wide.txt index 03ce6c6fa..dc0db76ef 100644 --- a/scripts/tests/fixtures/repl-catalog/sessions.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/sessions.wide.txt @@ -1,5 +1,5 @@ sessions.wide · 200 × 50 · Three concurrent Agent sessions - XMD REPL │ REPL › Entry 1 › document · suspended +▌XMD REPL │ REPL › Entry 1 › document · suspended │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ Entry 1 ● running · 48.9s ↳ document scope suspended │ BINDINGS diff --git a/scripts/tests/fixtures/repl-catalog/settled.narrow.txt b/scripts/tests/fixtures/repl-catalog/settled.narrow.txt index 286b06095..35afbff2c 100644 --- a/scripts/tests/fixtures/repl-catalog/settled.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/settled.narrow.txt @@ -24,5 +24,5 @@ settled.narrow · 90 × 28 · A settled entry, the input ready for the next one - REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] +▌REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] diff --git a/scripts/tests/fixtures/repl-catalog/settled.wide.txt b/scripts/tests/fixtures/repl-catalog/settled.wide.txt index f8adbb72f..5650fdd76 100644 --- a/scripts/tests/fixtures/repl-catalog/settled.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/settled.wide.txt @@ -40,7 +40,7 @@ settled.wide · 200 × 50 · A settled entry, the input ready for the next one │ │ │ │ │ │ - │ REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] + │▌REPL INPUT ready for Entry 2 [ Run ⌘⏎ ] │ │ │ diff --git a/scripts/tests/fixtures/repl-study/drawer.medium.txt b/scripts/tests/fixtures/repl-study/drawer.medium.txt index 2ae783bd1..6b61e4c00 100644 --- a/scripts/tests/fixtures/repl-study/drawer.medium.txt +++ b/scripts/tests/fixtures/repl-study/drawer.medium.txt @@ -23,12 +23,12 @@ drawer.medium · 140 × 38 │ │ Enter the project details. │ - │ Project name + │ ▸ Project name │ ┃ Northstar - │ Description + │ Description │ ┃ A lightweight workspace for coordinating coding agents. │ - │ both fields valid Submit ⌘↵ + │ both fields valid Submit ⌘↵ │ │ │ diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt index ec8a37527..f4c981240 100644 --- a/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.sessions.txt @@ -1,6 +1,6 @@ drawer.narrow.sessions · 90 × 28 SESSIONS · 1 / 4 REPL › Entry 1 › document · suspended Tab ▸ - SESSION JOURNAL STATE +▌SESSION JOURNAL STATE SESSIONS · 3 diff --git a/scripts/tests/fixtures/repl-study/drawer.narrow.txt b/scripts/tests/fixtures/repl-study/drawer.narrow.txt index 89458114f..b2b79a441 100644 --- a/scripts/tests/fixtures/repl-study/drawer.narrow.txt +++ b/scripts/tests/fixtures/repl-study/drawer.narrow.txt @@ -5,9 +5,9 @@ drawer.narrow · 90 × 28 Enter the project details. - Project name + ▸ Project name ┃ Northstar - Description + Description ┃ A lightweight workspace for coordinating coding agents. - both fields valid Submit ⌘↵ + both fields valid Submit ⌘↵ diff --git a/scripts/tests/fixtures/repl-study/drawer.wide.txt b/scripts/tests/fixtures/repl-study/drawer.wide.txt index af9420711..3c05e2f3d 100644 --- a/scripts/tests/fixtures/repl-study/drawer.wide.txt +++ b/scripts/tests/fixtures/repl-study/drawer.wide.txt @@ -35,14 +35,14 @@ drawer.wide · 200 × 50 │ │ Enter the project details. │ - │ Project name + │ ▸ Project name │ ┃ Northstar - │ Description + │ Description │ ┃ A lightweight workspace for coordinating coding agents. │ - │ both fields valid Submit ⌘↵ + │ both fields valid Submit ⌘↵ │ - │ schema + │ schema │ { EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 diff --git a/scripts/tests/fixtures/repl-study/empty.medium.txt b/scripts/tests/fixtures/repl-study/empty.medium.txt index 4371d2269..b38eeafcd 100644 --- a/scripts/tests/fixtures/repl-study/empty.medium.txt +++ b/scripts/tests/fixtures/repl-study/empty.medium.txt @@ -2,7 +2,7 @@ empty.medium · 140 × 38 SESSION JOURNAL STATE │ REPL │ No sessions yet │───────────────────────────────────────────────────────────────────────────────────────────────────────── - │ TRANSCRIPT │ BINDINGS + │▌TRANSCRIPT │ BINDINGS Agent sessions appear here as │ │ REPL scope executions open them. │ No executions yet. │ They persist after an entry │ │ No REPL bindings yet diff --git a/scripts/tests/fixtures/repl-study/empty.narrow.txt b/scripts/tests/fixtures/repl-study/empty.narrow.txt index 6031a9db4..5726c5120 100644 --- a/scripts/tests/fixtures/repl-study/empty.narrow.txt +++ b/scripts/tests/fixtures/repl-study/empty.narrow.txt @@ -1,6 +1,6 @@ empty.narrow · 90 × 28 TRANSCRIPT · 2 / 4 REPL Tab ▸ - TRANSCRIPT +▌TRANSCRIPT No executions yet. diff --git a/scripts/tests/fixtures/repl-study/empty.wide.txt b/scripts/tests/fixtures/repl-study/empty.wide.txt index 259867759..b75d5cac6 100644 --- a/scripts/tests/fixtures/repl-study/empty.wide.txt +++ b/scripts/tests/fixtures/repl-study/empty.wide.txt @@ -2,7 +2,7 @@ empty.wide · 200 × 50 XMD REPL │ REPL │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ TRANSCRIPT │ BINDINGS + │▌TRANSCRIPT │ BINDINGS No sessions yet │ │ REPL scope │ No executions yet. │ Agent sessions appear here as executions open │ │ No REPL bindings yet diff --git a/scripts/tests/fixtures/repl-study/generated.medium.txt b/scripts/tests/fixtures/repl-study/generated.medium.txt index 49117ccc3..8a024ccad 100644 --- a/scripts/tests/fixtures/repl-study/generated.medium.txt +++ b/scripts/tests/fixtures/repl-study/generated.medium.txt @@ -2,7 +2,7 @@ generated.medium · 140 × 38 SESSION JOURNAL STATE │ REPL › Entry 1 › document · active │ SESSIONS · 2 │───────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS + │▌Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS │ plan-a91f7c │ │ document scope ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ │ document │ admitted diff --git a/scripts/tests/fixtures/repl-study/generated.narrow.txt b/scripts/tests/fixtures/repl-study/generated.narrow.txt index dda99d6cc..3fd0ef967 100644 --- a/scripts/tests/fixtures/repl-study/generated.narrow.txt +++ b/scripts/tests/fixtures/repl-study/generated.narrow.txt @@ -1,6 +1,6 @@ generated.narrow · 90 × 28 TRANSCRIPT · 2 / 4 REPL › Entry 1 › document · active Tab ▸ - Entry 1 ● running · 48.1s ↳ document scope open +▌Entry 1 ● running · 48.1s ↳ document scope open ← opened from Entry 1 · live execution projection, not an editor document diff --git a/scripts/tests/fixtures/repl-study/generated.wide.txt b/scripts/tests/fixtures/repl-study/generated.wide.txt index 4f64577ff..358078589 100644 --- a/scripts/tests/fixtures/repl-study/generated.wide.txt +++ b/scripts/tests/fixtures/repl-study/generated.wide.txt @@ -2,7 +2,7 @@ generated.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document · active │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS + │▌Entry 1 ● running · 48.1s ↳ document scope open │ BINDINGS SESSIONS · 2 │ │ document scope chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ │ document │ admitted diff --git a/scripts/tests/fixtures/repl-study/nested.medium.txt b/scripts/tests/fixtures/repl-study/nested.medium.txt index e1d36bc66..03949ffe2 100644 --- a/scripts/tests/fixtures/repl-study/nested.medium.txt +++ b/scripts/tests/fixtures/repl-study/nested.medium.txt @@ -2,7 +2,7 @@ nested.medium · 140 × 38 SESSION JOURNAL STATE │ REPL › Entry 1 › document › Plan · active │ SESSIONS · 1 │───────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + │▌Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS │ plan-a91f7c │ │ Plan scope ✓ completed planner │ ← opened from Entry 1 · live execution projection, not an editor │ │ document │ prompt diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt index fe55ac798..853adccee 100644 --- a/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt +++ b/scripts/tests/fixtures/repl-study/nested.narrow.bindings.txt @@ -1,6 +1,6 @@ nested.narrow.bindings · 90 × 28 BINDINGS · 3 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ - BINDINGS +▌BINDINGS Plan scope prompt diff --git a/scripts/tests/fixtures/repl-study/nested.narrow.txt b/scripts/tests/fixtures/repl-study/nested.narrow.txt index 827f51dd8..63fb964bf 100644 --- a/scripts/tests/fixtures/repl-study/nested.narrow.txt +++ b/scripts/tests/fixtures/repl-study/nested.narrow.txt @@ -1,6 +1,6 @@ nested.narrow · 90 × 28 TRANSCRIPT · 2 / 4 REPL › Entry 1 › document › Plan · active Tab ▸ - Entry 1 ● running · 31.4s ↳ Plan scope open +▌Entry 1 ● running · 31.4s ↳ Plan scope open ← opened from Entry 1 · live execution projection, not an editor document diff --git a/scripts/tests/fixtures/repl-study/nested.wide.txt b/scripts/tests/fixtures/repl-study/nested.wide.txt index 30c24d319..7f0a7a1c1 100644 --- a/scripts/tests/fixtures/repl-study/nested.wide.txt +++ b/scripts/tests/fixtures/repl-study/nested.wide.txt @@ -2,7 +2,7 @@ nested.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document › Plan · active │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS + │▌Entry 1 ● running · 31.4s ↳ Plan scope open │ BINDINGS SESSIONS · 1 │ │ Plan scope chronological · selection follows you, not ac… │ ← opened from Entry 1 · live execution projection, not an editor │ │ document │ prompt diff --git a/scripts/tests/fixtures/repl-study/paused.medium.txt b/scripts/tests/fixtures/repl-study/paused.medium.txt index b2603a20d..31c5d7b95 100644 --- a/scripts/tests/fixtures/repl-study/paused.medium.txt +++ b/scripts/tests/fixtures/repl-study/paused.medium.txt @@ -2,7 +2,7 @@ paused.medium · 140 × 38 SESSION JOURNAL STATE │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY │ ENTRY 1 · CREATE PROJECT README │───────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS + │▌Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS 00:02 ◆ Entry 1 submitted │ │ Plan scope · as recorded 00:05 ● document scope entered │ reconstructed from the journal · no live action is possible here │ 00:12 ● Plan entered │ Plan │ prompt diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.history.txt b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt index d23a72ead..56a3a770b 100644 --- a/scripts/tests/fixtures/repl-study/paused.narrow.history.txt +++ b/scripts/tests/fixtures/repl-study/paused.narrow.history.txt @@ -1,6 +1,6 @@ paused.narrow.history · 90 × 28 EXECUTION HISTORY · 4 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ - HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] +▌HISTORY │ ┃ PAUSED HEAD INSPECTING [ Continue ] [ Return ] [ Fork ] 00:53 ││ ││┃ 00:53 Entry 1 ││ │ ││┃ ─◆●─●●───2─≈●─●●┃ diff --git a/scripts/tests/fixtures/repl-study/paused.narrow.txt b/scripts/tests/fixtures/repl-study/paused.narrow.txt index 5d7224093..db0e74693 100644 --- a/scripts/tests/fixtures/repl-study/paused.narrow.txt +++ b/scripts/tests/fixtures/repl-study/paused.narrow.txt @@ -1,6 +1,6 @@ paused.narrow · 90 × 28 TRANSCRIPT · 2 / 4 RECONSTRUCTED AT 00:12 · READ-ONLY Tab ▸ - Entry 1 ● running · 53.0s ↳ reconstructed · read-only +▌Entry 1 ● running · 53.0s ↳ reconstructed · read-only reconstructed from the journal · no live action is possible here Plan diff --git a/scripts/tests/fixtures/repl-study/paused.wide.txt b/scripts/tests/fixtures/repl-study/paused.wide.txt index fa11e9955..2767d1b1f 100644 --- a/scripts/tests/fixtures/repl-study/paused.wide.txt +++ b/scripts/tests/fixtures/repl-study/paused.wide.txt @@ -2,7 +2,7 @@ paused.wide · 200 × 50 XMD REPL │ REPL › Entry 1 › document › Plan RECONSTRUCTED AT 00:12 · READ-ONLY │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS + │▌Entry 1 ● running · 53.0s ↳ reconstructed · read-only │ BINDINGS ENTRY 1 · CREATE PROJECT README │ │ Plan scope · as recorded inspecting recorded history · read-only │ reconstructed from the journal · no live action is possible here │ │ Plan │ prompt diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt index 4b012e3ff..5c158f99e 100644 --- a/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt @@ -35,14 +35,14 @@ play.generated-drawer.midpoint · 200 × 50 │ │ Enter the project details. │ - │ Project name + │ ▸ Project name │ ┃ Northstar - │ Description + │ Description │ ┃ A lightweight workspace for coordinating coding agents. │ - │ both fields valid Submit ⌘↵ + │ both fields valid Submit ⌘↵ │ - │ schema + │ schema │ { EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:48 diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt index ad1c7ed0d..28a00d3dc 100644 --- a/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.settled.txt @@ -35,14 +35,14 @@ play.generated-drawer.settled · 200 × 50 │ │ Enter the project details. │ - │ Project name + │ ▸ Project name │ ┃ Northstar - │ Description + │ Description │ ┃ A lightweight workspace for coordinating coding agents. │ - │ both fields valid Submit ⌘↵ + │ both fields valid Submit ⌘↵ │ - │ schema + │ schema │ { EXECUTION HISTORY │ ┃ LIVE LIVE [ Pause ] recorded · 00:49 │ │ │ ┃ 00:49 diff --git a/scripts/tests/fixtures/repl-study/settled.medium.txt b/scripts/tests/fixtures/repl-study/settled.medium.txt index 0a9251f6b..0248bb60f 100644 --- a/scripts/tests/fixtures/repl-study/settled.medium.txt +++ b/scripts/tests/fixtures/repl-study/settled.medium.txt @@ -2,7 +2,7 @@ settled.medium · 140 × 38 SESSION JOURNAL STATE │ REPL · Entry 1 settled │ SESSIONS · 3 │───────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS + │▌Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS │ plan-a91f7c │ │ REPL scope ✓ completed planner │ Create a project README │ │ Provide the project name and a one-sentence description. │ Entry 1 published none diff --git a/scripts/tests/fixtures/repl-study/settled.narrow.txt b/scripts/tests/fixtures/repl-study/settled.narrow.txt index 9fc409451..050af2c27 100644 --- a/scripts/tests/fixtures/repl-study/settled.narrow.txt +++ b/scripts/tests/fixtures/repl-study/settled.narrow.txt @@ -1,6 +1,6 @@ settled.narrow · 90 × 28 TRANSCRIPT · 2 / 4 REPL · Entry 1 settled Tab ▸ - Entry 1 ✓ completed · 41.2s ▸ source · 8 lines +▌Entry 1 ✓ completed · 41.2s ▸ source · 8 lines Create a project README Provide the project name and a one-sentence description. diff --git a/scripts/tests/fixtures/repl-study/settled.wide.txt b/scripts/tests/fixtures/repl-study/settled.wide.txt index cacb41ace..2b03ca11b 100644 --- a/scripts/tests/fixtures/repl-study/settled.wide.txt +++ b/scripts/tests/fixtures/repl-study/settled.wide.txt @@ -2,7 +2,7 @@ settled.wide · 200 × 50 XMD REPL │ REPL · Entry 1 settled │ SESSION JOURNAL STATE │─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - │ Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS + │▌Entry 1 ✓ completed · 41.2s ▸ source · 8 lines │ BINDINGS SESSIONS · 3 │ │ REPL scope persist after settling │ Create a project README │ │ Provide the project name and a one-sentence description. │ Entry 1 published none diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index 8b27b7042..dd9e49bbd 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -122,8 +122,17 @@ describe("a component is a body on a node", () => { walk(node); // A body that held the node could create children, remove itself, set props // or reach its scope; the tree's authority would be advisory. - expect(Object.keys(seen).toSorted()).toEqual(["children", "data", "placement", "self"]); + expect(Object.keys(seen).toSorted()).toEqual([ + "children", + "data", + "focus", + "placement", + "self", + ]); expect(Object.keys(seen.self as object).toSorted()).toEqual(["id", "name"]); + // Focus arrives as a relation to this node and nothing else: a word, never + // a node, an identity or a map of where everything else is. + expect(seen.focus).toBe("outside"); }); it("renders the REPL by walking the mounted tree", function* () { @@ -435,7 +444,14 @@ describe("one mounted tree answers everything", () => { }, ); walk(node); - expect(Object.keys(seen).toSorted()).toEqual(["children", "data", "placement", "self"]); + expect(Object.keys(seen).toSorted()).toEqual([ + "children", + "data", + "focus", + "placement", + "self", + ]); + expect(seen.focus).toBe("outside"); // Its own data, not the projection every other component was built from. expect(seen.data).toEqual({ only: "mine" }); expect(Object.keys(seen.self as object).toSorted()).toEqual(["id", "name"]); diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index 035ebdd0e..e98750040 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -24,7 +24,15 @@ import type { Operation } from "effection"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { captureFocus, captureText, PROFILE_SIZES, renderFrame } from "../repl-study/capture.ts"; +import { + captureFocus, + captureText, + composeInto, + PROFILE_SIZES, + renderInto, + useTerm, +} from "../repl-study/capture.ts"; +import type { FrameRequest } from "../repl-study/capture.ts"; import { FRAMES, frame, stateFor, useFrame } from "../repl-study/frames.ts"; import { openingState, scanKeys } from "../repl-study/host.ts"; import { fold, JOURNAL, journalThrough, markers, siblingsOf } from "../repl-study/journal.ts"; @@ -79,6 +87,36 @@ function* opened( return { state, tree }; } +/** + * One frame, drawn by the tree that owns it. + * + * `extra` is what a caller might still try to tell the renderer. It is spread + * over a complete request, so anything it carries is carried all the way to + * `paint`. + */ +function* shot( + tree: ReplTree, + state: ReplState, + extra: Record = {}, +): Operation { + const term = yield* useTerm(WIDE); + const fixture = fixtureFor(state); + const view = viewOf(state); + return renderInto(term, { + fixture, + view, + size: WIDE, + overlay: true, + composition: composeInto(tree, fixture, view), + ...extra, + } as FrameRequest).text; +} + +/** The overlay's own row for one entry, as the map draws it. */ +function overlayRow(entry: { readonly number: number; readonly label: string }): string { + return `\u25b8 ${String(entry.number).padEnd(3)}${entry.label}`; +} + /** The identities Tab walks, in tree order. */ function chain(tree: ReplTree): string[] { return tree.chain().map((node) => node.name); @@ -968,27 +1006,42 @@ describe("the frames, as pictures", () => { } }); - it("draws the focused region and the numbered map", function* () { - const subject = frame("12")!; - const { state, tree } = yield* useFrame(subject); - const rendered = yield* renderFrame({ - fixture: fixtureFor(state), - view: viewOf(state), - size: WIDE, - focus: { here: tree.focused().name, map: overlayOf(tree), overlay: true }, + it("cannot be told where focus is, from outside or from a moment ago", function* () { + // #839 handed the renderer a `FocusView`: an identity and a numbered map, + // worked out somewhere else and threaded down through every frame request. + // The control is to try that again. There is nowhere left for it to land, + // and the proof is bytes: a frame drawn with a stale claim attached is the + // frame drawn without one. + const { state, tree } = yield* useFrame(frame("01")!); + const stale = overlayOf(tree).find((entry) => entry.focused)!; + const before = yield* shot(tree, state); + expect(before).toContain(overlayRow(stale)); + + tree.advance(); + const after = yield* shot(tree, state); + // Focus moved, and the picture moved with it, because the picture asked. + expect(after).not.toContain(overlayRow(stale)); + + const claimed = yield* shot(tree, state, { + focus: { here: stale.id, map: [stale], overlay: true }, }); - expect(rendered.text).toContain("FOCUS MAP"); - expect(rendered.text).toContain("Fork from here"); + expect(claimed).toBe(after); }); - it("says nothing about focus in a frame that was not asked about it", function* () { - const state = stateFor(frame("07")!); - const rendered = yield* renderFrame({ - fixture: fixtureFor(state), - view: viewOf(state), - size: WIDE, - }); - expect(rendered.text).not.toContain("FOCUS MAP"); + it("draws the focused region and the numbered map", function* () { + const { state, tree } = yield* useFrame(frame("12")!); + const rendered = yield* shot(tree, state); + expect(rendered).toContain("FOCUS MAP"); + expect(rendered).toContain("Fork from here"); + }); + + // The overlay is ordinary UI state, so a frame that did not ask for it does + // not get it. A frame cannot be silent about focus itself any more: focus is + // the tree's, the tree always has one, and drawing from the tree draws it. + it("draws no focus map in a frame that did not ask for one", function* () { + const { state, tree } = yield* useFrame(frame("07")!); + const rendered = yield* shot(tree, state, { overlay: false }); + expect(rendered).not.toContain("FOCUS MAP"); }); }); From 18a1aa248f7df05bc5b3728ef1cb592d684fb5ea Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 14:36:56 -0400 Subject: [PATCH 21/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Let=20each=20parent?= =?UTF-8?q?=20place=20its=20own=20children,=20and=20every=20focus=20target?= =?UTF-8?q?=20show=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root was reaching through its children to attach bodies and boxes to grandchildren. Each parent now places its own: the input band places `Run`, the Execution History band places its transport controls, the drawer places its panel and its way out, and the panel places the form's fields and buttons. Those closures are installed by the lifecycle that created the node, which is the one thing allowed to hold it — a render body still receives none. The root presents its direct children and asks each of them to present theirs. A child's box now comes from its parent's (`within`), so the density and the profile it is drawn under are the composition's rather than looked up again. Two focus targets could hold the keyboard invisibly: - `Run` had no cell at all. It gets the space inside its own bracket — `[▸Run ⌘⏎ ]` — the bargain the transport controls already strike, so the affordance keeps its twelve columns whether it is focused or not. - A drawer traps focus and carries its own Execution History target, a different node from the band outside it. It now draws its own `▌` over the band it is the way back to, so reaching it looks like reaching the band — which is what it does. That corrects one catalog capture: the recorded drawer's only focusable node is that target, so focus has been sitting there unshown. Both are proved with the focus map closed, because the map is the thing that was covering for them. The stale-focus control no longer casts: the obsolete field rides on an inferred request variable. All nineteen focus captures and all twenty-six study captures still reproduce byte for byte. Known gap, not corrected here: at the narrow profile the footer is composed only on the history surface, so a drawer's way-out target is in the ring with its destination off screen. --- scripts/repl-study/component.ts | 46 +++++++++ scripts/repl-study/components.ts | 20 +++- scripts/repl-study/render.ts | 58 ++++++++--- scripts/repl-study/tree.ts | 96 ++++++++++++------- .../repl-catalog/drawer-historical.wide.txt | 2 +- scripts/tests/repl-focus.test.ts | 61 +++++++++--- 6 files changed, 225 insertions(+), 58 deletions(-) diff --git a/scripts/repl-study/component.ts b/scripts/repl-study/component.ts index e65118d14..da90c4493 100644 --- a/scripts/repl-study/component.ts +++ b/scripts/repl-study/component.ts @@ -138,6 +138,52 @@ export function attach( }; } +/** + * How a parent presents its own children. + * + * Installed by the lifecycle that created the node, which is the one thing + * holding it. A render body may not receive a Freedom node; a lifecycle may, + * and presenting children is a lifecycle's work — it is where a parent decides + * which of its children exist on screen, what each of them is given and where + * each of them may draw. + * + * Nothing walks the tree to do this. Each parent is asked, and asks its own + * children in turn, so no presentation reaches past a direct child. + */ +export type Presentation = (data: Data, placement: Placement) => void; + +const presenterKey = createNodeData>("xmd:repl:presents"); + +export function presents(node: Node, presentation: Presentation): void { + node.data.set(presenterKey, presentation as Presentation); +} + +/** + * Ask one node to present its own children. + * + * A node with no children to place has nothing installed, and this does + * nothing — a leaf is not a special case. + */ +export function presentOwn(node: Node, data: Data, placement: Placement): void { + const presentation = node.data.get(presenterKey); + presentation?.(data as never, placement); +} + +/** + * One child's box, inside its parent's. + * + * The density and the profile are the parent's, because they are facts about + * the composition a child was placed into. Only the rectangle is the parent's + * decision, and a child given none has nowhere to draw. + */ +export function within(parent: Placement, rect: Rect | undefined): Placement { + return { + rect: rect ?? { x: 0, y: 0, width: 0, height: 0 }, + dense: parent.dense, + profile: parent.profile, + }; +} + export function hasBody(node: Node): boolean { return node.data.get(bodyKey) !== undefined; } diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index bd2ea3edf..3b1cdc3c6 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -22,6 +22,7 @@ import { drawerRegion, focusMapRegion, focusMark, + regionMark, inputRegion, rule, surfaceBarRegion, @@ -328,6 +329,20 @@ export const drawerBody: Body = ({ self, data, placement, children, ...children, ]; +/** + * The way out of a drawer. + * + * A drawer traps focus, so the Execution History band outside it is + * unreachable; the drawer carries a region of its own that names the same band + * and is inside the trap. It draws nothing but its own marker, over the band it + * is a way back to — so reaching it looks exactly like reaching the band, + * which is what it does. + */ +export const escapeBody: Body = ({ self, placement, children, focus }) => [ + ...(focus === "self" ? regionMark(`${self.id}.focus`, placement.rect) : []), + ...children, +]; + /** * One focusable control: a field, a button, a transport action. * @@ -336,8 +351,9 @@ export const drawerBody: Body = ({ self, data, placement, children, * say. For a parent to draw this it would have to be told which of its children * was focused, and that is the knowledge tree traversal deliberately withholds. * - * A control whose parent reserved no cell draws nothing, which is how the - * `Run` affordance behaves: the input band spends no column on a gutter. + * A control whose parent reserved no cell draws nothing. Every focusable + * control in this interface has one, because keyboard focus that nothing shows + * is focus a person has to guess at. */ export const controlBody: Body = ({ self, placement, children, focus }) => [ ...(focus === "self" ? focusMark(`${self.id}.mark`, placement.rect) : []), diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index ccae59b07..39d8f875a 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -217,20 +217,36 @@ export function region( ops.push(...(options.children ?? [])); ops.push(close()); if (options.focused === true) { - // A glyph rather than a colour, so focus survives a monochrome terminal and - // a committed `.txt` capture. - ops.push( - open(`${id}.focus`, { - layout: { width: fixed(1), height: fixed(1) }, - floating: { x: rect.x, y: rect.y, attachTo: "root" }, - }), - text("▌", { color: C.focus }), - close(), - ); + ops.push(...regionMark(`${id}.focus`, rect)); } return ops; } +/** The glyph a focused region wears at its top-left corner. */ +const REGION_MARK = "\u258c"; + +/** + * A focused region's own marker. + * + * A glyph rather than a colour, so focus survives a monochrome terminal and a + * committed `.txt` capture. It is separate from `region()` because a region is + * not always the thing that draws its own box: the way out of a drawer is a + * region of the drawer's, drawn over the band it names. + */ +export function regionMark(id: string, rect: Rect): Op[] { + if (rect.width <= 0 || rect.height <= 0) { + return []; + } + return [ + open(id, { + layout: { width: fixed(1), height: fixed(1) }, + floating: { x: rect.x, y: rect.y, attachTo: "root" }, + }), + text(REGION_MARK, { color: C.focus }), + close(), + ]; +} + export function rule(id: string, rect: Rect, glyph: string): Op[] { const ops: Op[] = [ open(id, { @@ -675,6 +691,9 @@ export function drawerRegion( }); } +/** The columns the `Run` affordance is written in, focused or not. */ +const RUN_WIDTH = 12; + /** The REPL input band, which the drawer takes over while one is open. */ export function inputRegion( id: string, @@ -692,7 +711,7 @@ export function inputRegion( { text: input.run !== undefined ? "[ Run ⌘⏎ ]" : "[ Run ]", color: input.run !== undefined ? C.tick : C.dim, - width: 12, + width: RUN_WIDTH, }, ], }, @@ -701,6 +720,23 @@ export function inputRegion( return region(id, rect, lines, { bg: BG.input, transition: DRAWER_TRANSITION, focused }); } +/** + * The cell the `Run` affordance's own control may draw its marker in. + * + * The band writes `[ Run ⌘⏎ ]` in a fixed twelve-column segment at the right + * end of its first line, so the marker replaces the space inside the bracket + * and the affordance keeps its width — the same bargain the transport controls + * strike, and for the same reason. + */ +export function inputSlot(placement: Placement): Rect | undefined { + const rect = placement.rect; + const inner = Math.max(0, rect.width - 2); + if (inner < RUN_WIDTH + 1) { + return undefined; + } + return { x: rect.x + 1 + (inner - RUN_WIDTH) + 1, y: rect.y, width: 1, height: 1 }; +} + export function clock(seconds: number): string { const minutes = Math.floor(Math.max(0, seconds) / 60); const rest = Math.floor(Math.max(0, seconds) % 60); diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 54f19759b..21c8ea06b 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -37,12 +37,13 @@ import type { Node, PopFocus, Root } from "./vendor/freedom/upstream/index.ts"; import { drawerTargets, labelFor } from "./surfaces.ts"; import { recordPath } from "./keys.ts"; -import { attach, placementOf } from "./component.ts"; +import { attach, placementOf, presentOwn, presents, within } from "./component.ts"; import type { Placement } from "./component.ts"; import { bindingsBody, controlBody, drawerBody, + escapeBody, focusMapBody, headerBody, historyBody, @@ -56,8 +57,8 @@ import { transcriptBody, } from "./components.ts"; import type { Motion } from "./playback.ts"; -import type { ReplView } from "./view.ts"; -import { drawerSlots, transportSlots } from "./render.ts"; +import type { DrawerView, HistoryView, InputView, ReplView } from "./view.ts"; +import { drawerSlots, inputSlot, transportSlots } from "./render.ts"; import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; import type { ReplState } from "./store.ts"; @@ -138,6 +139,18 @@ export interface PresentOptions { readonly overlay?: boolean; } +/** + * What the composition tells a drawer. + * + * `escape` is the box the way out is drawn over. The root composed both the + * drawer and the band, so the root is what knows where the band is; the drawer + * decides that its own escape child sits there. + */ +interface DrawerPresentation { + readonly view: DrawerView; + readonly escape: Rect | undefined; +} + interface Mounted { readonly node: Node; readonly pop?: PopFocus; @@ -175,6 +188,28 @@ export function useReplTree(state: ReplState): Operation { } useFocus(root.node); + // Each band places its own controls. These closures hold the node the + // lifecycle created — which is what a lifecycle may do and a render body + // may not — so nothing outside reaches past a direct child to say where a + // control draws. + const inputRegionNode = regions.get("input")!; + presents(inputRegionNode, (input, placement) => { + const cell = inputSlot(placement); + for (const control of inputRegionNode.children) { + attach(control, controlBody, undefined, within(placement, cell)); + } + }); + const historyRegionNode = regions.get("history")!; + presents(historyRegionNode, (history, placement) => { + // The band knows where it wrote each bracket, so the band says where + // its controls may draw. They are mounted in the band's own order, + // because both come from the same transport mode. + const cells = transportSlots(history, placement); + [...historyRegionNode.children].forEach((control, at) => { + attach(control, controlBody, undefined, within(placement, cells[at])); + }); + }); + let drawers: Mounted[] = []; let scopes: Node[] = []; @@ -365,6 +400,26 @@ export function useReplTree(state: ReplState): Operation { // that stays valid while a recorded moment is open. const footer = node.createChild("region:history"); focusable(footer); + + // The panel places the controls it created, and the drawer places the + // panel and the way out. Each closure holds only its own node. + presents>(body, (cells, placement) => { + for (const control of body.children) { + attach(control, controlBody, undefined, within(placement, cells.get(control.name))); + } + }); + presents(node, ({ view, escape }, placement) => { + // Whether there is a gutter at all is the question the drawer's own + // body asks of itself: is focus inside me? The lifecycle may hold + // the node, so it can ask the same question the same way. + const gutter = holds(node, current(root.node)); + const cells = new Map( + drawerSlots(view, placement, gutter).map((slot) => [slot.id, slot.rect] as const), + ); + presentOwn(body, cells, placement); + attach(footer, escapeBody, undefined, within(placement, escape)); + }); + const pop = mutation === "leak-drawer-trap" ? undefined : focusPush(node); drawers.push({ node, pop, historical }); } @@ -400,12 +455,8 @@ export function useReplTree(state: ReplState): Operation { // The overlay is the tree, walked — by the root, which is the only // thing that can see it. Its child is handed the result. const overlay = overlayOf(tree, options.mutation); - // Asked of the tree once. It never reaches a body: it is used here to - // work out which of a parent's children get a marker cell reserved, - // and a body learns only where focus is relative to itself. - const here = current(root.node); for (const child of root.node.children) { - presentChild(child, view, layout, place, options, overlay, here); + presentChild(child, view, layout, place, options, overlay); } }, focused: () => current(root.node), @@ -536,7 +587,6 @@ function presentChild( place: (rect: Rect | undefined) => Placement, options: PresentOptions, overlay: readonly OverlayEntry[], - here: Node, ): void { const name = child.name; if (name === "region:sessions") { @@ -565,11 +615,9 @@ function presentChild( // An open drawer owns the contextual band; the input keeps its node and // simply has nowhere to draw. const taken = view.contextual.drawers.length > 0; - attach(child, inputBody, view.contextual.input, place(taken ? undefined : layout.contextual)); - // `Run` is a control of this band, and the band reserves no cell for it. - for (const control of child.children) { - attach(control, controlBody, undefined, place(undefined)); - } + const placement = place(taken ? undefined : layout.contextual); + attach(child, inputBody, view.contextual.input, placement); + presentOwn(child, view.contextual.input, placement); return; } if (name === "region:history") { @@ -584,13 +632,7 @@ function presentChild( }, placement, ); - // The band knows where it wrote each bracket, so it is the band that says - // where its controls may draw. They are mounted in the band's own order, - // because both come from the same transport mode. - const cells = transportSlots(view.history, placement); - [...child.children].forEach((control, at) => { - attach(control, controlBody, undefined, place(cells[at])); - }); + presentOwn(child, view.history, placement); return; } if (name.startsWith("drawer:")) { @@ -607,17 +649,7 @@ function presentChild( : layout.contextual; const placement = place(rect); attach(child, drawerBody, { view: drawer }, placement); - // The form laid its own gutter out, so the form says which cell each of - // its controls owns. Whether there is a gutter at all is the same - // question the drawer's body asks of itself: is focus inside me? - const cells = new Map( - drawerSlots(drawer, placement, holds(child, here)).map((slot) => [slot.id, slot.rect]), - ); - for (const panel of child.children) { - for (const control of panel.children) { - attach(control, controlBody, undefined, place(cells.get(control.name))); - } - } + presentOwn(child, { view: drawer, escape: layout.footer }, placement); return; } } diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt index 5b1c900bc..fd8be9863 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.wide.txt @@ -44,7 +44,7 @@ drawer-historical.wide · 200 × 50 · A recorded drawer, rendered without anyth │ both fields valid Submit ⌘↵ │ │ schema - EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] +▌EXECUTION HISTORY │ ┃ PAUSED HEAD INSPECTING HISTORY [ Continue ] [ Return to paused head ] [ Fork from here ] recorded · 00:53 │ │ │ │ │┃ 00:53 Entry 1 │ │ │ │ │ │┃ ───◆───●──────────●────────●───────────────●─·──────≈────────●────────●──●────●┃ diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index e98750040..c22d48b67 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -32,7 +32,6 @@ import { renderInto, useTerm, } from "../repl-study/capture.ts"; -import type { FrameRequest } from "../repl-study/capture.ts"; import { FRAMES, frame, stateFor, useFrame } from "../repl-study/frames.ts"; import { openingState, scanKeys } from "../repl-study/host.ts"; import { fold, JOURNAL, journalThrough, markers, siblingsOf } from "../repl-study/journal.ts"; @@ -55,7 +54,7 @@ import type { HarnessEvent, ReplState, Size } from "../repl-study/store.ts"; import { drive, enterRoute } from "../repl-study/drive.ts"; import { focus as focusNode } from "../repl-study/tree.ts"; import { find, overlayOf, surfaceOwning, useReplTree, walk } from "../repl-study/tree.ts"; -import type { ReplTree } from "../repl-study/tree.ts"; +import type { OverlayEntry, ReplTree } from "../repl-study/tree.ts"; import { KeyboardApi, sendKey } from "../repl-study/keys.ts"; import type { Mutation } from "../repl-study/mutations.ts"; @@ -94,22 +93,32 @@ function* opened( * over a complete request, so anything it carries is carried all the way to * `paint`. */ -function* shot( - tree: ReplTree, - state: ReplState, - extra: Record = {}, -): Operation { +function* shot(tree: ReplTree, state: ReplState, options: Shot = {}): Operation { const term = yield* useTerm(WIDE); const fixture = fixtureFor(state); const view = viewOf(state); - return renderInto(term, { + const request = { fixture, view, size: WIDE, - overlay: true, + overlay: options.overlay ?? true, composition: composeInto(tree, fixture, view), - ...extra, - } as FrameRequest).text; + // The field #839 threaded a focus identity and a numbered map through. + // It is written into the request deliberately; nothing reads it any more. + focus: options.claim, + }; + return renderInto(term, request).text; +} + +interface Shot { + /** Whether F1 is down. Left out, the map is drawn. */ + readonly overlay?: boolean; + /** A caller still trying to tell the renderer where focus is. */ + readonly claim?: { + readonly here: string; + readonly map: readonly OverlayEntry[]; + readonly overlay: boolean; + }; } /** The overlay's own row for one entry, as the map draws it. */ @@ -1023,11 +1032,39 @@ describe("the frames, as pictures", () => { expect(after).not.toContain(overlayRow(stale)); const claimed = yield* shot(tree, state, { - focus: { here: stale.id, map: [stale], overlay: true }, + claim: { here: stale.id, map: [stale], overlay: true }, }); expect(claimed).toBe(after); }); + it("shows the focused Run affordance with the map closed", function* () { + // Nothing else on screen says where focus is when F1 is up, so a control + // with no marker cell of its own is a control a person has to guess at. + const state = hydrate("xmd://repl/e1/input?draft=hello", journalThrough(undefined)); + const tree = yield* useReplTree(state); + const run = tree.chain().find((node) => node.name === "control:input.run")!; + focusNode(run); + const rendered = yield* shot(tree, state, { overlay: false }); + expect(rendered).not.toContain("FOCUS MAP"); + expect(rendered).toContain("[\u25b8Run"); + }); + + it("shows the way out of a drawer with the map closed", function* () { + // A drawer traps focus and carries its own Execution History target. It is + // a different node from the band outside, so it has to draw its own marker + // — over the band it is the way back to. + const { state, tree } = yield* opened( + "xmd://repl/e1/transcript/entry-1/document/+project", + "cp-14", + ); + const inside = tree.chain().find((node) => node.name === "region:history")!; + expect(inside.parent?.name).toBe("drawer:project"); + focusNode(inside); + const rendered = yield* shot(tree, state, { overlay: false }); + expect(rendered).not.toContain("FOCUS MAP"); + expect(rendered).toContain("\u258cEXECUTION HISTORY"); + }); + it("draws the focused region and the numbered map", function* () { const { state, tree } = yield* useFrame(frame("12")!); const rendered = yield* shot(tree, state); From 837b68b5726a940ffab6272d087cbd19680b3206 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 14:49:26 -0400 Subject: [PATCH 22/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Keep=20each=20presen?= =?UTF-8?q?tation=20typed,=20and=20stop=20offering=20a=20way=20out=20that?= =?UTF-8?q?=20is=20not=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A presentation is a value its parent keeps, not something stored on a node and recovered later. The node-data registry erased every one to `Presentation` and cast on the way in and the way out, which meant nothing could say the data handed to a presenter was the data that presenter takes. `Presentation` is now just the type of the closure; the lifecycle that wrote it holds it — the root holds the input band's and the band's, and a drawer holds its panel's — and calls it directly. No casts remain in the study's production code. Topology follows the composition. A narrow drawer owns the whole screen, so the Execution History band it would escape to is not drawn — and a target Tab reaches with nothing on screen to show it is worse than no target at all. The drawer's own history node is mounted only where the composition draws that band: no branch, no place in the ring, no middleware. `useReplTree` takes the terminal it is composed for and `sync` may report a new one, so a resize remounts what the new profile composes. That made two things visible that a single tree had been hiding: `--capture-focus` rendered one tree at both profiles, and now mounts one per profile; and the drawer's way out has to exist before the trap is pushed, or a recorded drawer — which has no other focusable child — leaves focus on the container. Two captures move. `frame-07.narrow` drops `5 · Execution History` from the map, which is the rule doing its work. `drawer-historical.narrow` now marks the drawer itself, because a recorded narrow drawer has no focusable child at all and the trap has nowhere else to land. Evidence: a narrow drawer's chain, map and subtree carry no history target; a wide one keeps it; resizing wide→narrow with it focused removes it and leaves focus on a live drawer target; narrow→wide brings it back after the panel; and walking the whole narrow ring with Tab never reaches it. Every capture in all three sets is unchanged apart from those two. --- scripts/repl-study/capture.ts | 15 +- scripts/repl-study/catalog.ts | 2 +- scripts/repl-study/component.ts | 31 +-- scripts/repl-study/drive.ts | 4 +- scripts/repl-study/frames.ts | 6 +- scripts/repl-study/host.ts | 7 +- scripts/repl-study/tree.ts | 166 ++++++++++++---- .../repl-catalog/drawer-historical.narrow.txt | 2 +- .../fixtures/repl-focus/frame-07.narrow.txt | 2 +- scripts/tests/repl-components.test.ts | 16 +- scripts/tests/repl-focus.test.ts | 179 +++++++++++++----- scripts/tests/repl-study.test.ts | 2 +- 12 files changed, 298 insertions(+), 134 deletions(-) diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index ac44eac50..5fadeb17b 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -116,7 +116,7 @@ let foreign: Composition | undefined; export function useForeignTree(subject: Fixture, view: View): Operation { return { *[Symbol.iterator]() { - foreign = yield* useComposition(subject, view); + foreign = yield* useComposition(subject, view, PROFILE_SIZES.wide); return foreign; }, }; @@ -191,6 +191,7 @@ export function* renderFrame(request: Omit): Operat const composition = yield* useComposition( request.fixture, request.view, + request.size, request.surface ?? request.view.surface, ); return renderInto(term, { ...request, composition }); @@ -293,7 +294,7 @@ export function* playFrames( const limit = options.limit ?? 200; const subject = fixture(playback.to); const view = initialView(subject); - const composition = yield* useComposition(subject, view); + const composition = yield* useComposition(subject, view, size); const term = yield* useTerm(size); const frames: Frame[] = []; // A frame in the middle of a transition is a handful of changed cells, not a @@ -356,7 +357,7 @@ export function* journeyFrames( // transition is a change to a mounted tree, never a new one. let composition = compositions.get(planned.fixture); if (composition === undefined) { - composition = yield* useComposition(subject, view); + composition = yield* useComposition(subject, view, size); compositions.set(planned.fixture, composition); } const request: FrameRequest = { @@ -508,10 +509,13 @@ const NARROW_FRAMES = ["01", "05", "07", "12", "14"]; export function* captureFocus(): Operation { const captures: Capture[] = []; for (const subject of FRAMES) { - const { state, tree } = yield* useFrame(subject); const profiles: Profile[] = NARROW_FRAMES.includes(subject.id) ? ["wide", "narrow"] : ["wide"]; for (const profile of profiles) { const size = PROFILE_SIZES[profile]; + // One tree per composition. Topology follows the profile — a narrow + // drawer owns the screen and offers no way out to a band that is not on + // it — so a single tree cannot stand in for both. + const { state, tree } = yield* useFrame(subject, size); const term = yield* useTerm(size); // The frame's own tree draws the frame. It used to be told where focus // was and then rendered by a second tree mounted for the occasion, which @@ -540,6 +544,7 @@ export function* captureFocus(): Operation { export function useComposition( subject: Fixture, view: View, + composed: Size, surface: SurfaceName = view.surface, ): Operation { return { @@ -558,7 +563,7 @@ export function useComposition( }), journalThrough(markerShowing(subject.name)), ); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, composed); // A caller that owns the whole composition brings its tree to the moment // once, at mount. A repaint never does this. yield* tree.sync(state); diff --git a/scripts/repl-study/catalog.ts b/scripts/repl-study/catalog.ts index f54021e5c..4dd4e4c09 100644 --- a/scripts/repl-study/catalog.ts +++ b/scripts/repl-study/catalog.ts @@ -120,7 +120,7 @@ export function renderCatalog(subject: CatalogEntry, profile: Profile): Operatio *[Symbol.iterator]() { const size = PROFILE_SIZES[profile]; const state = hydrate(subject.url, journalThrough(subject.head)); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, size); yield* enterRoute(tree, state); const term = yield* useTerm(size); const result = term.render( diff --git a/scripts/repl-study/component.ts b/scripts/repl-study/component.ts index da90c4493..fee8f3bf9 100644 --- a/scripts/repl-study/component.ts +++ b/scripts/repl-study/component.ts @@ -141,34 +141,17 @@ export function attach( /** * How a parent presents its own children. * - * Installed by the lifecycle that created the node, which is the one thing - * holding it. A render body may not receive a Freedom node; a lifecycle may, - * and presenting children is a lifecycle's work — it is where a parent decides - * which of its children exist on screen, what each of them is given and where - * each of them may draw. + * A parent that has children to place writes one of these and **keeps it**. It + * closes over the node the lifecycle created — which is what a lifecycle may + * hold and a render body may not — so placing a child is the parent's own code + * running over the parent's own node. * - * Nothing walks the tree to do this. Each parent is asked, and asks its own - * children in turn, so no presentation reaches past a direct child. + * It is a value the parent retains rather than something stored on the node and + * recovered later. Recovering one would mean handing arbitrary data to whatever + * presentation happened to be attached, and nothing could say the two matched. */ export type Presentation = (data: Data, placement: Placement) => void; -const presenterKey = createNodeData>("xmd:repl:presents"); - -export function presents(node: Node, presentation: Presentation): void { - node.data.set(presenterKey, presentation as Presentation); -} - -/** - * Ask one node to present its own children. - * - * A node with no children to place has nothing installed, and this does - * nothing — a leaf is not a special case. - */ -export function presentOwn(node: Node, data: Data, placement: Placement): void { - const presentation = node.data.get(presenterKey); - presentation?.(data as never, placement); -} - /** * One child's box, inside its parent's. * diff --git a/scripts/repl-study/drive.ts b/scripts/repl-study/drive.ts index dfd28538a..3b72bf37e 100644 --- a/scripts/repl-study/drive.ts +++ b/scripts/repl-study/drive.ts @@ -99,7 +99,7 @@ export function drive( } const reduced = reduce(state, event, { ...context, focused: tree.focused().name }); applyFocus(tree, reduced.focus); - yield* tree.sync(reduced.state, context.mutation); + yield* tree.sync(reduced.state, { mutation: context.mutation, size: context.size }); // Which surface owns focus is read off the tree, not parsed out of the // focused node's name. const followed = followFocus( @@ -108,7 +108,7 @@ export function drive( "focus", context.mutation, ); - yield* tree.sync(followed, context.mutation); + yield* tree.sync(followed, { mutation: context.mutation, size: context.size }); return { state: followed, delivery }; }, }; diff --git a/scripts/repl-study/frames.ts b/scripts/repl-study/frames.ts index 46e99d25f..43854f63b 100644 --- a/scripts/repl-study/frames.ts +++ b/scripts/repl-study/frames.ts @@ -16,7 +16,7 @@ import { journalThrough } from "./journal.ts"; import type { FixtureName } from "./model.ts"; import { hydrate } from "./store.ts"; -import type { ReplState } from "./store.ts"; +import type { ReplState, Size } from "./store.ts"; import { useReplTree } from "./tree.ts"; import { enterRoute } from "./drive.ts"; import type { ReplTree } from "./tree.ts"; @@ -363,11 +363,11 @@ export interface Frame { * frame was taken, and the evidence's job is to check that the node the study * names is one the tree actually offers. */ -export function useFrame(subject: StudyFrame): Operation { +export function useFrame(subject: StudyFrame, composed: Size): Operation { return { *[Symbol.iterator]() { const state = stateFor(subject); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, composed); // The same entry the interactive harness uses, so a frame opened by // `--frame` and a frame built here cannot come out different. yield* enterRoute(tree, state, subject.focus); diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 921902289..3b10b7101 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -371,7 +371,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { // The tree is acquired before the terminal is touched, so its teardown runs // after the terminal has been given back rather than into a restored one. - const tree = yield* useReplTree(repl); + const tree = yield* useReplTree(repl, { cols: state.cols, rows: state.rows }); // Entering the region the route names comes first, because the footer is an // explicit region: its controls exist only once focus is inside it. Without // this the interactive harness opened at a frame's *location* but not its @@ -757,7 +757,10 @@ export function* runReplay(options: ReplayOptions): Operation { } // The replay owns its whole composition, so mounting one tree here is // exactly right — there is no other tree for it to be a second of. - const composition = yield* useComposition(state.fixture, state.view); + const composition = yield* useComposition(state.fixture, state.view, { + cols: state.cols, + rows: state.rows, + }); draw(term, state, composition, write, options.mutation); drawn += 1; if (options.failAfter !== undefined && drawn >= options.failAfter) { diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 21c8ea06b..34d92f4b8 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -37,8 +37,8 @@ import type { Node, PopFocus, Root } from "./vendor/freedom/upstream/index.ts"; import { drawerTargets, labelFor } from "./surfaces.ts"; import { recordPath } from "./keys.ts"; -import { attach, placementOf, presentOwn, presents, within } from "./component.ts"; -import type { Placement } from "./component.ts"; +import { attach, placementOf, within } from "./component.ts"; +import type { Placement, Presentation } from "./component.ts"; import { bindingsBody, controlBody, @@ -61,7 +61,8 @@ import type { DrawerView, HistoryView, InputView, ReplView } from "./view.ts"; import { drawerSlots, inputSlot, transportSlots } from "./render.ts"; import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; -import type { ReplState } from "./store.ts"; +import { layoutOf } from "./store.ts"; +import type { ReplState, Size } from "./store.ts"; import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; import type { RouteSurface } from "./route.ts"; import type { Mutation } from "./mutations.ts"; @@ -111,7 +112,7 @@ export function surfaceOwning(node: Node): RouteSurface | undefined { export interface ReplTree { readonly root: Root; /** Bring the interface into line with a state, mounting and removing branches. */ - sync(state: ReplState, mutation?: Mutation): Operation; + sync(state: ReplState, options?: SyncOptions): Operation; /** * Hand every direct child its own view subtree and its placement. * @@ -131,6 +132,18 @@ export interface ReplTree { chain(): Node[]; } +export interface SyncOptions { + readonly mutation?: Mutation; + /** + * The terminal this interface is composed for, when it has changed. + * + * Topology follows the composition: what a profile does not compose has no + * branch in the tree. Left out, the size is the one the interface already + * has, because most syncs are not resizes. + */ + readonly size?: Size; +} + export interface PresentOptions { readonly anchor?: number; readonly mutation?: Mutation; @@ -139,6 +152,19 @@ export interface PresentOptions { readonly overlay?: boolean; } +/** + * The presentations the root retains for its own children. + * + * Each one was written by the lifecycle that created the node it places, and + * the root holds them because the root is that lifecycle. A drawer's is looked + * up among the drawers this tree mounted, not off the node. + */ +interface Owned { + readonly input: Presentation; + readonly history: Presentation; + readonly drawer: (node: Node) => Presentation | undefined; +} + /** * What the composition tells a drawer. * @@ -153,9 +179,21 @@ interface DrawerPresentation { interface Mounted { readonly node: Node; - readonly pop?: PopFocus; + /** Assigned after the way out exists, so the trap has something to land on. */ + pop?: PopFocus; /** True while this drawer was mounted as a recorded, read-only one. */ readonly historical: boolean; + /** The drawer's own presentation of its own children, kept by its lifecycle. */ + readonly present: Presentation; + /** + * The way out, while the composition draws the band it leads to. + * + * Absent at the narrow profile, where the drawer owns the whole screen and + * there is no Execution History band on it. A target whose destination is not + * composed is one Tab reaches and nothing shows, so it is not mounted at all + * — no node, no place in the ring, no middleware. + */ + escape?: Node; } /** @@ -165,9 +203,10 @@ interface Mounted { * node each frame and take focus with it, which is the defect a live tree * exists to avoid. */ -export function useReplTree(state: ReplState): Operation { +export function useReplTree(state: ReplState, composed: Size): Operation { return { *[Symbol.iterator]() { + let size = composed; const root = yield* useRoot(); // Chrome the composition draws around the panes. These are nodes so that // rendering order is the tree's, not a sequence written out in one @@ -188,19 +227,19 @@ export function useReplTree(state: ReplState): Operation { } useFocus(root.node); - // Each band places its own controls. These closures hold the node the + // Each band places its own controls. These closures hold the node this // lifecycle created — which is what a lifecycle may do and a render body - // may not — so nothing outside reaches past a direct child to say where a - // control draws. + // may not — and they are kept here, so presenting a child is always the + // parent running its own code rather than something looked up on a node. const inputRegionNode = regions.get("input")!; - presents(inputRegionNode, (input, placement) => { + const presentInput: Presentation = (_input, placement) => { const cell = inputSlot(placement); for (const control of inputRegionNode.children) { attach(control, controlBody, undefined, within(placement, cell)); } - }); + }; const historyRegionNode = regions.get("history")!; - presents(historyRegionNode, (history, placement) => { + const presentHistory: Presentation = (history, placement) => { // The band knows where it wrote each bracket, so the band says where // its controls may draw. They are mounted in the band's own order, // because both come from the same transport mode. @@ -208,7 +247,7 @@ export function useReplTree(state: ReplState): Operation { [...historyRegionNode.children].forEach((control, at) => { attach(control, controlBody, undefined, within(placement, cells[at])); }); - }); + }; let drawers: Mounted[] = []; let scopes: Node[] = []; @@ -339,6 +378,31 @@ export function useReplTree(state: ReplState): Operation { } }; + /** + * Whether this composition draws the band a drawer escapes to. + * + * Asked of the layout rather than of the profile, because the escape + * exists exactly when the thing it leads to is on screen. + */ + const composesFooter = (next: ReplState, mutation?: Mutation): boolean => + layoutOf(next, size, mutation).footer !== undefined; + + /** Mount or remove one drawer's way out, to match the composition. */ + const syncEscape = function* (one: Mounted, composes: boolean): Operation { + if (composes && one.escape === undefined) { + // Appended, so it lands after the body panel: the same order a + // narrow-to-wide resize arrives at and a cold start builds. + const node = one.node.createChild("region:history"); + focusable(node); + one.escape = node; + return; + } + if (!composes && one.escape !== undefined) { + yield* until(one.escape.remove()); + one.escape = undefined; + } + }; + const syncDrawers = function* (next: ReplState, mutation?: Mutation): Operation { const wanted = next.route.drawers; let kept = 0; @@ -370,6 +434,11 @@ export function useReplTree(state: ReplState): Operation { yield* until(top.node.remove()); } } + // A drawer that survived this sync may have survived a resize with it. + const composes = composesFooter(next, mutation); + for (const one of drawers) { + yield* syncEscape(one, composes); + } for (let at = drawers.length; at < wanted.length; at += 1) { const kind = wanted[at]; if (!isDrawerKind(kind)) { @@ -395,33 +464,40 @@ export function useReplTree(state: ReplState): Operation { focusable(child); } } - // The footer stays reachable through a suspension, so it is inside - // the pushed root rather than outside it — and it is the navigation - // that stays valid while a recorded moment is open. - const footer = node.createChild("region:history"); - focusable(footer); - // The panel places the controls it created, and the drawer places the - // panel and the way out. Each closure holds only its own node. - presents>(body, (cells, placement) => { + // panel and the way out. Each closure holds only its own node, and + // the drawer keeps the panel's rather than looking one up. + const presentPanel: Presentation> = (cells, placement) => { for (const control of body.children) { attach(control, controlBody, undefined, within(placement, cells.get(control.name))); } - }); - presents(node, ({ view, escape }, placement) => { - // Whether there is a gutter at all is the question the drawer's own - // body asks of itself: is focus inside me? The lifecycle may hold - // the node, so it can ask the same question the same way. - const gutter = holds(node, current(root.node)); - const cells = new Map( - drawerSlots(view, placement, gutter).map((slot) => [slot.id, slot.rect] as const), - ); - presentOwn(body, cells, placement); - attach(footer, escapeBody, undefined, within(placement, escape)); - }); - - const pop = mutation === "leak-drawer-trap" ? undefined : focusPush(node); - drawers.push({ node, pop, historical }); + }; + const mounted: Mounted = { + node, + historical, + present: ({ view, escape }, placement) => { + // Whether there is a gutter at all is the question the drawer's + // own body asks of itself: is focus inside me? The lifecycle may + // hold the node, so it asks the same question the same way. + const gutter = holds(node, current(root.node)); + const cells = new Map( + drawerSlots(view, placement, gutter).map((slot) => [slot.id, slot.rect] as const), + ); + presentPanel(cells, placement); + if (mounted.escape !== undefined) { + attach(mounted.escape, escapeBody, undefined, within(placement, escape)); + } + }, + }; + // The footer stays reachable through a suspension, so it is inside + // the pushed root rather than outside it — and it is the navigation + // that stays valid while a recorded moment is open. It is mounted + // *before* the trap is pushed: a recorded drawer has no other + // focusable child, and a trap pushed over nothing keeps focus on the + // container itself. + yield* syncEscape(mounted, composesFooter(next, mutation)); + mounted.pop = mutation === "leak-drawer-trap" ? undefined : focusPush(node); + drawers.push(mounted); } }; @@ -431,7 +507,9 @@ export function useReplTree(state: ReplState): Operation { const tree: ReplTree = { root, - *sync(next: ReplState, mutation?: Mutation) { + *sync(next: ReplState, options: SyncOptions = {}) { + const mutation = options.mutation; + size = options.size ?? size; yield* mountScopes(next); yield* mountControls(next, mutation); yield* syncDrawers(next, mutation); @@ -455,8 +533,13 @@ export function useReplTree(state: ReplState): Operation { // The overlay is the tree, walked — by the root, which is the only // thing that can see it. Its child is handed the result. const overlay = overlayOf(tree, options.mutation); + const own: Owned = { + input: presentInput, + history: presentHistory, + drawer: (node) => drawers.find((one) => one.node === node)?.present, + }; for (const child of root.node.children) { - presentChild(child, view, layout, place, options, overlay); + presentChild(child, view, layout, place, options, overlay, own); } }, focused: () => current(root.node), @@ -587,6 +670,7 @@ function presentChild( place: (rect: Rect | undefined) => Placement, options: PresentOptions, overlay: readonly OverlayEntry[], + own: Owned, ): void { const name = child.name; if (name === "region:sessions") { @@ -617,7 +701,7 @@ function presentChild( const taken = view.contextual.drawers.length > 0; const placement = place(taken ? undefined : layout.contextual); attach(child, inputBody, view.contextual.input, placement); - presentOwn(child, view.contextual.input, placement); + own.input(view.contextual.input, placement); return; } if (name === "region:history") { @@ -632,7 +716,7 @@ function presentChild( }, placement, ); - presentOwn(child, view.history, placement); + own.history(view.history, placement); return; } if (name.startsWith("drawer:")) { @@ -649,7 +733,7 @@ function presentChild( : layout.contextual; const placement = place(rect); attach(child, drawerBody, { view: drawer }, placement); - presentOwn(child, { view: drawer, escape: layout.footer }, placement); + own.drawer(child)?.({ view: drawer, escape: layout.footer }, placement); return; } } diff --git a/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt b/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt index fb3b5a497..887182e02 100644 --- a/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt +++ b/scripts/tests/fixtures/repl-catalog/drawer-historical.narrow.txt @@ -1,5 +1,5 @@ drawer-historical.narrow · 90 × 28 · A recorded drawer, rendered without anything to act on - INPUT REQUIRED +▌INPUT REQUIRED suspended at · document scope · validated against the Elicit schema recorded · read-only diff --git a/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt b/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt index b14c81861..95fac1681 100644 --- a/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt +++ b/scripts/tests/fixtures/repl-focus/frame-07.narrow.txt @@ -5,7 +5,7 @@ frame-07.narrow · 90 × 28 2 Description Enter the project details. 3 Schema disclos… 4 Submit - ▸ Project name 5 Execution Hist… + ▸ Project name ┃ Northstar Description ┃ A lightweight workspace for coordinating coding agents. diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index dd9e49bbd..5a893b8fd 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -53,7 +53,7 @@ function* mounted( chain: string[]; }> { const state = hydrate(url, journalThrough(head)); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); const view = project(state); const term = yield* useTerm(WIDE); const result = term.render(paint({ tree, view, layout: layoutOf(state, WIDE) }).ops, { @@ -107,7 +107,7 @@ describe("the view a component is handed", () => { describe("a component is a body on a node", () => { it("hands a body its identity and no way to reach the tree", function* () { const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); const node = tree.root.node.createChild("probe:self"); let seen: Record = {}; attach( @@ -157,7 +157,7 @@ describe("a component is a body on a node", () => { it("lets a parent wrap what its children already rendered", function* () { const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); const parent = tree.root.node.createChild("probe:parent"); const child = parent.createChild("probe:child"); attach(child, ({ self }) => [{ kind: "text", value: self.name } as never], undefined, { @@ -183,7 +183,7 @@ describe("a component is a body on a node", () => { it("keeps the node when its data changes, rather than rebuilding", function* () { const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); const node = tree.chain().find((candidate) => candidate.name === "region:transcript")!; const before = node.id; const view = project(state); @@ -263,7 +263,7 @@ describe("a recorded drawer keeps its presentation and loses its actionability", function* open(url: string, head: string) { const state = hydrate(url, journalThrough(head)); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); yield* enterRoute(tree, state); return { state, tree }; } @@ -333,7 +333,7 @@ describe("a recorded drawer keeps its presentation and loses its actionability", describe("one mounted tree answers everything", () => { function* harness() { const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); yield* enterRoute(tree, state); const subject = fixture("nested"); const composition = composeInto(tree, subject, initialView(subject)); @@ -369,7 +369,7 @@ describe("one mounted tree answers everything", () => { it("is rejected when rendering uses a tree of its own", function* () { const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); yield* enterRoute(tree, state); const subject = fixture("nested"); yield* useForeignTree(subject, initialView(subject)); @@ -397,7 +397,7 @@ describe("one mounted tree answers everything", () => { // the bindings pane had focus dragged back to the transcript by the next // frame, because drawing was re-deciding where they were. const state = hydrate("xmd://repl/e1/transcript/entry-1/document", journalThrough("cp-14")); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); yield* enterRoute(tree, state); const moved = yield* drive(tree, state, key("Tab"), context(WIDE)); const focused = tree.focused().name; diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index c22d48b67..243508f7f 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -62,6 +62,8 @@ const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const GOLDENS = fileURLToPath(new URL("./fixtures/repl-focus/", import.meta.url)); const MAIN = "scripts/repl-study/main.ts"; +const DRAWER = "xmd://repl/e1/transcript/entry-1/document/+project"; + const WIDE: Size = PROFILE_SIZES.wide; const NARROW: Size = PROFILE_SIZES.narrow; @@ -77,12 +79,13 @@ function key(code: string, extra: Record = {}): HarnessEvent { function* opened( url: string, head: string | undefined, + composed: Size = WIDE, ): Operation<{ state: ReplState; tree: ReplTree; }> { const state = hydrate(url, journalThrough(head)); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, composed); return { state, tree }; } @@ -206,7 +209,7 @@ describe("the URL that says where you are", () => { describe("every frame of the approved focus study", () => { it("builds each frame's targets and numbering out of the live tree", function* () { for (const subject of FRAMES) { - const { tree } = yield* useFrame(subject); + const { tree } = yield* useFrame(subject, WIDE); const entries = overlayOf(tree); // With the overlay off the study draws only the focused target. const shown = subject.overlay @@ -228,13 +231,13 @@ describe("every frame of the approved focus study", () => { it("moves where the study says Tab and Shift+Tab move", function* () { for (const subject of FRAMES) { - const forward = yield* useFrame(subject); + const forward = yield* useFrame(subject, WIDE); forward.tree.advance(); expect({ frame: subject.id, tab: forward.tree.focused().name }).toEqual({ frame: subject.id, tab: subject.tab, }); - const reverse = yield* useFrame(subject); + const reverse = yield* useFrame(subject, WIDE); reverse.tree.retreat(); expect({ frame: subject.id, shift: reverse.tree.focused().name }).toEqual({ frame: subject.id, @@ -246,7 +249,7 @@ describe("every frame of the approved focus study", () => { it("drives the real keys through the real tree", function* () { // The transition, not two destinations built independently. for (const subject of FRAMES) { - const { state, tree } = yield* useFrame(subject); + const { state, tree } = yield* useFrame(subject, WIDE); yield* drive(tree, state, key("Tab"), context(WIDE)); expect({ frame: subject.id, tab: tree.focused().name }).toEqual({ frame: subject.id, @@ -257,7 +260,7 @@ describe("every frame of the approved focus study", () => { it("takes the URL with it whenever focus changes region", function* () { for (const subject of FRAMES) { - const { state, tree } = yield* useFrame(subject); + const { state, tree } = yield* useFrame(subject, WIDE); const driven = yield* drive(tree, state, key("Tab"), context(WIDE)); const landed = surfaceOwning(tree.focused()); expect({ frame: subject.id, surface: driven.state.route.surface }).toEqual({ @@ -268,7 +271,7 @@ describe("every frame of the approved focus study", () => { }); it("leaves the URL behind when focus is allowed to move without it", function* () { - const { state, tree } = yield* useFrame(frame("02")!); + const { state, tree } = yield* useFrame(frame("02")!, WIDE); const driven = yield* drive(tree, state, key("Tab"), context(WIDE, "keep-route-on-focus")); expect(tree.focused().name).toBe("region:history"); expect(driven.state.route.surface).toBe("input"); @@ -276,7 +279,7 @@ describe("every frame of the approved focus study", () => { it("walks the whole ring in both directions and comes back to the start", function* () { for (const subject of FRAMES) { - const { tree } = yield* useFrame(subject); + const { tree } = yield* useFrame(subject, WIDE); const size = tree.chain().length; for (let at = 0; at < size; at += 1) { tree.advance(); @@ -299,7 +302,7 @@ describe("input reaches the focused node through its ancestors", () => { }); it("passes through the region that owns a transport control", function* () { - const { state, tree } = yield* useFrame(frame("10")!); + const { state, tree } = yield* useFrame(frame("10")!, WIDE); void state; const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); expect(delivery.target).toBe("control:transport.continue"); @@ -358,7 +361,7 @@ describe("a branch may consume a key, and then nothing else runs it", () => { // Back from a control returns to the region that owns it. Which region that // is comes from walking the live tree, so a node that moved would move with // it. - const { state, tree } = yield* useFrame(frame("10")!); + const { state, tree } = yield* useFrame(frame("10")!, WIDE); expect(tree.focused().name).toBe("control:transport.continue"); const owner = surfaceOwning(tree.focused()); expect(owner).toBe("history"); @@ -374,7 +377,7 @@ describe("a live tree and a rebuilt one are the same tree", () => { state: ReplState; order: readonly string[]; }> { - const { state, tree } = yield* useFrame(frame("11")!); + const { state, tree } = yield* useFrame(frame("11")!, WIDE); const driven = yield* drive(tree, state, key("Enter"), context(WIDE, mutation)); return { state: driven.state, @@ -385,7 +388,7 @@ describe("a live tree and a rebuilt one are the same tree", () => { /** The same URL and journal, with the store and the tree thrown away. */ function* rebuilt(state: ReplState): Operation { const fresh = hydrate(formatRoute(state.route), state.journal); - const tree = yield* useReplTree(fresh); + const tree = yield* useReplTree(fresh, WIDE); const region = tree.chain().find((node) => node.name === "region:history"); if (region) { focusNode(region); @@ -418,7 +421,7 @@ describe("a live tree and a rebuilt one are the same tree", () => { }); it("leaves focus on a surviving node after every replacement", function* () { - const { state, tree } = yield* useFrame(frame("11")!); + const { state, tree } = yield* useFrame(frame("11")!, WIDE); const driven = yield* drive(tree, state, key("Enter"), context(WIDE)); void driven; expect(chain(tree)).toContain(tree.focused().name); @@ -471,23 +474,22 @@ describe("branches, and what closing one destroys", () => { "xmd://repl/e1/transcript/entry-1/document/+project", "cp-14", ); - yield* tree.sync( - hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), - "keep-closed-branch", - ); + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), { + mutation: "keep-closed-branch", + }); expect(walk(tree.root.node).map((node) => node.name)).toContain("field:drawer.project.name"); }); it("keeps focus across a sync, because the tree is reconciled and not rebuilt", function* () { - const { state, tree } = yield* useFrame(frame("10")!); + const { state, tree } = yield* useFrame(frame("10")!, WIDE); expect(tree.focused().name).toBe("control:transport.continue"); yield* tree.sync(state); expect(tree.focused().name).toBe("control:transport.continue"); }); it("loses focus when every node is rebuilt on each sync", function* () { - const { state, tree } = yield* useFrame(frame("10")!); - yield* tree.sync(state, "rebuild-tree-each-sync"); + const { state, tree } = yield* useFrame(frame("10")!, WIDE); + yield* tree.sync(state, { mutation: "rebuild-tree-each-sync" }); expect(tree.focused().name).not.toBe("control:transport.continue"); }); }); @@ -495,7 +497,7 @@ describe("branches, and what closing one destroys", () => { describe("drawers trap traversal and restore outward", () => { it("traps the ring in the top drawer, with the footer inside it", function* () { for (const subject of FRAMES.filter((one) => one.meta.trap)) { - const { tree } = yield* useFrame(subject); + const { tree } = yield* useFrame(subject, WIDE); const ids = chain(tree); expect({ frame: subject.id, last: ids[ids.length - 1] }).toEqual({ frame: subject.id, @@ -534,12 +536,100 @@ describe("drawers trap traversal and restore outward", () => { expect(tree.focused().name).toBe(invoker); }); + it("offers no way out of a narrow drawer, because there is no band to go to", function* () { + // A narrow drawer owns the whole screen, so the Execution History band it + // would escape to is not composed. A target Tab reaches and nothing draws + // is one a person has to guess at, so it is not mounted at all. + const { tree } = yield* opened(DRAWER, "cp-14", NARROW); + expect(chain(tree)).not.toContain("region:history"); + expect(overlayOf(tree).map((entry) => entry.id)).not.toContain("region:history"); + const drawer = find(tree.root.node, "drawer:project")!; + // No branch at all, so there is no node to enter the ring, carry + // middleware or receive a key. + expect(walk(drawer).map((node) => node.name)).not.toContain("region:history"); + }); + + it("keeps the way out of a wide drawer, where the band is on screen", function* () { + const { tree } = yield* opened(DRAWER, "cp-14", WIDE); + expect(chain(tree)).toContain("region:history"); + expect(overlayOf(tree).map((entry) => entry.id)).toContain("region:history"); + }); + + it("takes the way out away on a resize, and leaves focus on a live target", function* () { + const { state, tree } = yield* opened(DRAWER, "cp-14", WIDE); + const escape = tree.chain().find((node) => node.name === "region:history")!; + focusNode(escape); + expect(tree.focused().name).toBe("region:history"); + + yield* drive( + tree, + state, + { kind: "resize", cols: NARROW.cols, rows: NARROW.rows }, + { + size: NARROW, + scrollLimit: 0, + }, + ); + expect(chain(tree)).not.toContain("region:history"); + // Focus did not go outside the drawer, and it did not stay on a node that + // is no longer there. + expect(chain(tree)).toContain(tree.focused().name); + expect( + tree.focused().name.startsWith("field:drawer.") || + tree.focused().name.startsWith("control:drawer."), + ).toBe(true); + }); + + it("brings the way out back in its canonical place when the room returns", function* () { + const { state, tree } = yield* opened(DRAWER, "cp-14", WIDE); + const order = () => + [...tree.root.node.children] + .filter((node) => node.name === "drawer:project") + .flatMap((drawer) => [...drawer.children].map((child) => child.name)); + const wide = order(); + + yield* drive( + tree, + state, + { kind: "resize", cols: NARROW.cols, rows: NARROW.rows }, + { + size: NARROW, + scrollLimit: 0, + }, + ); + expect(order()).toEqual(["panel:project.body"]); + + yield* drive( + tree, + state, + { kind: "resize", cols: WIDE.cols, rows: WIDE.rows }, + { + size: WIDE, + scrollLimit: 0, + }, + ); + expect(order()).toEqual(wide); + }); + + it("gives a narrow drawer's ring nothing that leads off the screen", function* () { + // Walking the whole ring is the question a person asks with Tab. Nothing it + // stops on is a region, because the only region a drawer carries is the one + // the narrow composition does not draw. + const { state, tree } = yield* opened(DRAWER, "cp-14", NARROW); + const reached: string[] = []; + for (let at = 0; at < tree.chain().length + 1; at += 1) { + reached.push(tree.focused().name); + yield* drive(tree, state, key("Tab"), context(NARROW)); + } + expect(reached).not.toContain("region:history"); + expect(new Set(reached).size).toBe(tree.chain().length); + }); + it("lets Tab escape the trap when the branch is not pushed as a focus root", function* () { const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); - yield* tree.sync( - hydrate("xmd://repl/e1/transcript/entry-1/document/+project", state.journal), - "leak-drawer-trap", - ); + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document/+project", state.journal), { + mutation: "leak-drawer-trap", + }); expect(chain(tree)).toContain("region:transcript"); }); @@ -548,17 +638,16 @@ describe("drawers trap traversal and restore outward", () => { "xmd://repl/e1/transcript/entry-1/document/+project", "cp-14", ); - yield* tree.sync( - hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), - "forget-drawer-invoker", - ); + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal), { + mutation: "forget-drawer-invoker", + }); expect(tree.focused().name).not.toBe("region:transcript"); }); }); describe("removing the focused node", () => { it("selects a surviving node before teardown", function* () { - const { state, tree } = yield* useFrame(frame("10")!); + const { state, tree } = yield* useFrame(frame("10")!, WIDE); expect(tree.focused().name).toBe("control:transport.continue"); // Resuming removes the paused transport and mounts the live one. const live = hydrate(formatRoute(state.route), journalThrough("cp-19")); @@ -595,7 +684,7 @@ describe("background updates", () => { }); it("changes nothing about where the person is", function* () { - const { state, tree } = yield* useFrame(frame("06")!); + const { state, tree } = yield* useFrame(frame("06")!, WIDE); const before = tree.focused().name; const driven = yield* drive(tree, state, streaming(), context(WIDE)); expect(tree.focused().name).toBe(before); @@ -604,7 +693,7 @@ describe("background updates", () => { }); it("is rejected when the update moves focus", function* () { - const { state, tree } = yield* useFrame(frame("06")!); + const { state, tree } = yield* useFrame(frame("06")!, WIDE); const before = tree.focused().name; yield* drive(tree, state, streaming(), context(WIDE, "steal-focus-on-background")); expect(tree.focused().name).not.toBe(before); @@ -613,7 +702,7 @@ describe("background updates", () => { describe("a disabled control is drawn and never focusable", () => { it("numbers Continue in the overlay and keeps it out of the chain", function* () { - const { tree } = yield* useFrame(frame("12")!); + const { tree } = yield* useFrame(frame("12")!, WIDE); const entries = overlayOf(tree); const continues = entries.find((entry) => entry.id === "control:transport.continue"); expect(continues?.enabled).toBe(false); @@ -622,8 +711,8 @@ describe("a disabled control is drawn and never focusable", () => { }); it("admits it to the chain when a disabled control is made focusable", function* () { - const { state, tree } = yield* useFrame(frame("12")!); - yield* tree.sync(state, "focus-hidden-target"); + const { state, tree } = yield* useFrame(frame("12")!, WIDE); + yield* tree.sync(state, { mutation: "focus-hidden-target" }); expect(chain(tree)).toContain("control:transport.continue"); }); }); @@ -631,7 +720,7 @@ describe("a disabled control is drawn and never focusable", () => { describe("the overlay is the tree", () => { it("matches the live tree exactly, node for node", function* () { for (const subject of FRAMES) { - const { tree } = yield* useFrame(subject); + const { tree } = yield* useFrame(subject, WIDE); const fromTree = tree .map() .map((node) => node.name) @@ -914,7 +1003,7 @@ describe("rebuilding from the URL and the journal alone", () => { describe("the same route at two profiles", () => { it("says the same thing wide and narrow", function* () { for (const subject of FRAMES) { - const { state, tree } = yield* useFrame(subject); + const { state, tree } = yield* useFrame(subject, WIDE); const before = projection(state); expect(layoutOf(state, WIDE).profile).toBe("wide"); expect(layoutOf(state, NARROW).profile).toBe("narrow"); @@ -928,7 +1017,7 @@ describe("the same route at two profiles", () => { }); it("loses the route when a resize rebuilds it from the profile", function* () { - const { state, tree } = yield* useFrame(frame("07")!); + const { state, tree } = yield* useFrame(frame("07")!, WIDE); const moved = yield* drive( tree, state, @@ -978,7 +1067,7 @@ describe("through a real decoder", () => { expect("shift" in event ? event.shift : undefined).toBeUndefined(); const subject = frame("03")!; - const { state, tree } = yield* useFrame(subject); + const { state, tree } = yield* useFrame(subject, WIDE); for (const decodedEvent of events) { yield* drive(tree, state, { kind: "key", event: decodedEvent }, context(WIDE)); } @@ -989,7 +1078,7 @@ describe("through a real decoder", () => { const input: Input = yield* until(createInput({})); const events = yield* decoded(input, bytes(ESC, 0x5b, 0x5a)); const subject = frame("03")!; - const { state, tree } = yield* useFrame(subject); + const { state, tree } = yield* useFrame(subject, WIDE); for (const event of events) { yield* drive(tree, state, { kind: "key", event }, context(WIDE, "ignore-backtab")); } @@ -1021,7 +1110,7 @@ describe("the frames, as pictures", () => { // The control is to try that again. There is nowhere left for it to land, // and the proof is bytes: a frame drawn with a stale claim attached is the // frame drawn without one. - const { state, tree } = yield* useFrame(frame("01")!); + const { state, tree } = yield* useFrame(frame("01")!, WIDE); const stale = overlayOf(tree).find((entry) => entry.focused)!; const before = yield* shot(tree, state); expect(before).toContain(overlayRow(stale)); @@ -1041,7 +1130,7 @@ describe("the frames, as pictures", () => { // Nothing else on screen says where focus is when F1 is up, so a control // with no marker cell of its own is a control a person has to guess at. const state = hydrate("xmd://repl/e1/input?draft=hello", journalThrough(undefined)); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); const run = tree.chain().find((node) => node.name === "control:input.run")!; focusNode(run); const rendered = yield* shot(tree, state, { overlay: false }); @@ -1066,7 +1155,7 @@ describe("the frames, as pictures", () => { }); it("draws the focused region and the numbered map", function* () { - const { state, tree } = yield* useFrame(frame("12")!); + const { state, tree } = yield* useFrame(frame("12")!, WIDE); const rendered = yield* shot(tree, state); expect(rendered).toContain("FOCUS MAP"); expect(rendered).toContain("Fork from here"); @@ -1076,7 +1165,7 @@ describe("the frames, as pictures", () => { // not get it. A frame cannot be silent about focus itself any more: focus is // the tree's, the tree always has one, and drawing from the tree draws it. it("draws no focus map in a frame that did not ask for one", function* () { - const { state, tree } = yield* useFrame(frame("07")!); + const { state, tree } = yield* useFrame(frame("07")!, WIDE); const rendered = yield* shot(tree, state, { overlay: false }); expect(rendered).not.toContain("FOCUS MAP"); }); @@ -1097,7 +1186,7 @@ describe("the command opens at the frame it names", () => { route: subject.url, head: subject.head, }); - const tree = yield* useReplTree(state); + const tree = yield* useReplTree(state, WIDE); yield* enterRoute(tree, state, subject.focus); expect({ frame: subject.id, focus: tree.focused().name }).toEqual({ frame: subject.id, diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index a946b0ab8..b05a0f4ea 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -563,7 +563,7 @@ describe("resize", () => { const frame = renderInto(term, { fixture: subject, view: initialView(subject), - composition: yield* useComposition(subject, initialView(subject)), + composition: yield* useComposition(subject, initialView(subject), step.size), size: told ? step.size : PROFILE_SIZES.wide, mutation: told ? undefined : "skip-resize-update", }); From e00ab3d7bac77ecc1930c2553d7a257f395781a6 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 15:06:50 -0400 Subject: [PATCH 23/57] =?UTF-8?q?=F0=9F=90=9B=20Focus=20the=20URL's=20own?= =?UTF-8?q?=20surface=20before=20a=20drawer=20traps=20over=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drawer opened straight from a URL was mounted while the ring was still on its default first region, so the trap remembered Sessions as what it had interrupted. Closing it put you there — on a surface the URL had never named. The surface the route names now takes focus at mount, before the first trap is pushed, so what the trap restores is what the URL says. That state is the one place it mattered most: a recorded drawer at the narrow profile is a read-only modal — nothing in it is actionable and the Execution History band it would escape to is not on screen — so the drawer itself holds focus, Tab does nothing, and Escape is the only way out. Where Escape lands is the whole of that state's navigation. The regression walks it: no focusable child, an empty ring, then Escape closes the drawer alone — the reconstruction stays open at cp-04 — focus returns to `region:transcript`, and it is a node the live chain has. A second Escape leaves the reconstruction, which is a separate step. No capture moves. --- scripts/repl-study/tree.ts | 12 ++++++++++++ scripts/tests/repl-focus.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 34d92f4b8..470b68a97 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -501,6 +501,18 @@ export function useReplTree(state: ReplState, composed: Size): Operation child.name === `region:${state.route.surface}`, + ); + if (owner !== undefined) { + focus(owner); + } + yield* mountScopes(state); yield* mountControls(state); yield* syncDrawers(state); diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index 243508f7f..af6bed269 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -64,6 +64,9 @@ const MAIN = "scripts/repl-study/main.ts"; const DRAWER = "xmd://repl/e1/transcript/entry-1/document/+project"; +/** The same drawer, opened over a reconstruction, which makes it read-only. */ +const RECORDED = "xmd://repl/e1/transcript/entry-1/document/+project?at=cp-04&inspect"; + const WIDE: Size = PROFILE_SIZES.wide; const NARROW: Size = PROFILE_SIZES.narrow; @@ -625,6 +628,33 @@ describe("drawers trap traversal and restore outward", () => { expect(new Set(reached).size).toBe(tree.chain().length); }); + it("closes a recorded narrow drawer back to the surface the URL names", function* () { + // A recorded drawer at the narrow profile is a read-only modal: nothing in + // it is actionable, and the band it would escape to is not on screen. So + // the drawer itself holds focus, Tab does nothing, and Escape is the way + // out — which makes where Escape *lands* the whole of this state's + // navigation, and it used to land on Sessions. + const { state, tree } = yield* opened(RECORDED, "cp-18", NARROW); + const drawer = find(tree.root.node, "drawer:project")!; + expect([...drawer.children].flatMap((child) => [...child.children]).length).toBeGreaterThan(0); + expect(chain(tree)).toEqual([]); + expect(tree.focused().name).toBe("drawer:project"); + + const closed = yield* drive(tree, state, key("Escape"), context(NARROW)); + // Escape closes the drawer and nothing else: the reconstruction it was + // opened over is still open, at the same recorded marker. + expect(closed.state.route.drawers).toEqual([]); + expect(closed.state.route.inspect).toBe(true); + expect(closed.state.route.at).toBe("cp-04"); + // Back to the surface the URL names, and to a node the ring actually has. + expect(tree.focused().name).toBe("region:transcript"); + expect(chain(tree)).toContain(tree.focused().name); + + // Leaving the reconstruction is a second, separate Escape. + const live = yield* drive(tree, closed.state, key("Escape"), context(NARROW)); + expect(live.state.route.inspect).toBe(false); + }); + it("lets Tab escape the trap when the branch is not pushed as a focus root", function* () { const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document/+project", state.journal), { From a68b24a68759383b61f3f1a99fd90a2372e80787 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 15:26:46 -0400 Subject: [PATCH 24/57] =?UTF-8?q?=E2=9C=A8=20Say=20what=20a=20person=20mea?= =?UTF-8?q?nt=20as=20an=20action,=20and=20read=20input=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal is read in one place. What travels from there is a `ReplInput` — a key, or a synthetic pointer landing on a cell — and nothing below that boundary parses a decoder's shape again. A resize, a frame passing and a record arriving from a running execution never become input: they happened to the interface, not because of a person. `ReplInputApi.handle(input)` is invoked on the node the input is aimed at: whatever has focus for a key, whatever is drawn at the cell for a pointer. A branch that returns true has answered it, and nothing else runs it — not an ancestor, not the root. Middleware receives no node; the lifecycle that installed it already holds the one it is for. `ReplActionApi.dispatch(action)` carries what was meant, not how it was asked for. Enter, Space and a primary pointer are one gesture with three spellings, so a control tests one thing and its keyboard and its pointer cannot drift apart. A branch may own an action: the drawer owns Back and says what it really means there by dispatching `close-drawer`. The root answers what nothing nearer did, and is the only code that turns an action into a new state — `applyAction` in the store, and the tree's own focus operations. An action nobody owns reaches the API default and throws. `KeyboardApi` is gone; there is no second input path. Tab, Shift+Tab and Escape are read by the root as actions rather than by the store, and Enter belongs to whatever was activated, so the store's fallback keeps only what is not an action: typing, scrolling, scrubbing, the overlay, quitting. Evidence: Enter, Space and a pointer on the same control emit byte-identical actions and leave identical state; a consumed input runs no fallback — proved with `F1`, which the store still owns — and emits no action; the drawer translates Back and a plain Back elsewhere does something else entirely; an unowned action throws, both outside a delivery and against the new `disown-actions` root; and closing a branch takes its path recording and its translation with it. Each of those four rules was broken in turn and the case that covers it failed. Mouse reporting stays off. StarFX and the router stay out. Run and Fork deliberately have no action: both name execution this study's fixture journal cannot perform, and answering them with nothing would invent it. No capture moves. --- scripts/repl-study/actions.ts | 52 ++++++ scripts/repl-study/component.ts | 15 ++ scripts/repl-study/drive.ts | 31 ++-- scripts/repl-study/input.ts | 156 +++++++++++++++++ scripts/repl-study/keys.ts | 84 --------- scripts/repl-study/mutations.ts | 2 + scripts/repl-study/store.ts | 123 +++++++++---- scripts/repl-study/tree.ts | 239 +++++++++++++++++++++++++- scripts/tests/repl-components.test.ts | 14 +- scripts/tests/repl-focus.test.ts | 194 ++++++++++++++++++++- 10 files changed, 765 insertions(+), 145 deletions(-) create mode 100644 scripts/repl-study/actions.ts create mode 100644 scripts/repl-study/input.ts delete mode 100644 scripts/repl-study/keys.ts diff --git a/scripts/repl-study/actions.ts b/scripts/repl-study/actions.ts new file mode 100644 index 000000000..203d3298b --- /dev/null +++ b/scripts/repl-study/actions.ts @@ -0,0 +1,52 @@ +/** + * What a person meant, said as intent. + * + * An action names *what should happen* — pause the run, go back, move focus — + * and never how it was asked for or what it would take to do it. No key code, no + * node, no route, no journal record. That is the whole of why the same control + * can be reached with the keyboard and with a pointer and produce one thing: + * there is nothing in an action for the two to differ about. + * + * An action is dispatched on the scope of the node it came from, so every + * branch between that node and the root sees it on the way up. A branch may + * **own** one — consuming it, or translating it into the action it really means + * in that context — and what survives reaches the application root, which is + * the only thing that turns an action into a new state. + * + * Reaching the default means nothing owned it. That throws rather than passing + * quietly, because an interface that dispatches an action nobody implements is + * broken in a way silence would hide until somebody noticed the button did + * nothing. + */ + +import { createApi } from "effection/experimental"; + +export type ReplAction = + /** Pause a live run. */ + | { readonly kind: "pause" } + /** Resume a paused one. */ + | { readonly kind: "continue" } + /** Leave a reconstruction and stand at the paused head again. */ + | { readonly kind: "return-to-head" } + /** Open a reconstruction of the selected recorded moment. */ + | { readonly kind: "inspect" } + /** Undo one step of where you are, never of what has happened. */ + | { readonly kind: "back" } + /** Close the drawer that is open, which is what Back means inside one. */ + | { readonly kind: "close-drawer" } + /** Move focus, in the tree's own terms rather than the ring's implementation. */ + | { readonly kind: "focus"; readonly move: "next" | "previous" | "owner" }; + +/** An action that reached the root without anything owning it. */ +export class UnownedActionError extends Error { + constructor(action: ReplAction) { + super(`nothing owns the action ${JSON.stringify(action)}`); + this.name = "UnownedActionError"; + } +} + +export const ReplActionApi = createApi("xmd:repl:action", { + dispatch(action: ReplAction): void { + throw new UnownedActionError(action); + }, +}); diff --git a/scripts/repl-study/component.ts b/scripts/repl-study/component.ts index fee8f3bf9..366d51fdf 100644 --- a/scripts/repl-study/component.ts +++ b/scripts/repl-study/component.ts @@ -127,6 +127,7 @@ export function attach( ): Update { let current = data; let where = placement; + node.data.set(boxKey, placement.rect); const self: Surface = { id: node.id, name: node.name === "" ? "root" : node.name }; node.data.set(bodyKey, { render: (_node, children, focus) => @@ -135,6 +136,7 @@ export function attach( return (next, to) => { current = next; where = to; + node.data.set(boxKey, to.rect); }; } @@ -167,6 +169,19 @@ export function within(parent: Placement, rect: Rect | undefined): Placement { }; } +const boxKey = createNodeData("xmd:repl:box"); + +/** + * The box a node was last placed in. + * + * Recorded so that a point on the screen can be resolved to the node drawn + * there. It is the framework reading what a parent decided, never a body + * reaching for geometry it was not given. + */ +export function boxOf(node: Node): Rect | undefined { + return node.data.get(boxKey); +} + export function hasBody(node: Node): boolean { return node.data.get(bodyKey) !== undefined; } diff --git a/scripts/repl-study/drive.ts b/scripts/repl-study/drive.ts index 3b72bf37e..03b5ac216 100644 --- a/scripts/repl-study/drive.ts +++ b/scripts/repl-study/drive.ts @@ -19,14 +19,14 @@ import type { Operation } from "effection"; -import { asKey, followFocus, reduce } from "./store.ts"; +import { followFocus, reduce } from "./store.ts"; import type { HarnessEvent, ReduceContext, ReplState } from "./store.ts"; import type { FocusIntent } from "./store.ts"; import { focus as focusNode, surfaceOwning } from "./tree.ts"; import type { ReplTree } from "./tree.ts"; import type { Node } from "./vendor/freedom/upstream/index.ts"; -import { sendKey } from "./keys.ts"; -import type { Delivery } from "./keys.ts"; +import { normalize } from "./input.ts"; +import type { Delivery } from "./input.ts"; import type { Mutation } from "./mutations.ts"; /** @@ -87,17 +87,20 @@ export function drive( ): Operation { return { *[Symbol.iterator]() { - const delivery = - event.kind === "key" - ? sendKey(tree.root.node, tree.focused(), asKey(event.event)) - : undefined; - if (delivery?.handled === true) { - // A branch on the live ancestor path claimed it. Running the fallback - // anyway is exactly the defect this ordering exists to prevent: the - // hierarchy would be annotating the dispatch instead of governing it. - return { state, delivery }; - } - const reduced = reduce(state, event, { ...context, focused: tree.focused().name }); + // The terminal's event is read once, here, and what travels on is a + // normalized input. A resize, a frame passing and a record arriving are + // not things a person did, so they never become one. + const input = normalize(event); + const delivered = input === undefined ? undefined : tree.deliver({ state, input, context }); + // A branch on the live ancestor path claimed it. Running the fallback + // anyway is exactly the defect this ordering exists to prevent: the + // hierarchy would be annotating the dispatch instead of governing it. + const reduced = + delivered?.reduction ?? + (delivered?.delivery.handled === true + ? { state } + : reduce(state, event, { ...context, focused: tree.focused().name })); + const delivery = delivered?.delivery; applyFocus(tree, reduced.focus); yield* tree.sync(reduced.state, { mutation: context.mutation, size: context.size }); // Which surface owns focus is read off the tree, not parsed out of the diff --git a/scripts/repl-study/input.ts b/scripts/repl-study/input.ts new file mode 100644 index 000000000..d77e27d29 --- /dev/null +++ b/scripts/repl-study/input.ts @@ -0,0 +1,156 @@ +/** + * One thing a person did, delivered to the node it is aimed at. + * + * The terminal is read in one place and normalized once. What travels from + * there is a `ReplInput` — a key, or a pointer landing on a cell — and nothing + * below this boundary parses an escape sequence or reads a decoder's shape + * again. + * + * The input is invoked on the target node's scope, so Effection walks that + * scope's ancestors and every branch between the root and the target — the + * drawer, the panel, the region — runs its middleware in order. Any of them may + * **handle** it by returning `true` without calling `next`, and a handled input + * goes no further: no root fallback runs an input the hierarchy already + * answered. + * + * That last sentence is the whole point, and an earlier round of this + * experiment got it wrong. The path was recorded and then the same event was + * reduced globally regardless, so a branch could intercept a key and watch the + * global behavior happen anyway — the hierarchy annotated the dispatch instead + * of governing it. + * + * Middleware receives no node. The lifecycle that installed it already holds + * the node it is for, and a handler that was handed one could act on a part of + * the tree it has no business in. + * + * `packages/input/src/lib/input.ts` at the pinned Bombshell commit is the + * reference this follows. + */ + +import { createContext } from "effection"; +import { createApi } from "effection/experimental"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; + +import { asKey } from "./store.ts"; +import type { HarnessEvent, Key, Pointer } from "./store.ts"; + +/** The branches a dispatch passed through, innermost last. */ +const PathContext = createContext("xmd:repl:input-path"); + +/** The node this input was aimed at, for the branches it passes on the way. */ +const TargetContext = createContext("xmd:repl:input-target"); + +/** + * True where the input travelling through was aimed at this node itself. + * + * Middleware runs outermost first, so a region sees an activation meant for a + * control inside it before the control does. Without this a band would answer + * its own children's buttons. The node comes from the lifecycle that installed + * the middleware, never from the input. + */ +export function aimedAt(node: Node): boolean { + return node.scope.get(TargetContext) === node; +} + +export type ReplInput = + | { readonly kind: "key"; readonly key: Key } + /** + * A pointer that no terminal sent. + * + * Mouse reporting is never enabled — #838 decided that, and a terminal left + * reporting movement is the loudest way this experiment could damage the + * thing it is borrowing. A pointer here is synthetic: it exists to prove that + * activating a control with it and activating it with the keyboard are the + * same act by the time either reaches an action. + */ + | { readonly kind: "pointer"; readonly pointer: Pointer }; + +/** + * One input, delivered to a node. + * + * The return value is whether it was **handled**. The default is `false`: + * nothing between the root and the node claimed it, so the root's own fallback + * may read it. Middleware that handles an input returns `true` without calling + * `next`. + */ +export const ReplInputApi = createApi("xmd:repl:input", { + handle(input: ReplInput): boolean { + void input; + return false; + }, +}); + +/** + * The terminal's event, read once. + * + * Only a keystroke and a synthetic pointer are input. A resize, a frame passing + * and a record arriving from a running execution are things that happened *to* + * the interface, not things a person did to it, so they never become input and + * never reach a node. + */ +export function normalize(event: HarnessEvent): ReplInput | undefined { + if (event.kind === "key") { + const key = asKey(event.event); + return key.type === "keydown" ? { kind: "key", key } : undefined; + } + if (event.kind === "pointer") { + return { kind: "pointer", pointer: event.pointer }; + } + return undefined; +} + +/** + * True where this input is an activation of whatever it reached. + * + * Enter, Space and a primary pointer are one gesture with three spellings. A + * control tests this and nothing else, which is why its keyboard and its + * pointer behavior cannot drift apart: there is one branch, not two. + */ +export function activation(input: ReplInput): boolean { + if (input.kind === "pointer") { + return input.pointer.button === "primary"; + } + return input.key.code === "Enter" || input.key.code === "Space" || input.key.text === " "; +} + +/** + * Record this branch on the path of every input that passes through it. + * + * Installed by `tree.ts` when a branch is mounted, and gone when the branch is + * removed — which is the whole of why a closed panel cannot receive input. It + * passes everything on: recording is not handling. + */ +export function recordPath(node: Node, name: string): void { + node.scope.around(ReplInputApi, { + handle([input], next): boolean { + node.scope.get(PathContext)?.push(name); + return next(input); + }, + }); +} + +export interface Delivery { + /** The node the input was delivered to. */ + readonly target: string; + /** The branches it passed through, outermost first. */ + readonly path: readonly string[]; + /** True when something on that path claimed it. Nothing else may run it. */ + readonly handled: boolean; +} + +/** + * Send one input to one node. + * + * The path is collected on the root's scope rather than returned by the + * middleware, because a middleware that had to return it could not also use its + * return value to say whether it handled the input. + */ +export function sendInput(root: Node, target: Node, input: ReplInput): Delivery { + const path: string[] = []; + root.scope.set(PathContext, path); + root.scope.set(TargetContext, target); + const handled = ReplInputApi.invoke(target.scope, "handle", [input]); + // Ancestors run outermost first, so the recorded order is already the path + // from the root down to the node. + return { target: target.name, path, handled: handled === true }; +} diff --git a/scripts/repl-study/keys.ts b/scripts/repl-study/keys.ts deleted file mode 100644 index 455ea370c..000000000 --- a/scripts/repl-study/keys.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * A keystroke goes to the node that has focus, and stops where it is consumed. - * - * The key is invoked on the focused node's scope, so Effection walks that - * scope's ancestors and every branch between the root and the control — the - * drawer, the panel, the surface — runs its middleware in order. Any of them may - * **consume** the key by not calling `next`, and a consumed key goes no further: - * no fallback runs it afterwards. - * - * That last sentence is the whole point, and an earlier round of this - * experiment got it wrong. The path was recorded and then the same event was - * reduced globally regardless, so a branch could intercept a key and watch the - * global behavior happen anyway — the hierarchy annotated the dispatch instead - * of governing it. - * - * `packages/input/src/lib/input.ts` at the pinned Bombshell commit is the - * reference this follows. - */ - -import { createContext } from "effection"; -import { createApi } from "effection/experimental"; -import type { Node } from "./vendor/freedom/upstream/index.ts"; - -import type { Key } from "./store.ts"; - -/** The branches a dispatch passed through, innermost last. */ -const PathContext = createContext("xmd:repl:key-path"); - -/** - * One keystroke, delivered to a node. - * - * The return value is whether the key was **handled**. The default is `false`: - * nothing between the root and the node claimed it, so the harness's own - * fallback may run it. Middleware that handles a key returns `true` without - * calling `next`. - */ -export const KeyboardApi = createApi("xmd:repl:keyboard", { - keydown(node: Node, key: Key): boolean { - void node; - void key; - return false; - }, -}); - -/** - * Record this branch on the path of every key that passes through it. - * - * Installed by `tree.ts` when a branch is mounted, and gone when the branch is - * removed — which is the whole of why a closed panel cannot receive input. It - * passes every key on: recording is not handling. - */ -export function recordPath(node: Node, name: string): void { - node.scope.around(KeyboardApi, { - keydown([target, key], next): boolean { - node.scope.get(PathContext)?.push(name); - return next(target, key); - }, - }); -} - -export interface Delivery { - /** The node the key was delivered to. */ - readonly target: string; - /** The branches it passed through, outermost first. */ - readonly path: readonly string[]; - /** True when something on that path claimed the key. Nothing else may run it. */ - readonly handled: boolean; -} - -/** - * Send one key to whichever node has focus. - * - * The path is collected on the root's scope rather than returned by the - * middleware, because a middleware that had to return it could not also use its - * return value to say whether it handled the key. - */ -export function sendKey(root: Node, focused: Node, key: Key): Delivery { - const path: string[] = []; - root.scope.set(PathContext, path); - const handled = KeyboardApi.invoke(focused.scope, "keydown", [focused, key]); - // Ancestors run outermost first, so the recorded order is already the path - // from the root down to the node. - return { target: focused.name, path, handled: handled === true }; -} diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index fb1572e45..f6a57aa1b 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -58,6 +58,8 @@ export const MUTATIONS = [ "ignore-backtab", /** Move focus across a region boundary without moving the URL's surface. */ "keep-route-on-focus", + /** A root that implements no action, so every one reaches the unowned default. */ + "disown-actions", /** Forget the selected marker when a state is rebuilt from its URL. */ "drop-selection-on-hydrate", /** Exit on Ctrl+C while a paused entry is still active. */ diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts index ed8813102..90bc95417 100644 --- a/scripts/repl-study/store.ts +++ b/scripts/repl-study/store.ts @@ -23,6 +23,7 @@ import { layoutFor, SURFACES } from "./layout.ts"; import type { Layout, SurfaceName } from "./layout.ts"; import type { FixtureName, Fixture, TransportMode } from "./model.ts"; import type { Mutation } from "./mutations.ts"; +import type { ReplAction } from "./actions.ts"; import { formatRoute, navigationFor, parseRoute, topDrawer } from "./route.ts"; import type { Route, RouteChange, RouteSurface } from "./route.ts"; @@ -326,6 +327,14 @@ export function followFocus( export type HarnessEvent = /** Whatever the decoder produced. It is parsed here, never assumed. */ | { readonly kind: "key"; readonly event: unknown } + /** + * A pointer landing on a cell. + * + * Synthetic: mouse reporting is never enabled. It exists so that activating a + * control with a pointer and activating it with the keyboard can be shown to + * be the same act by the time either reaches an action. + */ + | { readonly kind: "pointer"; readonly pointer: Pointer } | { readonly kind: "resize"; readonly cols: number; readonly rows: number } | { readonly kind: "tick"; readonly advanceMs: number } | { readonly kind: "background"; readonly record: JournalRecord } @@ -363,6 +372,12 @@ export interface Reduction { readonly focus?: FocusIntent; } +export interface Pointer { + readonly button: "primary"; + readonly x: number; + readonly y: number; +} + export interface Key { readonly type: string; readonly code?: string; @@ -402,7 +417,7 @@ function editable(identity: string): boolean { * key code `Backtab` carrying no shift flag. A reducer that tested `Tab` with * `shift` was testing an event only a test had ever produced. */ -function reverseTab(key: Key, mutation?: Mutation): boolean { +export function reverseTab(key: Key, mutation?: Mutation): boolean { if (key.code === "Tab" && key.shift === true) { return true; } @@ -457,6 +472,11 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon return only(withJournal(state, [...state.journal, event.record])); } + if (event.kind === "pointer") { + // A pointer that nothing owned did nothing. There is no global meaning for + // one: it is an address, and an address nobody answered is not an event. + return only(state); + } const key = asKey(event.event); if (key.type !== "keydown") { return only(state); @@ -482,15 +502,17 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon if (key.code === "F1") { return only({ ...state, overlay: !state.overlay }); } - if (key.code === "Tab" || key.code === "Backtab") { - // Traversal is the tree's: it is the thing that knows what exists now. - return { state, focus: reverseTab(key, mutation) ? { kind: "retreat" } : { kind: "advance" } }; - } - if (key.code === "Escape") { - return back(state, here, context.size, mutation); + if (key.code === "Tab" || key.code === "Backtab" || key.code === "Escape") { + // Traversal and Back are actions: the root maps these keys before the store + // ever sees them, so reaching here means the delivery was refused and there + // is nothing left to do. + return only(state); } if (key.code === "Enter") { - return only(activate(state, here, mutation)); + // Activation belongs to whatever was activated. An Enter that reaches the + // fallback activated nothing, and inserting it into a draft instead would + // make the same key mean two things. + return only(state); } const digit = Number(key.code); @@ -546,18 +568,10 @@ export function reduce(state: ReplState, event: HarnessEvent, context: ReduceCon */ function back(state: ReplState, here: string, size: Size, mutation?: Mutation): Reduction { void size; - const top = topDrawer(state.route); - if (top !== undefined) { - // The route loses the drawer; the tree removes the branch and restores the - // focus its push remembered. Neither side keeps the other's answer. - const closed = go( - state, - { ...state.route, drawers: state.route.drawers.slice(0, -1) }, - "drawer", - mutation, - ); - return { state: closed }; - } + // A drawer is not a step in this sequence any more. An open drawer traps + // focus, so Back inside one passes through the drawer's own branch, and the + // drawer translates it into closing itself — which is the thing that knows + // it is a drawer. if (state.route.inspect) { return { state: go(state, { ...state.route, inspect: false }, "inspection", mutation) }; } @@ -586,27 +600,74 @@ function back(state: ReplState, here: string, size: Size, mutation?: Mutation): }; } -/** Enter: what the focused target does when it is activated. */ -function activate(state: ReplState, here: string, mutation?: Mutation): ReplState { - if (here === "control:transport.pause") { - return frozen(state, mutation) ? state : extendTo(state, "paused"); +/** + * What an action does to the state. + * + * `undefined` means this store does not own the action, which is how an action + * nobody implements reaches the API default and throws instead of quietly doing + * nothing. + * + * Every transition the interface can perform is here, and nowhere else. The + * branch an action came from decided *what* should happen; this is the only + * place that decides what the state becomes because of it. + */ +export function applyAction( + state: ReplState, + action: ReplAction, + context: ReduceContext, +): Reduction | undefined { + const { mutation } = context; + if (mutation === "disown-actions") { + // The control: a root that implements nothing. Every action then reaches + // the default, which is the only thing that can tell the difference between + // an action nobody owns and a button that happens to do nothing. + return undefined; } - if (here === "control:transport.continue") { - return frozen(state, mutation) ? state : extendTo(state, "resumed"); + if (action.kind === "pause") { + return { state: frozen(state, mutation) ? state : extendTo(state, "paused") }; } - if (here === "control:transport.return-head") { + if (action.kind === "continue") { + return { state: frozen(state, mutation) ? state : extendTo(state, "resumed") }; + } + if (action.kind === "return-to-head") { // Closing the reconstruction leaves the selection where it was: returning // to the head is not the same act as deselecting a marker. - return go(state, { ...state.route, inspect: false }, "inspection", mutation); + return { state: go(state, { ...state.route, inspect: false }, "inspection", mutation) }; } - if (here === "region:history" && state.selection >= 0 && !state.route.inspect) { + if (action.kind === "inspect") { // A reconstruction has no live suspension, so the drawer stack does not // survive into one. That is what makes study frame 12's focus walk real: // the trapped controls leave the sequence and focus has to resolve to the // nearest owner that did survive. - return go(state, { ...state.route, inspect: true, drawers: [] }, "inspection", mutation); + if (state.selection < 0 || state.route.inspect) { + return { state }; + } + return { + state: go(state, { ...state.route, inspect: true, drawers: [] }, "inspection", mutation), + }; } - return state; + if (action.kind === "close-drawer") { + // The route loses the drawer; the tree removes the branch and restores the + // focus its push remembered. Neither side keeps the other's answer. + return { + state: go( + state, + { ...state.route, drawers: state.route.drawers.slice(0, -1) }, + "drawer", + mutation, + ), + }; + } + if (action.kind === "back") { + return back(state, context.focused, context.size, mutation); + } + if (action.move === "next") { + return { state, focus: { kind: "advance" } }; + } + if (action.move === "previous") { + return { state, focus: { kind: "retreat" } }; + } + return { state, focus: { kind: "owner" } }; } /** diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 470b68a97..5dd322fb4 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -36,8 +36,11 @@ import { import type { Node, PopFocus, Root } from "./vendor/freedom/upstream/index.ts"; import { drawerTargets, labelFor } from "./surfaces.ts"; -import { recordPath } from "./keys.ts"; -import { attach, placementOf, within } from "./component.ts"; +import { activation, aimedAt, recordPath, ReplInputApi, sendInput } from "./input.ts"; +import type { Delivery, ReplInput } from "./input.ts"; +import { ReplActionApi, UnownedActionError } from "./actions.ts"; +import type { ReplAction } from "./actions.ts"; +import { attach, boxOf, placementOf, within } from "./component.ts"; import type { Placement, Presentation } from "./component.ts"; import { bindingsBody, @@ -61,8 +64,8 @@ import type { DrawerView, HistoryView, InputView, ReplView } from "./view.ts"; import { drawerSlots, inputSlot, transportSlots } from "./render.ts"; import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; -import { layoutOf } from "./store.ts"; -import type { ReplState, Size } from "./store.ts"; +import { applyAction, layoutOf, reverseTab } from "./store.ts"; +import type { Key, ReduceContext, Reduction, ReplState, Size } from "./store.ts"; import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; import type { RouteSurface } from "./route.ts"; import type { Mutation } from "./mutations.ts"; @@ -122,6 +125,17 @@ export interface ReplTree { * from outside. */ present(view: ReplView, layout: Layout, options?: PresentOptions): void; + /** + * Deliver one normalized input to the node it is aimed at. + * + * A key goes to whatever has focus; a pointer goes to the node drawn where it + * landed. Either way it travels up that node's scope, and whatever action + * survives to the root is adapted there — the one place a new state comes + * from. + */ + deliver(request: DeliverRequest): Delivered; + /** The innermost node drawn over a cell, or nothing where none is. */ + hit(x: number, y: number): Node | undefined; /** Where focus is, asked of the tree. */ focused(): Node; advance(): void; @@ -132,6 +146,39 @@ export interface ReplTree { chain(): Node[]; } +/** + * What each control stands for. + * + * `Run` and `Fork` are drawn, numbered and focusable, and they are deliberately + * not here. Both name execution this study's fixture journal cannot perform — + * starting a run, forking from a recorded moment — and giving them an action + * the root would have to answer with nothing would be inventing the answer + * ahead of the execution model that owes it. + */ +const CONTROL_ACTIONS: Readonly> = { + "control:transport.pause": { kind: "pause" }, + "control:transport.continue": { kind: "continue" }, + "control:transport.return-head": { kind: "return-to-head" }, +}; + +export interface DeliverRequest { + readonly state: ReplState; + readonly input: ReplInput; + /** Where focus is is the tree's own answer, so it is not asked for. */ + readonly context: Omit; +} + +export interface Delivered { + readonly delivery: Delivery; + /** + * What an action left, when one was dispatched and the root adapted it. + * + * Absent where nothing owned the input at all, which is the only case the + * store's own fallback may read. + */ + readonly reduction?: Reduction; +} + export interface SyncOptions { readonly mutation?: Mutation; /** @@ -271,6 +318,14 @@ export function useReplTree(state: ReplState, composed: Size): Operation { + const action = CONTROL_ACTIONS[node.name]; + if (action !== undefined) { + activates(node, () => ({ ...action })); + } + }; + const reconcile = function* ( parent: Node, wanted: readonly Control[], @@ -294,6 +349,7 @@ export function useReplTree(state: ReplState, composed: Size): Operation [control.name, at] as const)); @@ -472,6 +529,22 @@ export function useReplTree(state: ReplState, composed: Size): Operation ReplAction): void => { + node.scope.around(ReplInputApi, { + handle([input], next): boolean { + if (!activation(input) || !aimedAt(node)) { + return next(input); + } + ReplActionApi.invoke(node.scope, "dispatch", [action()]); + return true; + }, + }); + }; + + // Activating the band opens a reconstruction of whatever the scrubber is + // on. The band is a region rather than a control, and it is still the + // thing that was activated. + activates(historyRegionNode, () => ({ kind: "inspect" })); + // The surface the URL names owns focus before anything is pushed over // it. A drawer's trap remembers what it interrupted, and a cold start // that mounted the drawer first made it remember the ring's default @@ -554,6 +695,41 @@ export function useReplTree(state: ReplState, composed: Size): Operation hitAt(activeRoot(root, drawers), x, y), focused: () => current(root.node), advance: () => advance(root.node), retreat: () => retreat(root.node), @@ -569,6 +745,61 @@ export function useReplTree(state: ReplState, composed: Size): Operation= box.x && + x < box.x + box.width && + y >= box.y && + y < box.y + box.height + ) { + found = node; + } + for (const child of node.children) { + const deeper = hitAt(child, x, y); + if (deeper !== undefined) { + found = deeper; + } + } + return found; +} + +/** + * What the root reads an input as, when nothing in the tree claimed it. + * + * Only the two that are navigation rather than editing. Everything else a key + * can mean — typing, scrolling, scrubbing, the overlay, quitting — is not an + * action and is read by the store instead. + */ +function rootAction(input: ReplInput, mutation?: Mutation): ReplAction | undefined { + if (input.kind !== "key") { + return undefined; + } + const key: Key = input.key; + if (key.code === "Tab" || key.code === "Backtab") { + // Traversal is the tree's: it is the thing that knows what exists now. + return { kind: "focus", move: reverseTab(key, mutation) ? "previous" : "next" }; + } + if (key.code === "Escape") { + return { kind: "back" }; + } + return undefined; +} + /** The subtree traversal is trapped in: the top drawer, or the whole tree. */ function activeRoot(root: Root, drawers: readonly Mounted[]): Node { const top = drawers[drawers.length - 1]; diff --git a/scripts/tests/repl-components.test.ts b/scripts/tests/repl-components.test.ts index 5a893b8fd..04fb4a29e 100644 --- a/scripts/tests/repl-components.test.ts +++ b/scripts/tests/repl-components.test.ts @@ -28,7 +28,8 @@ import { drive } from "../repl-study/drive.ts"; import { applyAnsi, createGrid, gridText } from "../repl-study/screen.ts"; import { overlayOf, useReplTree } from "../repl-study/tree.ts"; import { enterRoute } from "../repl-study/drive.ts"; -import { sendKey } from "../repl-study/keys.ts"; +import { sendInput } from "../repl-study/input.ts"; +import type { ReplInput } from "../repl-study/input.ts"; import { indexOf, project } from "../repl-study/view.ts"; import type { ReplView } from "../repl-study/view.ts"; import type { HarnessEvent } from "../repl-study/store.ts"; @@ -36,6 +37,11 @@ import type { Mutation } from "../repl-study/mutations.ts"; const WIDE = PROFILE_SIZES.wide; +/** One key, already normalized, for a case that delivers it by hand. */ +function press(code: string): ReplInput { + return { kind: "key", key: { type: "keydown", code } }; +} + function context(size: typeof WIDE, mutation?: Mutation) { return { size, mutation, scrollLimit: 40 }; } @@ -277,7 +283,7 @@ describe("a recorded drawer keeps its presentation and loses its actionability", "control:drawer.project.submit", "region:history", ]); - const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + const delivery = sendInput(tree.root.node, tree.focused(), press("x")); expect(delivery.target).toBe("field:drawer.project.name"); }); @@ -313,7 +319,7 @@ describe("a recorded drawer keeps its presentation and loses its actionability", it("keeps only the navigation that stays valid while inspecting", function* () { const { tree } = yield* open(RECORDED, "cp-18"); expect(tree.focused().name).toBe("region:history"); - const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + const delivery = sendInput(tree.root.node, tree.focused(), press("x")); // A key reaches the history path and nothing recorded. expect(delivery.target).toBe("region:history"); expect(delivery.path).not.toContain("panel:project.body"); @@ -358,7 +364,7 @@ describe("one mounted tree answers everything", () => { for (const target of tree.chain()) { expect(rootOf(target)).toBe(tree.root.node); } - const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + const delivery = sendInput(tree.root.node, tree.focused(), press("x")); expect(delivery.target).toBe(tree.focused().name); // The overlay is the same tree walked, so every entry names a node in it. const names = new Set(walkNames(tree.root.node)); diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index af6bed269..c709ce39e 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -55,7 +55,12 @@ import { drive, enterRoute } from "../repl-study/drive.ts"; import { focus as focusNode } from "../repl-study/tree.ts"; import { find, overlayOf, surfaceOwning, useReplTree, walk } from "../repl-study/tree.ts"; import type { OverlayEntry, ReplTree } from "../repl-study/tree.ts"; -import { KeyboardApi, sendKey } from "../repl-study/keys.ts"; +import { ReplInputApi, sendInput } from "../repl-study/input.ts"; +import { ReplActionApi, UnownedActionError } from "../repl-study/actions.ts"; +import type { ReplAction } from "../repl-study/actions.ts"; +import { boxOf } from "../repl-study/component.ts"; +import type { Node } from "../repl-study/vendor/freedom/upstream/index.ts"; +import type { ReplInput } from "../repl-study/input.ts"; import type { Mutation } from "../repl-study/mutations.ts"; const ROOT = fileURLToPath(new URL("../../", import.meta.url)); @@ -78,6 +83,11 @@ function key(code: string, extra: Record = {}): HarnessEvent { return { kind: "key", event: { type: "keydown", key: code, code, ...extra } }; } +/** One key, already normalized, for a case that delivers it by hand. */ +function press(code: string): ReplInput { + return { kind: "key", key: { type: "keydown", code } }; +} + /** One state and the tree that renders it, built from a URL and a journal. */ function* opened( url: string, @@ -299,7 +309,7 @@ describe("input reaches the focused node through its ancestors", () => { it("passes through the panel and the drawer that contain it", function* () { // A flat registry has no way to produce this: the path is the tree's. const { tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document/+project", "cp-14"); - const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + const delivery = sendInput(tree.root.node, tree.focused(), press("x")); expect(delivery.target).toBe("field:drawer.project.name"); expect(delivery.path).toEqual(["drawer:project", "panel:project.body"]); }); @@ -307,7 +317,7 @@ describe("input reaches the focused node through its ancestors", () => { it("passes through the region that owns a transport control", function* () { const { state, tree } = yield* useFrame(frame("10")!, WIDE); void state; - const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + const delivery = sendInput(tree.root.node, tree.focused(), press("x")); expect(delivery.target).toBe("control:transport.continue"); expect(delivery.path).toEqual(["region:history"]); }); @@ -324,7 +334,7 @@ describe("input reaches the focused node through its ancestors", () => { // it, nothing can focus it, and no middleware path reaches it any more. expect(chain(tree)).not.toContain("field:drawer.project.name"); expect(walk(tree.root.node).map((node) => node.name)).not.toContain("drawer:project"); - const delivery = sendKey(tree.root.node, tree.focused(), { type: "keydown", code: "x" }); + const delivery = sendInput(tree.root.node, tree.focused(), press("x")); expect(delivery.path).not.toContain("drawer:project"); expect(delivery.target).not.toBe(field.name); }); @@ -336,10 +346,9 @@ describe("a branch may consume a key, and then nothing else runs it", () => { it("stops at the branch that claimed it, and the fallback never fires", function* () { const { state, tree } = yield* suspended(); const drawer = find(tree.root.node, "drawer:project")!; - drawer.scope.around(KeyboardApi, { - keydown([node, pressed], _next): boolean { - void node; - void pressed; + drawer.scope.around(ReplInputApi, { + handle([received], _next): boolean { + void received; return true; }, }); @@ -1124,6 +1133,175 @@ describe("through a real decoder", () => { }); }); +describe("input is one gesture, and what it means is an action", () => { + const PAUSED = "xmd://repl/e1/history/entry-1/document"; + const DELIVERY = { size: WIDE, scrollLimit: 0 }; + + /** A footer with its transport controls mounted, and a frame drawn once. */ + function* transport( + url: string, + head: string, + ): Operation<{ state: ReplState; tree: ReplTree; control: Node }> { + const { state, tree } = yield* opened(url, head); + // The footer is an explicit region: its controls exist once focus is in it. + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + tree.advance(); + // Drawing once is what gives every node the box a pointer is resolved + // against. Nothing is asserted about the picture here. + yield* shot(tree, entered.state); + return { state: entered.state, tree, control: tree.focused() }; + } + + /** Every action that passed this node, in order. */ + function record(node: Node, seen: ReplAction[]): void { + node.scope.around(ReplActionApi, { + dispatch([action], next): void { + seen.push(action); + return next(action); + }, + }); + } + + it("emits one action for Enter, for Space and for a pointer on the same control", function* () { + const { state, tree, control } = yield* transport(PAUSED, "cp-18"); + expect(control.name).toBe("control:transport.continue"); + const box = boxOf(control)!; + // The pointer is aimed at the cell the band itself says the control owns, + // and the tree resolves that cell back to the same node. + expect(tree.hit(box.x, box.y)).toBe(control); + + const seen: ReplAction[] = []; + record(tree.root.node, seen); + + const byEnter = tree.deliver({ state, input: press("Enter"), context: DELIVERY }); + const bySpace = tree.deliver({ state, input: press("Space"), context: DELIVERY }); + const byPointer = tree.deliver({ + state, + input: { kind: "pointer", pointer: { button: "primary", x: box.x, y: box.y } }, + context: DELIVERY, + }); + + // Byte for byte: there is nothing in an action for a keyboard and a pointer + // to differ about, because neither is in it. + const [enter, space, pointer] = seen.map((action) => JSON.stringify(action)); + expect({ space, pointer }).toEqual({ space: enter, pointer: enter }); + expect(enter).toBe(JSON.stringify({ kind: "continue" })); + + // And the same state, from the same state. + const shapes = [byEnter, bySpace, byPointer].map((one) => JSON.stringify(one.reduction?.state)); + expect(shapes[1]).toBe(shapes[0]); + expect(shapes[2]).toBe(shapes[0]); + expect(byEnter.reduction?.state.moment.transport).toBe("live"); + }); + + it("runs no fallback and emits no action for an input a branch consumed", function* () { + const { state, tree } = yield* transport(PAUSED, "cp-18"); + // `F1` is not an action: the store owns it, and it is the one that shows + // whether the fallback ran at all. Enter is, and shows whether the branch + // below the consumer ever got to say so. + const loose = yield* drive(tree, state, key("F1"), context(WIDE)); + expect(loose.state.overlay).toBe(!state.overlay); + + const seen: ReplAction[] = []; + record(tree.root.node, seen); + find(tree.root.node, "region:history")!.scope.around(ReplInputApi, { + handle([received], _next): boolean { + void received; + return true; + }, + }); + const overlay = yield* drive(tree, state, key("F1"), context(WIDE)); + // The store never saw it: a consumed input has no global meaning left. + expect(overlay.state.overlay).toBe(state.overlay); + expect(overlay.state).toBe(state); + expect(overlay.delivery?.handled).toBe(true); + + const activated = yield* drive(tree, state, key("Enter"), context(WIDE)); + expect(seen).toEqual([]); + expect(activated.state).toBe(state); + }); + + it("lets a drawer say what Back means inside it", function* () { + const { state, tree } = yield* opened(DRAWER, "cp-14"); + const seen: ReplAction[] = []; + // Recorded at the root, which is where every action passes: the drawer + // consumes `back` rather than forwarding it, so its own scope never sees + // both halves of what it did. + record(tree.root.node, seen); + const inside = tree.deliver({ state, input: press("Escape"), context: DELIVERY }); + // The drawer owned `back` and dispatched what it really meant there. + expect(seen.map((action) => action.kind)).toEqual(["back", "close-drawer"]); + expect(inside.reduction?.state.route.drawers).toEqual([]); + + // Back anywhere else is a different thing entirely: it returns focus to the + // region that owns the control, and the route keeps its shape. + const { state: plain, tree: bare } = yield* transport(PAUSED, "cp-18"); + const outside = bare.deliver({ state: plain, input: press("Escape"), context: DELIVERY }); + expect(outside.reduction?.focus).toEqual({ kind: "owner" }); + expect(outside.reduction?.state.route.drawers).toEqual([]); + }); + + it("throws on an action nothing owns", function* () { + const { state, tree } = yield* transport(PAUSED, "cp-18"); + // Dispatched where no root is adapting: there is nothing to answer it. + expect(() => + ReplActionApi.invoke(tree.focused().scope, "dispatch", [{ kind: "pause" }]), + ).toThrow(UnownedActionError); + + // And inside a delivery, against a root that implements nothing. + expect(() => + tree.deliver({ + state, + input: press("Enter"), + context: { ...DELIVERY, mutation: "disown-actions" }, + }), + ).toThrow(UnownedActionError); + }); + + it("takes a closed branch's input and action middleware away with it", function* () { + const { state, tree } = yield* opened(DRAWER, "cp-14"); + const seen: ReplAction[] = []; + record(tree.root.node, seen); + + const open = tree.deliver({ state, input: press("Escape"), context: DELIVERY }); + expect(open.delivery.path).toContain("drawer:project"); + expect(seen.map((action) => action.kind)).toEqual(["back", "close-drawer"]); + + yield* tree.sync(open.reduction!.state); + expect(find(tree.root.node, "drawer:project")).toBeUndefined(); + seen.length = 0; + const closed = tree.deliver({ + state: open.reduction!.state, + input: press("Escape"), + context: DELIVERY, + }); + // Nothing left to record the path, and nothing left to translate the + // action: Back is plain Back again. + expect(closed.delivery.path).not.toContain("drawer:project"); + expect(seen.map((action) => action.kind)).toEqual(["back"]); + }); + + it("leaves the state it was handed alone, whatever an action does to it", function* () { + const { state, tree } = yield* transport(PAUSED, "cp-18"); + const before = JSON.stringify(state.route); + const driven = tree.deliver({ state, input: press("Enter"), context: DELIVERY }); + expect(JSON.stringify(state.route)).toBe(before); + expect(driven.reduction?.state).not.toBe(state); + }); + + it("keeps route building where the only action handler is", function* () { + // The root adapts; nothing else may. A branch that built a route would be a + // second place state comes from, and the way to see that is to look. + for (const name of ["components.ts", "render.ts", "tree.ts", "input.ts", "actions.ts"]) { + const source = yield* readTextFile(join(ROOT, "scripts/repl-study", name)); + expect({ + name, + builds: source.includes("hydrate(") || source.includes("formatRoute("), + }).toEqual({ name, builds: false }); + } + }); +}); + describe("the frames, as pictures", () => { it("renders every committed focus capture exactly", function* () { const captures = yield* captureFocus(); From 13d1c9fb2be22649995be0ed27479d27a35e5e63 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 15:44:53 -0400 Subject: [PATCH 25/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Let=20each=20parent?= =?UTF-8?q?=20hand=20its=20control=20an=20action,=20and=20refuse=20out=20l?= =?UTF-8?q?oud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name-to-action table is gone. A control's action is declared where the control is: the footer's transport list carries one per entry, the input band carries `Run`'s, and `surfaces.ts` carries each drawer target's beside its label and its order. The parent hands it over when it mounts the child, so nothing infers behavior from `node.name`. Every enabled control now answers its own activation. The ones that mean something emit it — Run, Fork, Submit, Approve, Request changes, Stop, Decline, schema disclosure — and the ones that do not, a field and a scroll region, consume it, because an activation that falls past a control is one the fallback gets to reinterpret. A disabled control and a recorded drawer's contents are wired to nothing at all, which is the same act as not making them focusable. Eight of those actions name operations a real execution owns. The root owns them and refuses: it names the control, says it needs a real execution, and leaves the journal and the URL exactly as they were. The refusal is drawn — it takes the row the header spent on air, and the narrow surface bar's crumb — because a refusal nobody can see is indistinguishable from a button that does nothing, which is the failure this whole boundary exists to remove. It is disposable: the next thing you do answers it, and going anywhere clears it. Evidence walks the tree rather than a list beside it. Seven mounted states, every focusable control in each, Enter and Space and a pointer at the cell its own parent reserved: one action byte for byte, one outcome, and every delivery handled. The roster it reaches is compared with the one this study declares, so a control that stopped being mounted fails here instead of going unexercised. Two more cases cover what the enumeration cannot: a pointer aimed at a control while a *different* one holds focus speaks for the one it landed on, and a disabled or recorded control neither handles nor emits. Each new rule was broken in turn — the fall-through, a control's declared action, the drawn refusal, the hit test — and the case that covers it failed. No capture moves. --- scripts/repl-study/actions.ts | 20 ++- scripts/repl-study/capture.ts | 1 + scripts/repl-study/components.ts | 15 +- scripts/repl-study/render.ts | 13 +- scripts/repl-study/store.ts | 52 ++++++- scripts/repl-study/surfaces.ts | 69 +++++++-- scripts/repl-study/tree.ts | 89 ++++++------ scripts/repl-study/view.ts | 6 + scripts/tests/repl-focus.test.ts | 231 ++++++++++++++++++++++++++++++- 9 files changed, 430 insertions(+), 66 deletions(-) diff --git a/scripts/repl-study/actions.ts b/scripts/repl-study/actions.ts index 203d3298b..d16bfff5b 100644 --- a/scripts/repl-study/actions.ts +++ b/scripts/repl-study/actions.ts @@ -35,7 +35,25 @@ export type ReplAction = /** Close the drawer that is open, which is what Back means inside one. */ | { readonly kind: "close-drawer" } /** Move focus, in the tree's own terms rather than the ring's implementation. */ - | { readonly kind: "focus"; readonly move: "next" | "previous" | "owner" }; + | { readonly kind: "focus"; readonly move: "next" | "previous" | "owner" } + /** + * The ones a real execution owns. + * + * Every one of these is a thing the interface offers and this study cannot + * perform: submitting an entry, forking a recorded moment, answering a + * suspension. They are still actions — the control emits one, the root owns + * it, and what the root does is refuse in a way a person can see. Leaving + * them unwired would have been a button that silently does nothing, which is + * the failure the whole action boundary exists to make impossible. + */ + | { readonly kind: "run" } + | { readonly kind: "fork" } + | { readonly kind: "submit" } + | { readonly kind: "approve" } + | { readonly kind: "request-changes" } + | { readonly kind: "stop" } + | { readonly kind: "decline" } + | { readonly kind: "disclose-schema" }; /** An action that reached the root without anything owning it. */ export class UnownedActionError extends Error { diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 5fadeb17b..127ef3831 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -151,6 +151,7 @@ export function composeInto( transport: subject.history.transport, running: subject.entry?.state === "running", selectedAt: subject.history.checkpoints[view.checkpoint]?.at, + notice: view.notice, }), }; } diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index 3b1cdc3c6..5080905bf 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -54,7 +54,11 @@ import type { import { isRedacted } from "./view.ts"; /** The crumb line above the panes. */ -export const headerBody: Body> = ({ self, data, placement }) => +export const headerBody: Body> = ({ + self, + data, + placement, +}) => region( self.id, placement.rect, @@ -67,7 +71,11 @@ export const headerBody: Body> = ({ self, data : [{ text: data.badge, color: C.gold, width: [...data.badge].length + 2 }]), ], }, - blank(), + // A refusal has to be seen or it is the same as a button that does + // nothing, which is the failure the action boundary exists to remove. It + // gets the row the header already spends on air rather than a share of + // the crumb's, because a truncated refusal is not a visible one. + data.notice === "" ? blank() : plain(data.notice, C.hold), ], { bg: BG.center }, ); @@ -369,8 +377,9 @@ export const surfaceBarBody: Body<{ readonly crumb: string; readonly badge?: string; readonly surface: SurfaceName; + readonly notice: string; }> = ({ self, data, placement, children }) => [ - ...surfaceBarRegion(self.id, data.crumb, data.badge, data.surface, placement.rect), + ...surfaceBarRegion(self.id, data.crumb, data.badge, data.surface, placement.rect, data.notice), ...children, ]; diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 39d8f875a..f6262572e 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -1147,6 +1147,7 @@ export function surfaceBarRegion( badge: string | undefined, surface: SurfaceName, rect: Rect, + notice = "", ): Op[] { const names = { sessions: "SESSIONS", @@ -1162,7 +1163,9 @@ export function surfaceBarRegion( { segments: [ { text: `${names[surface]} · ${at} / 4`, color: C.intro, width: 27 }, - { text: badge ?? crumb, color: badge === undefined ? C.dim : C.gold }, + // A refusal takes the line over: it is about the last thing the + // person did, and there is nowhere else this narrow to put it. + { text: notice === "" ? (badge ?? crumb) : notice, color: refusalColor(badge, notice) }, { text: "Tab ▸", color: C.label, width: 7 }, ], }, @@ -1171,6 +1174,14 @@ export function surfaceBarRegion( ); } +/** Amber for a refusal, gold for a badge, dim for an ordinary crumb. */ +function refusalColor(badge: string | undefined, notice: string): number { + if (notice !== "") { + return C.hold; + } + return badge === undefined ? C.dim : C.gold; +} + export function tooSmallRegion(id: string, layout: Layout): Op[] { const lines: VisualLine[] = [ plain("Terminal too small", C.out), diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts index 90bc95417..2e3d20f57 100644 --- a/scripts/repl-study/store.ts +++ b/scripts/repl-study/store.ts @@ -43,6 +43,8 @@ export interface View { readonly checkpoint: number; readonly surface: SurfaceName; readonly drawerOpen: boolean; + /** What the interface last refused. A view of a moment refused nothing. */ + readonly notice: string; } export function initialView(subject: Fixture): View { @@ -57,6 +59,7 @@ export function initialView(subject: Fixture): View { checkpoint, surface: "transcript", drawerOpen: subject.drawer !== undefined, + notice: "", }; } @@ -101,6 +104,14 @@ export interface ReplState { readonly selection: number; /** Disposable: whether the F1 focus map is drawn. */ readonly overlay: boolean; + /** + * Disposable: what the interface last refused, in words, or nothing. + * + * A refusal has to be visible or it is indistinguishable from a button that + * does nothing. It is not in the URL and it does not survive navigation, + * because it is about the last thing you did rather than about where you are. + */ + readonly notice: string; /** Which identity opened each drawer, so closing one can restore it. */ readonly invokers: Readonly>; /** The navigation stack, for Back. Entries are URLs. */ @@ -144,6 +155,7 @@ export function hydrateRoute( const rebuilt = mint(route, journal, { anchor: 0, overlay: false, + notice: "", invokers: {}, history: [], interrupts: 0, @@ -256,6 +268,7 @@ export function viewOf(state: ReplState): View { : subject.history.checkpoints.findIndex((point) => point.at === selectedAt), surface: surfaceOf(state.route), drawerOpen: subject.drawer !== undefined, + notice: state.notice, }; } @@ -272,6 +285,8 @@ function go(state: ReplState, route: Route, change: RouteChange, mutation?: Muta return mint(route, state.journal, { anchor: state.anchor, overlay: state.overlay, + // Going somewhere answers whatever the last refusal was about. + notice: "", invokers: state.invokers, history: navigation === "push" ? [...state.history, formatRoute(state.route)] : state.history, interrupts: state.interrupts, @@ -283,6 +298,7 @@ function withJournal(state: ReplState, journal: JournalFixture): ReplState { return mint(state.route, journal, { anchor: state.anchor, overlay: state.overlay, + notice: state.notice, invokers: state.invokers, history: state.history, interrupts: state.interrupts, @@ -592,6 +608,7 @@ function back(state: ReplState, here: string, size: Size, mutation?: Mutation): state: mint(parsed.value, state.journal, { anchor: state.anchor, overlay: state.overlay, + notice: "", invokers: state.invokers, history: state.history.slice(0, -1), interrupts: state.interrupts, @@ -661,15 +678,38 @@ export function applyAction( if (action.kind === "back") { return back(state, context.focused, context.size, mutation); } - if (action.move === "next") { - return { state, focus: { kind: "advance" } }; - } - if (action.move === "previous") { - return { state, focus: { kind: "retreat" } }; + if (action.kind === "focus") { + if (action.move === "next") { + return { state, focus: { kind: "advance" } }; + } + return { state, focus: { kind: action.move === "previous" ? "retreat" : "owner" } }; } - return { state, focus: { kind: "owner" } }; + // Everything left is an operation a real execution owns. The interface offers + // it, this study cannot carry it out, and saying so is the only honest + // answer: the journal and the URL are untouched, and the refusal is drawn. + return { state: { ...state, notice: `${STUDY_REFUSALS[action.kind]} ${UNAVAILABLE}` } }; } +/** + * What the interface calls each operation it cannot perform here. + * + * The words are the study's own, so a refusal names the thing that was pressed + * rather than the shape of the action behind it. + */ +const STUDY_REFUSALS: Readonly> = { + run: "Run", + fork: "Fork from here", + submit: "Submit", + approve: "Approve", + "request-changes": "Request changes", + stop: "Stop", + decline: "Decline", + "disclose-schema": "Schema disclosure", +}; + +/** The one sentence a refusal says, so a reader can look for exactly it. */ +export const UNAVAILABLE = "needs a real execution — unavailable in this study"; + /** * The chronological axis: one semantic marker at a time. * diff --git a/scripts/repl-study/surfaces.ts b/scripts/repl-study/surfaces.ts index 420595215..db6a53f6e 100644 --- a/scripts/repl-study/surfaces.ts +++ b/scripts/repl-study/surfaces.ts @@ -1,18 +1,28 @@ /** * The vocabulary the interface is built from. * - * Names only: which controls a drawer carries, and in what order. The tree in - * `tree.ts` turns these into nodes, and the renderer draws them. Nothing here - * knows about focus — that is the tree's, and having it in one place is the - * point of this file being this small. + * Which controls a drawer carries, in what order, and what activating each one + * means. The tree in `tree.ts` turns these into nodes and hands each its own + * action; the renderer draws them. Nothing here knows about focus — that is the + * tree's, and having it in one place is the point of this file being this + * small. */ import type { DrawerKind } from "./fixtures.ts"; +import type { ReplAction } from "./actions.ts"; export interface SurfaceControl { readonly id: string; readonly kind: "control" | "field"; readonly label: string; + /** + * What activating it means, for the parent that mounts it to hand over. + * + * Absent where there is nothing to say: a field and a scroll region are + * activated by being reached, and they answer the activation themselves + * rather than letting it fall past them. + */ + readonly action?: ReplAction; } /** Each drawer's own sequence, taken from study frames 07, 08 and 09. */ @@ -20,15 +30,40 @@ const DRAWER_TARGETS: Record = { project: [ { id: "field:drawer.project.name", kind: "field", label: "Project name" }, { id: "field:drawer.project.description", kind: "field", label: "Description" }, - { id: "control:drawer.project.schema", kind: "control", label: "Schema disclosure · ⌥S" }, - { id: "control:drawer.project.submit", kind: "control", label: "Submit" }, + { + id: "control:drawer.project.schema", + kind: "control", + label: "Schema disclosure · ⌥S", + action: { kind: "disclose-schema" }, + }, + { + id: "control:drawer.project.submit", + kind: "control", + label: "Submit", + action: { kind: "submit" }, + }, ], review: [ { id: "control:drawer.review.scroll", kind: "control", label: "Plan review · scroll region" }, - { id: "control:drawer.review.approve", kind: "control", label: "Approve" }, - { id: "control:drawer.review.request", kind: "control", label: "Request changes" }, - { id: "control:drawer.review.stop", kind: "control", label: "Stop" }, - { id: "control:drawer.review.submit", kind: "control", label: "Submit" }, + { + id: "control:drawer.review.approve", + kind: "control", + label: "Approve", + action: { kind: "approve" }, + }, + { + id: "control:drawer.review.request", + kind: "control", + label: "Request changes", + action: { kind: "request-changes" }, + }, + { id: "control:drawer.review.stop", kind: "control", label: "Stop", action: { kind: "stop" } }, + { + id: "control:drawer.review.submit", + kind: "control", + label: "Submit", + action: { kind: "submit" }, + }, ], confirm: [ { @@ -36,8 +71,18 @@ const DRAWER_TARGETS: Record = { kind: "control", label: "README preview · scroll region", }, - { id: "control:drawer.confirm.approve", kind: "control", label: "Approve" }, - { id: "control:drawer.confirm.decline", kind: "control", label: "Decline" }, + { + id: "control:drawer.confirm.approve", + kind: "control", + label: "Approve", + action: { kind: "approve" }, + }, + { + id: "control:drawer.confirm.decline", + kind: "control", + label: "Decline", + action: { kind: "decline" }, + }, ], }; diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 5dd322fb4..7e28aa080 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -146,21 +146,6 @@ export interface ReplTree { chain(): Node[]; } -/** - * What each control stands for. - * - * `Run` and `Fork` are drawn, numbered and focusable, and they are deliberately - * not here. Both name execution this study's fixture journal cannot perform — - * starting a run, forking from a recorded moment — and giving them an action - * the root would have to answer with nothing would be inventing the answer - * ahead of the execution model that owes it. - */ -const CONTROL_ACTIONS: Readonly> = { - "control:transport.pause": { kind: "pause" }, - "control:transport.continue": { kind: "continue" }, - "control:transport.return-head": { kind: "return-to-head" }, -}; - export interface DeliverRequest { readonly state: ReplState; readonly input: ReplInput; @@ -318,14 +303,6 @@ export function useReplTree(state: ReplState, composed: Size): Operation { - const action = CONTROL_ACTIONS[node.name]; - if (action !== undefined) { - activates(node, () => ({ ...action })); - } - }; - const reconcile = function* ( parent: Node, wanted: readonly Control[], @@ -349,7 +326,9 @@ export function useReplTree(state: ReplState, composed: Size): Operation [control.name, at] as const)); @@ -515,10 +496,11 @@ export function useReplTree(state: ReplState, composed: Size): Operation ReplAction): void => { + const activates = (node: Node, action: ReplAction | undefined): void => { node.scope.around(ReplInputApi, { handle([input], next): boolean { if (!activation(input) || !aimedAt(node)) { return next(input); } - ReplActionApi.invoke(node.scope, "dispatch", [action()]); + if (action !== undefined) { + ReplActionApi.invoke(node.scope, "dispatch", [{ ...action }]); + } + // A control with nothing to say still answers. A field and a scroll + // region are activated by being reached, and letting the activation + // fall past them would hand it to whatever the fallback makes of + // it — which is the silent fall-through this boundary removes. return true; }, }); @@ -640,7 +628,7 @@ export function useReplTree(state: ReplState, composed: Size): Operation ({ kind: "inspect" })); + activates(historyRegionNode, { kind: "inspect" }); // The surface the URL names owns focus before anything is pushed over // it. A drawer's trap remembers what it interrupted, and a cold start @@ -706,12 +694,20 @@ export function useReplTree(state: ReplState, composed: Size): Operation { return { state: entered.state, tree, control: tree.focused() }; } - /** Every action that passed this node, in order. */ - function record(node: Node, seen: ReplAction[]): void { + /** + * Every action that passed this node, in order. + * + * Recording is installed for as long as it is wanted and then switched off, + * because a case that walks many controls on one tree would otherwise keep + * collecting through every middleware it ever added. + */ + function record(node: Node, seen: ReplAction[]): () => void { + let on = true; node.scope.around(ReplActionApi, { dispatch([action], next): void { - seen.push(action); + if (on) { + seen.push(action); + } return next(action); }, }); + return () => { + on = false; + }; } + /** + * Every state that mounts action-bearing controls, and the controls in it. + * + * Driven from what the tree actually mounts rather than from a list written + * beside it: the roster below is checked against the walk, so a control that + * stopped being mounted, or one that was added, fails here rather than going + * unexercised. + */ + const MOUNTED: readonly { readonly url: string; readonly head?: string }[] = [ + // `Run` is offered when there is something to run and nothing running. + { url: "xmd://repl/e1/input?draft=hello" }, + { url: "xmd://repl/e1/history/entry-1/document", head: "cp-14" }, + { url: "xmd://repl/e1/history/entry-1/document", head: "cp-18" }, + { url: "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", head: "cp-18" }, + { url: "xmd://repl/e1/transcript/entry-1/document/+project", head: "cp-14" }, + { url: "xmd://repl/e1/transcript/entry-1/document/+review", head: "cp-14" }, + { url: "xmd://repl/e1/transcript/entry-1/document/+confirm", head: "cp-14" }, + ]; + + /** The actions a real execution owns, which this study answers by refusing. */ + const UNSUPPORTED = [ + "run", + "fork", + "submit", + "approve", + "request-changes", + "stop", + "decline", + "disclose-schema", + ]; + + /** What each enabled control emits. An empty string is one that emits nothing. */ + const ROSTER: Readonly> = { + "control:input.run": "run", + "control:transport.pause": "pause", + "control:transport.continue": "continue", + "control:transport.return-head": "return-to-head", + "control:transport.fork": "fork", + "field:drawer.project.name": "", + "field:drawer.project.description": "", + "control:drawer.project.schema": "disclose-schema", + "control:drawer.project.submit": "submit", + "control:drawer.review.scroll": "", + "control:drawer.review.approve": "approve", + "control:drawer.review.request": "request-changes", + "control:drawer.review.stop": "stop", + "control:drawer.review.submit": "submit", + "control:drawer.confirm.preview": "", + "control:drawer.confirm.approve": "approve", + "control:drawer.confirm.decline": "decline", + }; + + it("gives every enabled control one action for Enter, Space and a pointer", function* () { + const reached = new Set(); + for (const where of MOUNTED) { + const { state, tree } = yield* opened(where.url, where.head); + // The footer's controls exist only once focus is inside it. + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + for (const node of tree.chain()) { + if (!node.name.startsWith("control:") && !node.name.startsWith("field:")) { + continue; + } + focusNode(node); + yield* shot(tree, entered.state); + const box = boxOf(node); + const seen: ReplAction[] = []; + const stop = record(tree.root.node, seen); + const inputs: ReplInput[] = [press("Enter"), press("Space")]; + if (box !== undefined && box.width > 0) { + // Pointed at the cell its own parent reserved for it, which the tree + // resolves back to this very node. + expect({ id: node.name, hit: tree.hit(box.x, box.y)?.name }).toEqual({ + id: node.name, + hit: node.name, + }); + inputs.push({ + kind: "pointer", + pointer: { button: "primary", x: box.x, y: box.y }, + }); + } + const delivered = inputs.map((input) => + tree.deliver({ state: entered.state, input, context: DELIVERY }), + ); + stop(); + + // Nothing fell through: every enabled control answers its own + // activation, whether or not it has anything to say about it. + expect({ id: node.name, handled: delivered.map((one) => one.delivery.handled) }).toEqual({ + id: node.name, + handled: delivered.map(() => true), + }); + // One action, byte for byte, however it was asked for. + const shapes = seen.map((action) => JSON.stringify(action)); + expect({ id: node.name, shapes }).toEqual({ + id: node.name, + shapes: shapes.map(() => shapes[0] ?? ""), + }); + const expected = ROSTER[node.name]; + expect({ id: node.name, kind: seen[0]?.kind ?? "" }).toEqual({ + id: node.name, + kind: expected, + }); + expect({ id: node.name, count: seen.length }).toEqual({ + id: node.name, + count: expected === "" ? 0 : inputs.length, + }); + // The same input, the same outcome. + const states = delivered.map((one) => JSON.stringify(one.reduction?.state ?? null)); + expect({ id: node.name, states }).toEqual({ + id: node.name, + states: states.map(() => states[0]), + }); + reached.add(node.name); + } + } + // The roster is the tree's, not a list kept beside it. + expect([...reached].sort()).toEqual(Object.keys(ROSTER).sort()); + }); + it("emits one action for Enter, for Space and for a pointer on the same control", function* () { const { state, tree, control } = yield* transport(PAUSED, "cp-18"); expect(control.name).toBe("control:transport.continue"); @@ -1194,6 +1326,99 @@ describe("input is one gesture, and what it means is an action", () => { expect(byEnter.reduction?.state.moment.transport).toBe("live"); }); + it("refuses what a real execution owns, visibly, and changes nothing else", function* () { + for (const where of MOUNTED) { + const { state, tree } = yield* opened(where.url, where.head); + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + for (const node of tree.chain()) { + const action = ROSTER[node.name]; + if (action === undefined || !UNSUPPORTED.includes(action)) { + continue; + } + focusNode(node); + const refused = yield* drive(tree, entered.state, key("Enter"), context(WIDE)); + // Said in words, where the interface can draw it. + expect({ id: node.name, notice: refused.state.notice.includes(UNAVAILABLE) }).toEqual({ + id: node.name, + notice: true, + }); + // And nothing else moved: not the journal, not the URL. + expect({ + id: node.name, + journal: refused.state.journal, + route: refused.state.route, + }).toEqual({ + id: node.name, + journal: entered.state.journal, + route: entered.state.route, + }); + + // Drawn, not merely recorded. + const drawn = yield* shot(tree, refused.state); + expect({ id: node.name, shown: drawn.includes(UNAVAILABLE) }).toEqual({ + id: node.name, + shown: true, + }); + } + } + }); + + it("wires nothing a person cannot reach", function* () { + // A disabled control and a recorded drawer's contents are both drawn and + // numbered and neither is actionable. Not wiring them is the same act as + // not making them focusable: there is one node, and it either takes part or + // it does not. + const { state, tree } = yield* opened( + "xmd://repl/e1/history/entry-1/document/plan?at=cp-04&inspect", + "cp-18", + ); + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + const numbered = overlayOf(tree).map((one) => one.id); + expect(numbered).toContain("control:transport.continue"); + expect(chain(tree)).not.toContain("control:transport.continue"); + + const disabled = find(tree.root.node, "control:transport.continue")!; + const seen: ReplAction[] = []; + record(tree.root.node, seen); + const delivery = sendInput(tree.root.node, disabled, press("Enter")); + expect(delivery.handled).toBe(false); + expect(seen).toEqual([]); + + const recorded = yield* opened(RECORDED, "cp-18"); + const inside = find(recorded.tree.root.node, "control:drawer.project.submit")!; + const heard: ReplAction[] = []; + record(recorded.tree.root.node, heard); + expect(sendInput(recorded.tree.root.node, inside, press("Enter")).handled).toBe(false); + expect(heard).toEqual([]); + void entered; + }); + + it("aims a pointer at what it landed on, not at what had focus", function* () { + const { state, tree } = yield* opened(PAUSED, "cp-18"); + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + yield* shot(tree, entered.state); + + const chain = tree.chain(); + const first = chain.find((node) => node.name === "control:transport.continue")!; + const other = chain.find((node) => node.name === "control:transport.return-head")!; + focusNode(first); + expect(tree.focused()).toBe(first); + + const box = boxOf(other)!; + const seen: ReplAction[] = []; + record(tree.root.node, seen); + const pointed = tree.deliver({ + state: entered.state, + input: { kind: "pointer", pointer: { button: "primary", x: box.x, y: box.y } }, + context: DELIVERY, + }); + // The control that was pointed at is the one that spoke, and it is not the + // one that had focus. + expect(pointed.delivery.target).toBe("control:transport.return-head"); + expect(seen.map((action) => action.kind)).toEqual(["return-to-head"]); + expect(tree.focused()).toBe(first); + }); + it("runs no fallback and emits no action for an input a branch consumed", function* () { const { state, tree } = yield* transport(PAUSED, "cp-18"); // `F1` is not an action: the store owns it, and it is the one that shows From d6f7a1eebcde1086a418415cc9ed245c25d8969a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 15:53:28 -0400 Subject: [PATCH 26/57] =?UTF-8?q?=F0=9F=90=9B=20Make=20pointing=20at=20som?= =?UTF-8?q?ething=20you=20can=20reach=20the=20same=20as=20reaching=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A primary pointer that lands on a visible, enabled, focusable node now moves Freedom's focus there before its input is delivered. The action is dispatched from where the person now is, and everything that reads focus afterwards reads the same answer: which region owns it, so the URL follows onto the surface that was pointed at, and what Back returns to. Nothing else moves focus. A cell with nothing drawn in it, a disabled control, a recorded drawer's read-only contents: none of them can take focus, so pointing at one changes neither where you are nor what has happened. A refusal now survives the navigation that the same input caused. The URL following focus is part of that one act, not the next one — clearing the notice there would have wiped the answer before it could be read. What clears it is still the next thing a person does. `composeInto` hardcoded `inspect: false`, so a composition drew a recorded drawer as though it were live and actionable — the opposite of what the tree makes of it. The view carries `inspect` now, which is what lets the evidence ask whether a recorded control offers anything to point at. No capture moves: no captured moment has a drawer open inside a reconstruction. Regressions: pointing at Return to paused head while Continue holds focus focuses Return and emits `return-to-head`; pointing at Run from the footer focuses Run, refuses it out loud, and moves the URL's surface to `input` with the journal untouched; a pointer whose own action removes the control it landed on leaves focus on a survivor in the live chain; and a disabled control is hit-testable but neither acts nor takes focus, while a recorded one offers no cell at all. The assertion that focus stayed on the old node is replaced — it was describing the defect. --- scripts/repl-study/capture.ts | 5 ++- scripts/repl-study/store.ts | 12 +++-- scripts/repl-study/tree.ts | 15 ++++++- scripts/tests/repl-focus.test.ts | 75 ++++++++++++++++++++++++++++++-- 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 127ef3831..f474c22e8 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -146,7 +146,10 @@ export function composeInto( surface, scopes: [], drawerOpen: subject.drawer !== undefined && view.drawerOpen, - inspect: false, + // A reconstruction makes what is drawn a recording. Hardcoding this false + // drew a recorded drawer as though it were live and actionable, which is + // exactly what the tree refuses to make it. + inspect: view.inspect, draft: "", transport: subject.history.transport, running: subject.entry?.state === "running", diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts index 2e3d20f57..0ff6cfa4a 100644 --- a/scripts/repl-study/store.ts +++ b/scripts/repl-study/store.ts @@ -43,6 +43,8 @@ export interface View { readonly checkpoint: number; readonly surface: SurfaceName; readonly drawerOpen: boolean; + /** Whether a reconstruction is open, which makes what is drawn read-only. */ + readonly inspect: boolean; /** What the interface last refused. A view of a moment refused nothing. */ readonly notice: string; } @@ -59,6 +61,7 @@ export function initialView(subject: Fixture): View { checkpoint, surface: "transcript", drawerOpen: subject.drawer !== undefined, + inspect: false, notice: "", }; } @@ -268,6 +271,7 @@ export function viewOf(state: ReplState): View { : subject.history.checkpoints.findIndex((point) => point.at === selectedAt), surface: surfaceOf(state.route), drawerOpen: subject.drawer !== undefined, + inspect: state.route.inspect, notice: state.notice, }; } @@ -285,8 +289,10 @@ function go(state: ReplState, route: Route, change: RouteChange, mutation?: Muta return mint(route, state.journal, { anchor: state.anchor, overlay: state.overlay, - // Going somewhere answers whatever the last refusal was about. - notice: "", + // A refusal survives the navigation the same input caused — the URL + // following focus is part of that one act, not the next one. What clears it + // is the next thing the person does. + notice: state.notice, invokers: state.invokers, history: navigation === "push" ? [...state.history, formatRoute(state.route)] : state.history, interrupts: state.interrupts, @@ -608,7 +614,7 @@ function back(state: ReplState, here: string, size: Size, mutation?: Mutation): state: mint(parsed.value, state.journal, { anchor: state.anchor, overlay: state.overlay, - notice: "", + notice: state.notice, invokers: state.invokers, history: state.history.slice(0, -1), interrupts: state.interrupts, diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 7e28aa080..6eb2e6530 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -684,16 +684,27 @@ export function useReplTree(state: ReplState, composed: Size): Operation { expect(delivery.handled).toBe(false); expect(seen).toEqual([]); + // Drawn, so a pointer reaches it — and it neither acts nor takes focus. + yield* shot(tree, entered.state); + const box = boxOf(disabled)!; + expect(tree.hit(box.x, box.y)).toBe(disabled); + const here = tree.focused(); + const pointed = tree.deliver({ + state: entered.state, + input: { kind: "pointer", pointer: { button: "primary", x: box.x, y: box.y } }, + context: DELIVERY, + }); + expect(pointed.delivery.handled).toBe(false); + expect(seen).toEqual([]); + expect(tree.focused()).toBe(here); + const recorded = yield* opened(RECORDED, "cp-18"); const inside = find(recorded.tree.root.node, "control:drawer.project.submit")!; const heard: ReplAction[] = []; record(recorded.tree.root.node, heard); expect(sendInput(recorded.tree.root.node, inside, press("Enter")).handled).toBe(false); expect(heard).toEqual([]); - void entered; + // A recorded drawer reserves no gutter, so its controls offer no cell to + // point at either. + yield* shot(recorded.tree, recorded.state); + expect(boxOf(inside)?.width ?? 0).toBe(0); }); it("aims a pointer at what it landed on, not at what had focus", function* () { @@ -1413,10 +1430,62 @@ describe("input is one gesture, and what it means is an action", () => { context: DELIVERY, }); // The control that was pointed at is the one that spoke, and it is not the - // one that had focus. + // one that had focus — pointing at something you can reach is reaching it. expect(pointed.delivery.target).toBe("control:transport.return-head"); expect(seen.map((action) => action.kind)).toEqual(["return-to-head"]); - expect(tree.focused()).toBe(first); + expect(tree.focused()).toBe(other); + }); + + it("follows a pointer onto another surface, and the URL follows with it", function* () { + // `Run` belongs to the input band. Reaching it from the footer is a move, + // and the surface segment is what says which region owns focus — so the URL + // has to arrive there too, in the same act. + const { state, tree } = yield* opened("xmd://repl/e1/history?draft=hello", undefined); + expect(tree.focused().name).toBe("region:history"); + yield* shot(tree, state); + const run = tree.chain().find((node) => node.name === "control:input.run")!; + const box = boxOf(run)!; + + const driven = yield* drive( + tree, + state, + { + kind: "pointer", + pointer: { button: "primary", x: box.x, y: box.y }, + }, + context(WIDE), + ); + + expect(tree.focused().name).toBe("control:input.run"); + expect(driven.state.route.surface).toBe("input"); + // And what it asked for is a thing this study cannot do, said out loud. + expect(driven.state.notice).toContain(UNAVAILABLE); + expect(driven.state.journal).toEqual(state.journal); + }); + + it("leaves focus on a survivor when a pointer's own action removes it", function* () { + const { state, tree } = yield* opened(PAUSED, "cp-18"); + const entered = yield* drive(tree, state, key("5"), context(WIDE)); + yield* shot(tree, entered.state); + const resume = tree.chain().find((node) => node.name === "control:transport.continue")!; + const box = boxOf(resume)!; + + const driven = yield* drive( + tree, + entered.state, + { + kind: "pointer", + pointer: { button: "primary", x: box.x, y: box.y }, + }, + context(WIDE), + ); + + // Resuming replaces the paused transport with the live one, so the control + // the pointer landed on is not there any more. + expect(driven.state.moment.transport).toBe("live"); + expect(chain(tree)).not.toContain("control:transport.continue"); + expect(chain(tree)).toContain("control:transport.pause"); + expect(chain(tree)).toContain(tree.focused().name); }); it("runs no fallback and emits no action for an input a branch consumed", function* () { From 680b049cfa607229dbd4a925c9cb65c5bc864690 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 16:21:35 -0400 Subject: [PATCH 27/57] =?UTF-8?q?=E2=9C=A8=20Give=20the=20interface=20one?= =?UTF-8?q?=20clock,=20and=20each=20component=20its=20own=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host owns when a frame happens — it is the only thing that knows whether anything is still moving, how long the next wait should be, and when the terminal has been given back. Everything else asks the frame service and is told. What a component does with a frame is now its own. The transcript's arrival and the playhead's travel were computed centrally and threaded down through the presentation as a finished `Motion`; both now live in the component's lifecycle, in variables nothing outside can see, and their bodies are handed the resulting number and nothing else. `Motion` and `motionAt` are gone from `PresentOptions`, from `FrameRequest` and from `playback.ts`. What crosses the boundary is intent: a `Transition` saying that this moment is being played into rather than cut to, and where the motion starts — which two moments they are is a fact only the thing that chose them knows. It also says whether the motion has begun, because a playback's first frame still shows the moment it is leaving, and that is what gives the renderer two geometries to interpolate between. A subscription belongs to the node. It is registered synchronously and released by the node's own scope, and the clock also asks the tree whether a node is still in it — a branch removed before its release task ever started would otherwise go on being woken. Both halves are needed, and both are proved. Two things the work found. A task spawned into a scope does not begin until the scheduler gets a turn, and the journey's frame loop renders without yielding: subscribing inside that task left the entire demonstration with nothing subscribed and nothing moving, which the renderer-rebuild case caught. And progress is now accumulated a frame at a time rather than divided from a total, so a frame landing exactly on half reveals one row more than a division would — that is the single capture that moves, `play.generated-drawer.midpoint`. Everything else reproduces byte for byte: the other 25 study captures, all 19 focus captures, all 22 catalog states. The complete demonstration is re-proved end to end in a real pty, and the renderer still runs out of measurement room part way through and rebuilds. Evidence: a branch's subscription is woken and stops the moment the branch goes; the clock is asked for only while something is moving; a teardown takes every subscription with it; and two frames of a transition with no time between them are the same picture, because the components advance on the clock and on nothing else. Each of those was broken in turn and the case that covers it failed. --- scripts/repl-study/animation.ts | 173 +++++++++++++++++ scripts/repl-study/capture.ts | 37 ++-- scripts/repl-study/components.ts | 30 ++- scripts/repl-study/host.ts | 38 ++-- scripts/repl-study/playback.ts | 58 +++--- scripts/repl-study/render.ts | 6 +- scripts/repl-study/tree.ts | 175 ++++++++++++++++-- .../play.generated-drawer.midpoint.txt | 4 +- scripts/tests/repl-focus.test.ts | 56 +++++- scripts/tests/repl-study.test.ts | 80 ++++---- 10 files changed, 524 insertions(+), 133 deletions(-) create mode 100644 scripts/repl-study/animation.ts diff --git a/scripts/repl-study/animation.ts b/scripts/repl-study/animation.ts new file mode 100644 index 000000000..0123a6439 --- /dev/null +++ b/scripts/repl-study/animation.ts @@ -0,0 +1,173 @@ +/** + * One clock, and the components that animate against it. + * + * The host owns *when* a frame happens: it is the only thing that knows whether + * anything is still moving, how long the next wait should be, and when the + * terminal has been given back. Everything else asks this service for frames + * and is told; nothing else schedules one. + * + * What a component does with a frame is its own. The progress of an arriving + * transcript, the position of a travelling playhead — those live in the + * component's lifecycle, in its own variables, and its render body reads the + * resulting snapshot and nothing else. An earlier round computed every one of + * them centrally and threaded the answer down through the presentation, which + * is the same second structure the focus rework removed: a number worked out + * somewhere else, free to disagree with the thing it described. + * + * Delivery is **synchronous**. A frame is a moment in time, and the component + * has to have moved before the picture of that moment is drawn — a subscription + * that woke a turn later would render the frame before last, and a capture + * would record a screen no viewer ever saw. + */ + +import { createContext, ensure, suspend } from "effection"; +import type { Operation } from "effection"; +import type { Node } from "./vendor/freedom/upstream/index.ts"; + +/** One frame's worth of time, in the unit the renderer measures transitions in. */ +export interface Frame { + readonly deltaSeconds: number; +} + +export type Listener = (frame: Frame) => void; + +export interface FrameService { + /** Subscribe until the release is called. */ + listen(listener: Listener): () => void; + /** + * Ask for the clock to keep running. + * + * A component that is animating says so and releases when it settles. The + * host runs the clock while anything still wants it, so an interface with + * nothing moving schedules nothing at all. + */ + want(): () => void; + /** True while at least one component is still animating. */ + wanted(): boolean; + /** Deliver one frame. The host calls this; nothing else does. */ + advance(deltaSeconds: number): void; +} + +export function createFrameService(): FrameService { + const listeners = new Set(); + let wants = 0; + return { + listen(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + want() { + wants += 1; + let released = false; + return () => { + if (released) { + return; + } + released = true; + wants -= 1; + }; + }, + wanted: () => wants > 0, + advance(deltaSeconds) { + for (const listener of [...listeners]) { + listener({ deltaSeconds }); + } + }, + }; +} + +const FrameContext = createContext("xmd:repl:frames"); + +/** + * The one frame service this run animates against. + * + * The host installs it before anything is mounted, so the tree and the loop + * that drives it are looking at the same clock. A caller that mounts a tree + * without one gets a service of its own — which is what a capture wants: time + * supplied rather than measured, and nothing shared with any other run. + */ +export function useFrames(): Operation { + return { + *[Symbol.iterator]() { + const existing = yield* FrameContext.get(); + if (existing !== undefined) { + return existing; + } + return yield* FrameContext.set(createFrameService()); + }, + }; +} + +/** + * Subscribe one node's own scope to the clock. + * + * The subscription belongs to the node: it is created in the node's scope and + * released when that scope ends, so removing a branch takes its animation with + * it. Nothing has to remember to unsubscribe, because there is nowhere for the + * registration to outlive the thing it was for. + */ +export function animates(node: Node, service: FrameService, listener: Listener): void { + // Subscribed **now**, not on the next turn. A task spawned into a scope does + // not begin until the scheduler gets one, and a frame loop that renders + // without yielding never gives it one — the whole journey ran with nothing + // subscribed and nothing moved. + // + // The node's scope owns the registration and releases it when the branch + // goes. It cannot be the *only* thing that does, for the same reason: a + // branch removed before that task ever started would be halted with nothing + // registered to release. So the clock also asks the tree, which is the + // authority on what exists, and a node that is no longer in it is not woken + // again. + let release = (): void => {}; + release = service.listen((frame) => { + if (!attached(node)) { + release(); + return; + } + listener(frame); + }); + const owned = release; + node.scope.run(function* () { + yield* ensure(owned); + yield* suspend(); + }); +} + +/** True while this node is still reachable from the tree it was mounted in. */ +function attached(node: Node): boolean { + for (let at: Node = node; at.parent !== undefined; at = at.parent) { + let found = false; + for (const child of at.parent.children) { + if (child === at) { + found = true; + break; + } + } + if (!found) { + return false; + } + } + return true; +} + +/** How long one transition takes, in the seconds the renderer measures in. */ +export const TRANSITION_SECONDS = 0.64; + +/** + * How close to the end counts as the end. + * + * Elapsed time is accumulated a frame at a time, so forty sixteen-millisecond + * steps land a few parts in 10^16 short of the 640 they add up to. A transition + * that close to its duration has ended: there is no frame left to draw the + * difference in, and waiting for exact equality would leave one running for + * ever. + */ +export const SETTLED_SECONDS = 1e-9; + +export function easeInOutCubic(fraction: number): number { + return fraction < 0.5 + ? 4 * fraction * fraction * fraction + : 1 - Math.pow(-2 * fraction + 2, 3) / 2; +} diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index f474c22e8..2f1b26a0d 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -15,8 +15,9 @@ import { ensureDir, writeTextFile } from "@effectionx/fs"; import { join } from "node:path"; import { fixture, fixtures } from "./fixtures.ts"; -import { JOURNEY, journeyPlan, motionAt, PLAYBACKS } from "./playback.ts"; -import type { Motion, Playback } from "./playback.ts"; +import { JOURNEY, journeyPlan, PLAYBACKS, transitionOf } from "./playback.ts"; +import { useFrames } from "./animation.ts"; +import type { Playback, Transition } from "./playback.ts"; import type { Fixture } from "./model.ts"; import type { Profile, SurfaceName } from "./layout.ts"; import { layoutFor } from "./layout.ts"; @@ -168,7 +169,8 @@ export interface FrameRequest { readonly mutation?: Mutation; readonly surface?: SurfaceName; /** Present only while a playback is running between two fixtures. */ - readonly motion?: Motion; + /** Present only while a moment is being played into rather than cut to. */ + readonly transition?: Transition; /** * Ordinary UI state: whether the numbered overlay is drawn. * @@ -218,16 +220,10 @@ export class RendererCapacityError extends Error { export function renderInto(term: Term, request: FrameRequest): Frame { const { view, size, mutation } = request; const subject = request.fixture; - const motion = - mutation === "restore-mid-animation" && request.motion === undefined - ? // Reconstruction must land on a state, never halfway through a transition. - // This control makes it land halfway. - { progress: 0.5, headAt: subject.history.headAt / 2, reveal: 0.5, done: false } - : request.motion; // A playback's first frame still shows the moment it is leaving, so a drawer // about to open is not open yet: that is what gives the renderer two // geometries to interpolate between rather than one it has already arrived at. - const opening = motion === undefined || motion.progress > 0; + const opening = request.transition === undefined || request.transition.begun; const layout = layoutFor({ cols: size.cols, rows: size.rows, @@ -255,7 +251,7 @@ export function renderInto(term: Term, request: FrameRequest): Frame { view: shown, layout, anchor: view.anchor, - options: { overlay: request.overlay, mutation, motion }, + options: { overlay: request.overlay, mutation, transition: request.transition }, }); const result = term.render( painted.ops, @@ -298,6 +294,9 @@ export function* playFrames( const limit = options.limit ?? 200; const subject = fixture(playback.to); const view = initialView(subject); + // The clock this playback supplies time to. Components subscribed to it when + // the tree below was mounted, so advancing it is the whole of how they move. + const clock = yield* useFrames(); const composition = yield* useComposition(subject, view, size); const term = yield* useTerm(size); const frames: Frame[] = []; @@ -305,23 +304,23 @@ export function* playFrames( // screen. The screen is what those changes have added up to, so the grid // carries across frames exactly as a terminal's does. const screen = createGrid(size.cols, size.rows); - let elapsed = 0; for (let index = 0; index < limit; index += 1) { - const motion = motionAt(playback, elapsed); + const transition = transitionOf(playback, index > 0); + const deltaSeconds = index === 0 ? 0 : frameMs / 1000; + clock.advance(deltaSeconds); const frame = renderInto(term, { fixture: subject, view, composition, size, - motion, - deltaSeconds: index === 0 ? 0 : frameMs / 1000, + transition, + deltaSeconds, }); applyAnsi(screen, frame.ansi); frames.push({ ...frame, text: gridText(screen) }); - if (motion.done && !frame.animating) { + if (!clock.wanted() && !frame.animating) { return frames; } - elapsed += frameMs; } return frames; } @@ -354,7 +353,9 @@ export function* journeyFrames( let rebuilds = 0; const screen = createGrid(size.cols, size.rows); const frames: JourneyFrame[] = []; + const clock = yield* useFrames(); for (const planned of journeyPlan(JOURNEY, frameMs)) { + clock.advance(planned.deltaMs / 1000); const subject = fixture(planned.fixture); const view = initialView(subject); // One composition per moment, reused across that moment's frames: a @@ -369,7 +370,7 @@ export function* journeyFrames( view, composition, size, - motion: planned.motion, + transition: planned.transition, deltaSeconds: planned.deltaMs / 1000, }; let frame: Frame; diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index 5080905bf..4f7cfff99 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -41,7 +41,6 @@ import type { VisualLine } from "./render.ts"; import type { OverlayEntry } from "./tree.ts"; import type { Layout, Rect, SurfaceName } from "./layout.ts"; import type { Mutation } from "./mutations.ts"; -import type { Motion } from "./playback.ts"; import type { BindingsView, DrawerView, @@ -189,8 +188,13 @@ export interface TranscriptData { /** The window over a long transcript, which the renderer clips rather than scrolls. */ readonly anchor: number; readonly mutation?: Mutation; - /** Present only while a playback is running between two moments. */ - readonly motion?: Motion; + /** + * How much of the transcript has arrived, 0…1. + * + * This component's own number, kept by its lifecycle and read here. A body + * cannot ask how far along anything is; it is told what has arrived. + */ + readonly reveal: number; } export const transcriptBody: Body = ({ @@ -236,12 +240,12 @@ export const transcriptBody: Body = ({ ? body : body.slice(data.anchor, data.anchor + Math.max(0, capacity - 1)); - // While a playback runs, the target's transcript arrives a few rows at a - // time. This is the application's own interpolation; the renderer is not - // animating anything here. - const arriving = data.motion !== undefined && !data.motion.done; + // While a moment is being played into, its transcript arrives a few rows at + // a time. The component interpolated this against the one clock; the renderer + // is not animating anything here. + const arriving = data.reveal < 1; if (arriving) { - const shown = Math.max(1, Math.ceil(data.motion!.reveal * windowed.length)); + const shown = Math.max(1, Math.ceil(data.reveal * windowed.length)); lines.push(...windowed.slice(0, shown), plain("…", C.dim)); return region(self.id, rect, lines, { bg: BG.center, children, focused: focus === "self" }); } @@ -313,14 +317,20 @@ export const inputBody: Body = ({ * for it: the band is a component handed its own view and its own box. */ export const historyBody: Body = ({ self, data, placement, children, focus }) => [ - ...bandRegion(self.id, data.view, placement, data.mutation, data.motion, focus === "self"), + ...bandRegion(self.id, data.view, placement, data.mutation, data.headAt, focus === "self"), ...children, ]; export interface HistoryData { readonly view: HistoryView; readonly mutation?: Mutation; - readonly motion?: Motion; + /** + * Where the playhead is, which is this component's own number. + * + * It is where the head sits *now* — travelling between two moments while one + * is being played into, and at the recorded head the rest of the time. + */ + readonly headAt: number; } /** diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 3b10b7101..21cb98abc 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -26,6 +26,7 @@ import { initialView } from "./store.ts"; import { asKey, fixtureFor, hydrate, reduce, viewOf } from "./store.ts"; import { useReplTree } from "./tree.ts"; import { drive, enterRoute } from "./drive.ts"; +import { useFrames } from "./animation.ts"; import type { HarnessEvent, ReplState, View } from "./store.ts"; import { journalThrough, markerShowing } from "./journal.ts"; import { formatRoute } from "./route.ts"; @@ -34,13 +35,13 @@ import type { Composition } from "./capture.ts"; import { renderInto } from "./capture.ts"; import type { Mutation } from "./mutations.ts"; import { - motionAt, + transitionOf, playbackFrom, segmentDurationMs, segmentFixture, segmentLabel, } from "./playback.ts"; -import type { Motion, Playback, Segment } from "./playback.ts"; +import type { Playback, Segment, Transition } from "./playback.ts"; /** The modes the harness changes, as one reversible pair. */ export function terminalModes(): Setting { @@ -206,7 +207,7 @@ function draw( composition: Composition, write: (bytes: Uint8Array) => void, mutation?: Mutation, - motion?: Motion, + transition?: Transition, deltaMs = 0, overlay?: boolean, ): Painted { @@ -218,7 +219,7 @@ function draw( composition, size: { cols: state.cols, rows: state.rows }, mutation, - motion, + transition, overlay, // The harness counts in milliseconds and the renderer in seconds. The // conversion happens here, once, at the only place the two meet. @@ -257,7 +258,8 @@ export interface TraceEntry { /** What the renderer was advanced by, in its own unit: seconds. */ readonly deltaSeconds: number; readonly animating: boolean; - readonly motionDone: boolean | null; + /** True while a component is still animating and has asked for more frames. */ + readonly moving: boolean; readonly bytes: number; /** `hold:nested`, `play:nested→generated`, or `settled` once it is over. */ readonly segment: string; @@ -369,6 +371,9 @@ export function* runInteractive(options: InteractiveOptions): Operation { quit: false, }; + // The one clock, installed before anything is mounted, so the tree's + // components and the loop that drives them are looking at the same service. + const frameClock = yield* useFrames(); // The tree is acquired before the terminal is touched, so its teardown runs // after the terminal has been given back rather than into a restored one. const tree = yield* useReplTree(repl, { cols: state.cols, rows: state.rows }); @@ -507,7 +512,9 @@ export function* runInteractive(options: InteractiveOptions): Operation { */ const paint = function* (deltaMs: number, finished = false): Operation { const segment = currentSegment(); - const motion = playback === undefined ? undefined : motionAt(playback, elapsed); + const transition = playback === undefined ? undefined : transitionOf(playback, elapsed > 0); + // The one clock, advanced once per frame, before anything is drawn from it. + frameClock.advance(deltaMs / 1000); const label = finished || segment === undefined ? journey === undefined @@ -543,7 +550,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { composition, write, options.mutation, - motion, + transition, deltaMs, repl.overlay, ); @@ -555,7 +562,16 @@ export function* runInteractive(options: InteractiveOptions): Operation { // wide terminal will do. A new one starts that cache again and repaints // the whole screen, so the person watching sees nothing but a frame. term = yield* useTerm(measured); - painted = draw(term, state, composition, write, options.mutation, motion, 0, repl.overlay); + painted = draw( + term, + state, + composition, + write, + options.mutation, + transition, + 0, + repl.overlay, + ); } frames += 1; options.trace?.push({ @@ -563,7 +579,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { elapsedMs: elapsed, deltaSeconds: deltaMs / 1000, animating: painted.animating, - motionDone: motion === undefined ? null : motion.done, + moving: frameClock.wanted(), bytes: painted.bytes, segment: label, fixture: state.fixture.name, @@ -573,7 +589,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { held = segment?.kind === "hold" ? label : undefined; const journeyRunning = journey !== undefined && !finished; - const moving = lastPainted || (motion !== undefined && !motion.done) || journeyRunning; + const moving = lastPainted || frameClock.wanted() || journeyRunning; const active = options.mutation === "never-tick" ? false : moving; if (clock !== undefined) { const running = clock; @@ -584,7 +600,7 @@ export function* runInteractive(options: InteractiveOptions): Operation { const delay = Math.max(1, Math.round(nextDelayMs())); clock = yield* spawn(() => ticker(events, delay)); } - if (journey === undefined && motion !== undefined && motion.done && !lastPainted) { + if (journey === undefined && playback !== undefined && !frameClock.wanted() && !lastPainted) { // The transition has arrived. What remains is the fixture itself, which // is what a journal or a URL would restore. playback = undefined; diff --git a/scripts/repl-study/playback.ts b/scripts/repl-study/playback.ts index f4732bba1..9e77c080b 100644 --- a/scripts/repl-study/playback.ts +++ b/scripts/repl-study/playback.ts @@ -95,41 +95,30 @@ export function playbackBetween(from: FixtureName, to: FixtureName): Playback | return PLAYBACKS.find((playback) => playback.from === from && playback.to === to); } -export interface Motion { - /** Eased 0…1. */ - readonly progress: number; - /** Where the recorded head sits while it travels between the two moments. */ - readonly headAt: number; - /** How much of the target's transcript has arrived, as a share of its rows. */ - readonly reveal: number; - readonly done: boolean; -} - -function easeInOutCubic(fraction: number): number { - return fraction < 0.5 - ? 4 * fraction * fraction * fraction - : 1 - Math.pow(-2 * fraction + 2, 3) / 2; -} - /** - * The motion of one playback at one moment. + * What a playback tells the components: where the motion starts. * - * Pure, and a function of elapsed time alone, so the same instant can be - * rendered from a capture, from a test, or from the frame loop and come out - * identical. + * Which two moments these are is a fact about the playback, and only the thing + * that chose them knows it. How far along the motion is, and what that looks + * like, is each component's own — kept in its lifecycle and advanced by the one + * clock the host runs. */ -export function motionAt(playback: Playback, elapsedMs: number): Motion { - const fraction = - playback.durationMs <= 0 ? 1 : Math.max(0, Math.min(1, elapsedMs / playback.durationMs)); - const progress = easeInOutCubic(fraction); - const from = fixture(playback.from).history.headAt; - const to = fixture(playback.to).history.headAt; - return { - progress, - headAt: from + (to - from) * progress, - reveal: progress, - done: fraction >= 1, - }; +export interface Transition { + readonly fromHeadAt: number; + /** + * False on the very first frame of the motion, true afterwards. + * + * A playback's first frame still shows the moment it is leaving, so a drawer + * about to open is not open yet — that is what gives the renderer two + * geometries to interpolate between rather than one it has already arrived + * at. It is a fact about which frame this is, which only the thing supplying + * the time knows. + */ + readonly begun: boolean; +} + +export function transitionOf(playback: Playback, begun: boolean): Transition { + return { fromHeadAt: fixture(playback.from).history.headAt, begun }; } /** The moment a playback settles on, which is the state anything restores to. */ @@ -141,7 +130,8 @@ export function settledFixture(playback: Playback): FixtureName { export interface PlannedFrame { readonly label: string; readonly fixture: FixtureName; - readonly motion?: Motion; + /** Present only on a frame that is being played into. */ + readonly transition?: Transition; readonly deltaMs: number; readonly elapsedMs: number; } @@ -173,7 +163,7 @@ export function journeyPlan(journey: readonly Segment[] = JOURNEY, frameMs = 16) planned.push({ label: segmentLabel(segment), fixture: segment.playback.to, - motion: motionAt(segment.playback, within), + transition: transitionOf(segment.playback, within > 0), deltaMs: planned.length === 0 ? 0 : frameMs, elapsedMs: elapsed + within, }); diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index f6262572e..5edabe374 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -25,7 +25,6 @@ import { MINIMUM } from "./layout.ts"; import type { View } from "./store.ts"; import type { Mutation } from "./mutations.ts"; import type { OverlayEntry } from "./tree.ts"; -import type { Motion } from "./playback.ts"; export const C = { src: rgba(0xc8, 0xd2, 0xd9), @@ -943,13 +942,10 @@ export function bandRegion( history: HistoryView, placement: Placement, mutation?: Mutation, - motion?: Motion, + headAt = history.headAt, focused = false, ): Op[] { const rect = placement.rect; - // While a playback runs, the head is where the application says it is; the - // recorded head is where it will be when the motion settles. - const headAt = motion !== undefined && !motion.done ? motion.headAt : history.headAt; const flat = mutation === "flatten-notches"; const geometry = bandGeometry(history, placement); const { transport, inner, labelWidth, trackLeft, trackWidth } = geometry; diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 6eb2e6530..5c454853e 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -59,11 +59,17 @@ import { surfaceBarBody, transcriptBody, } from "./components.ts"; -import type { Motion } from "./playback.ts"; -import type { DrawerView, HistoryView, InputView, ReplView } from "./view.ts"; +import type { DrawerView, HistoryView, InputView, ReplView, TranscriptView } from "./view.ts"; import { drawerSlots, inputSlot, transportSlots } from "./render.ts"; import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; +import { + animates, + easeInOutCubic, + SETTLED_SECONDS, + TRANSITION_SECONDS, + useFrames, +} from "./animation.ts"; import { applyAction, layoutOf, reverseTab } from "./store.ts"; import type { Key, ReduceContext, Reduction, ReplState, Size } from "./store.ts"; import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; @@ -179,7 +185,14 @@ export interface SyncOptions { export interface PresentOptions { readonly anchor?: number; readonly mutation?: Mutation; - readonly motion?: Motion; + /** + * The moment on screen is being played into rather than cut to. + * + * It carries where the motion starts, because which two moments they are is a + * fact only the thing that chose them knows. How far along it is, and what + * that looks like, belongs to the components. + */ + readonly transition?: { readonly fromHeadAt: number }; /** Ordinary UI state: whether F1 has been pressed. Nothing about focus. */ readonly overlay?: boolean; } @@ -191,9 +204,25 @@ export interface PresentOptions { * the root holds them because the root is that lifecycle. A drawer's is looked * up among the drawers this tree mounted, not off the node. */ +/** What the root tells the transcript, beyond its own view. */ +interface TranscriptPresentation { + readonly view: TranscriptView; + readonly anchor: number; + readonly mutation?: Mutation; + readonly transition?: { readonly fromHeadAt: number }; +} + +/** What the root tells the Execution History band, beyond its own view. */ +interface HistoryPresentation { + readonly view: HistoryView; + readonly mutation?: Mutation; + readonly transition?: { readonly fromHeadAt: number }; +} + interface Owned { readonly input: Presentation; - readonly history: Presentation; + readonly transcript: Presentation; + readonly history: Presentation; readonly drawer: (node: Node) => Presentation | undefined; } @@ -239,6 +268,10 @@ export function useReplTree(state: ReplState, composed: Size): Operation = (_input, placement) => { const cell = inputSlot(placement); @@ -271,7 +305,117 @@ export function useReplTree(state: ReplState, composed: Size): Operation = (history, placement) => { + + /** + * The transcript's own arrival, and the playhead's own travel. + * + * Both are this component's to keep: how far along they are lives here, + * in variables nothing outside can see, advanced by the one clock the + * host runs. Their bodies are handed the resulting number and nothing + * else — not the clock, not the two moments, not how long it takes. + */ + type Phase = "still" | "running" | "arrived"; + let reveal = 1; + let revealPhase: Phase = "still"; + let revealed = 0; + let revealWant: (() => void) | undefined; + const releaseReveal = (): void => { + revealWant?.(); + revealWant = undefined; + }; + animates(transcriptNode, frames, ({ deltaSeconds }) => { + if (revealPhase !== "running") { + return; + } + revealed += deltaSeconds; + reveal = easeInOutCubic(Math.min(1, revealed / TRANSITION_SECONDS)); + if (revealed >= TRANSITION_SECONDS - SETTLED_SECONDS) { + // Arrived, and it stays arrived: the transition is still being + // supplied on every frame after this one, and a component that read + // it as a fresh instruction would play the same arrival for ever. + revealPhase = "arrived"; + reveal = 1; + releaseReveal(); + } + }); + + let headAt: number | undefined; + let travelFrom = 0; + let travelTo = 0; + let travelPhase: Phase = "still"; + let travelled = 0; + let travelWant: (() => void) | undefined; + const releaseTravel = (): void => { + travelWant?.(); + travelWant = undefined; + }; + animates(historyRegionNode, frames, ({ deltaSeconds }) => { + if (travelPhase !== "running") { + return; + } + travelled += deltaSeconds; + const eased = easeInOutCubic(Math.min(1, travelled / TRANSITION_SECONDS)); + headAt = travelFrom + (travelTo - travelFrom) * eased; + if (travelled >= TRANSITION_SECONDS - SETTLED_SECONDS) { + travelPhase = "arrived"; + headAt = travelTo; + releaseTravel(); + } + }); + + const presentTranscript: Presentation = (data, placement) => { + if (data.transition === undefined) { + revealPhase = "still"; + reveal = 1; + releaseReveal(); + } else if (revealPhase === "still") { + revealPhase = "running"; + revealed = 0; + reveal = 0; + revealWant = frames.want(); + } + attach( + transcriptNode, + transcriptBody, + { + view: data.view, + anchor: data.anchor, + mutation: data.mutation, + // The control: a reconstruction that lands halfway through a + // transition instead of on a moment. + reveal: data.mutation === "restore-mid-animation" ? 0.5 : reveal, + }, + placement, + ); + }; + + const presentHistory: Presentation = (data, placement) => { + const history = data.view; + if (data.transition === undefined) { + travelPhase = "still"; + headAt = history.headAt; + releaseTravel(); + } else if (travelPhase === "still") { + travelPhase = "running"; + travelFrom = data.transition.fromHeadAt; + travelTo = history.headAt; + travelled = 0; + headAt = travelFrom; + travelWant = frames.want(); + } + attach( + historyRegionNode, + historyBody, + { + view: history, + mutation: data.mutation, + headAt: + data.mutation === "restore-mid-animation" + ? history.headAt / 2 + : (headAt ?? history.headAt), + }, + placement, + ); // The band knows where it wrote each bracket, so the band says where // its controls may draw. They are mounted in the band's own order, // because both come from the same transport mode. @@ -676,6 +820,7 @@ export function useReplTree(state: ReplState, composed: Size): Operation drawers.find((one) => one.node === node)?.present, }; @@ -935,14 +1080,12 @@ function presentChild( return; } if (name === "region:transcript") { - attach( - child, - transcriptBody, + own.transcript( { view: view.transcript, anchor: options.anchor ?? 0, mutation: options.mutation, - motion: options.motion, + transition: options.transition, }, place(layout.transcript), ); @@ -962,18 +1105,10 @@ function presentChild( return; } if (name === "region:history") { - const placement = place(layout.footer); - attach( - child, - historyBody, - { - view: view.history, - mutation: options.mutation, - motion: options.motion, - }, - placement, + own.history( + { view: view.history, mutation: options.mutation, transition: options.transition }, + place(layout.footer), ); - own.history(view.history, placement); return; } if (name.startsWith("drawer:")) { diff --git a/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt index 5c158f99e..c836af340 100644 --- a/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt +++ b/scripts/tests/fixtures/repl-study/play.generated-drawer.midpoint.txt @@ -8,8 +8,8 @@ play.generated-drawer.midpoint · 200 × 50 │ document │ readme │ plan-a91f7c │ ▶ ENTER │ markdown · 3 lines ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar - │ … │ - review-b72e1d │ │ + │ │ ● WAITING │ + review-b72e1d │ … │ ● responding reviewer · turn 1 · streaming │ │ streaming · background update · selection u… │ │ │ │ diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index 6bc2a205c..1a9139c99 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -19,7 +19,7 @@ import { createInput } from "@bomb.sh/tty"; import type { Input, InputEvent } from "@bomb.sh/tty"; import { readTextFile } from "@effectionx/fs"; import { exec } from "@effectionx/process"; -import { until } from "effection"; +import { sleep, spawn, suspend, until } from "effection"; import type { Operation } from "effection"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -59,6 +59,7 @@ import { ReplInputApi, sendInput } from "../repl-study/input.ts"; import { ReplActionApi, UnownedActionError } from "../repl-study/actions.ts"; import type { ReplAction } from "../repl-study/actions.ts"; import { boxOf } from "../repl-study/component.ts"; +import { animates, createFrameService, useFrames } from "../repl-study/animation.ts"; import { UNAVAILABLE } from "../repl-study/store.ts"; import type { Node } from "../repl-study/vendor/freedom/upstream/index.ts"; import type { ReplInput } from "../repl-study/input.ts"; @@ -1596,6 +1597,59 @@ describe("input is one gesture, and what it means is an action", () => { }); }); +describe("one clock, and the components that animate against it", () => { + it("wakes a branch's own subscription, and stops when the branch goes", function* () { + const clock = yield* useFrames(); + const { state, tree } = yield* opened(DRAWER, "cp-14"); + const drawer = find(tree.root.node, "drawer:project")!; + const woken: number[] = []; + animates(drawer, clock, ({ deltaSeconds }) => woken.push(deltaSeconds)); + + clock.advance(0.016); + expect(woken).toEqual([0.016]); + + // Closing the drawer removes the branch, and the subscription was the + // branch's: nothing had to remember to take it away. + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); + expect(find(tree.root.node, "drawer:project")).toBeUndefined(); + clock.advance(0.016); + expect(woken).toEqual([0.016]); + }); + + it("asks for the clock only while something is moving", function* () { + const clock = yield* useFrames(); + const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); + // An interface with nothing moving schedules nothing at all. + expect(clock.wanted()).toBe(false); + yield* shot(tree, state); + expect(clock.wanted()).toBe(false); + }); + + it("takes its subscriptions down with the tree that owned them", function* () { + // A teardown is the whole interface going away. What it leaves behind is + // the question: a clock that still had components on it would go on waking + // them into a terminal that has been given back. + const clock = createFrameService(); + let woken = 0; + const inner = yield* spawn(function* () { + const { tree } = yield* opened(DRAWER, "cp-14"); + animates(find(tree.root.node, "drawer:project")!, clock, () => { + woken += 1; + }); + yield* suspend(); + }); + // A spawned task attaches a turn late, so the tree is mounted only after + // this. + yield* sleep(0); + clock.advance(0.016); + expect(woken).toBe(1); + + yield* inner.halt(); + clock.advance(0.016); + expect(woken).toBe(1); + }); +}); + describe("the frames, as pictures", () => { it("renders every committed focus capture exactly", function* () { const captures = yield* captureFocus(); diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index b05a0f4ea..a43e4455a 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -46,11 +46,12 @@ import { JOURNEY, journeyDurationMs, journeyPlan, - motionAt, playbackBetween, segmentDurationMs, segmentLabel, + transitionOf, } from "../repl-study/playback.ts"; +import { useFrames } from "../repl-study/animation.ts"; import { FRAME_SECONDS } from "../repl-study/host.ts"; import { intersects, layoutFor, MINIMUM, PANE_MINIMUMS, profileFor } from "../repl-study/layout.ts"; import type { Profile } from "../repl-study/layout.ts"; @@ -180,7 +181,7 @@ const TRACE_ENTRY = z.object({ elapsedMs: z.number(), deltaSeconds: z.number(), animating: z.boolean(), - motionDone: z.boolean().nullable(), + moving: z.boolean(), bytes: z.number(), segment: z.string(), fixture: z.string(), @@ -914,30 +915,47 @@ describe("animation", () => { }); it("moves the head and reveals the transcript on the application's own clock", function* () { - const start = motionAt(PLAYBACK, 0); - const middle = motionAt(PLAYBACK, PLAYBACK.durationMs / 2); - const end = motionAt(PLAYBACK, PLAYBACK.durationMs); - - expect(start.progress).toBe(0); - expect(end.done).toBe(true); - expect(middle.headAt).toBeGreaterThan(start.headAt); - expect(end.headAt).toBeGreaterThan(middle.headAt); - expect(end.headAt).toBe(fixture(PLAYBACK.to).history.headAt); - - // Time in, frame out: the same instant renders identically every time. - const once = yield* renderFrame({ - fixture: fixture(PLAYBACK.to), - view: initialView(fixture(PLAYBACK.to)), - size: PROFILE_SIZES.wide, - motion: middle, - }); - const twice = yield* renderFrame({ - fixture: fixture(PLAYBACK.to), - view: initialView(fixture(PLAYBACK.to)), - size: PROFILE_SIZES.wide, - motion: middle, - }); - expect(once.text).toBe(twice.text); + const subject = fixture(PLAYBACK.to); + const view = initialView(subject); + const size = PROFILE_SIZES.wide; + const clock = yield* useFrames(); + const composition = yield* useComposition(subject, view, size); + const transition = transitionOf(PLAYBACK, true); + // A fresh terminal each time, so every one of these is a whole screen + // rather than the handful of cells that changed since the last. + const shot = function* (): Operation { + const term = yield* useTerm(size); + return renderInto(term, { + fixture: subject, + view, + composition, + size, + transition, + deltaSeconds: 0, + }).text; + }; + + // Nothing moves on its own. Two frames of a transition with no time between + // them are the same picture, because the components advance on the clock + // and on nothing else. + const first = yield* shot(); + expect(yield* shot()).toBe(first); + + // Time in, picture out. The clock is the only thing that moved. + clock.advance(PLAYBACK.durationMs / 2 / 1000); + const middle = yield* shot(); + expect(middle).not.toBe(first); + clock.advance(PLAYBACK.durationMs / 2 / 1000); + const settled = yield* shot(); + expect(settled).not.toBe(middle); + + // And it has arrived: nothing is still asking to be woken. + expect(clock.wanted()).toBe(false); + + // The same clock, run again, draws the same film. + const played = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + const again = yield* playFrames(PLAYBACK, PROFILE_SIZES.wide); + expect(again.map((frame) => frame.text)).toEqual(played.map((frame) => frame.text)); }); it("captures a start, a midpoint and a settled frame that differ", function* () { @@ -963,7 +981,7 @@ describe("animation", () => { yield* exec(ptyCommand(command), { cwd: ROOT, arguments: ptyArguments(command) }).join(); const drawn = yield* readTrace(trace); expect(drawn.length).toBe(1); - expect(drawn[0].motionDone).toBe(false); + expect(drawn[0].moving).toBe(true); }); it("rejects a reconstruction that lands halfway through a transition", function* () { @@ -995,7 +1013,7 @@ describe("animation in a real terminal", () => { drawn.every((entry, index) => index === 0 || entry.elapsedMs > drawn[index - 1].elapsedMs), ).toBe(true); expect(drawn.some((entry) => entry.animating)).toBe(true); - expect(drawn[drawn.length - 1].motionDone).toBe(true); + expect(drawn[drawn.length - 1].moving).toBe(false); expect(drawn.filter((entry) => entry.bytes > 0).length).toBeGreaterThan(3); }); @@ -1012,7 +1030,7 @@ describe("animation in a real terminal", () => { // The interruption arrived while the transition was still running, and no // frame was drawn after it: the clock went down with the session. expect(drawn.length).toBe(4); - expect(drawn[drawn.length - 1].motionDone).toBe(false); + expect(drawn[drawn.length - 1].moving).toBe(true); expect(result.stdout.endsWith(new TextDecoder().decode(terminalModes().revert))).toBe(true); }); }); @@ -1112,9 +1130,7 @@ describe("the whole demonstration, in a real terminal", () => { const drawn = yield* readTrace(trace); expect(visited(drawn)).toEqual([...JOURNEY_SEGMENTS, "settled"]); expect(drawn.some((entry) => entry.animating)).toBe(true); - expect( - drawn.some((entry) => entry.segment.startsWith("play:") && entry.motionDone === false), - ).toBe(true); + expect(drawn.some((entry) => entry.segment.startsWith("play:") && entry.moving)).toBe(true); // It stopped because the story ended, not because it ran out of budget — // which is what it means for the clock to stop after the settled state. From 5753b87058b70545e486ebec083f37bcab639a17 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 16:35:40 -0400 Subject: [PATCH 28/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Make=20the=20clock?= =?UTF-8?q?=20a=20stream,=20owned=20by=20the=20branches=20that=20consume?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component-facing boundary is a `Stream`. The host owns one producer; a component consumes time the way it consumes anything else here — with an Effection operation, in a scope that owns it. The manual listener registry, the reachability poll that asked the tree whether a node still existed, and the release that waited for a later frame are all gone. Each of them was a second structure that could disagree with the tree. A subscription is taken inside a task attached to the node's own scope, so removing the branch closes it and nothing else has to know it was there. `animates` does not return until that task has actually subscribed — a task attaches a turn before it runs, and a caller that mounted a component and advanced the clock in the same turn would otherwise send the first frame to nobody. That is subscribe-before-spawn, arranged so the subscription still belongs to the branch rather than to whoever mounted it. `advance` is an operation now: it hands the moment over and returns once every subscriber has taken it, so nothing is ever drawn from a frame half the interface has not reached. All three loops that supply time are structured the same way — mount, tell the time, draw. The journey did it the other way round and every component started its transition from a moment it had never seen. A frame carries a timestamp rather than a delta, following `@effection-contrib/raf`. A component works out its own elapsed time by subtraction and never accumulates one — and that puts the midpoint capture back exactly where it was before item 4, so **every one of the 26 study captures, 19 focus captures and 22 catalog states is now byte-identical to the state this issue started from**. The accepted one-row change is no longer needed. The transcript's arrival and the playhead's travel move as one transition, so they are one subscription, taken at the nearest thing that owns them both: the root. Two subscriptions to the same clock for the same motion would be two answers to when it started. Evidence: a branch closed inside the turn its consumer was still attaching in takes no frame and leaves nothing waiting; a branch closed after consumption began takes no further frame; two owners receive the same timestamps from one producer and removing one leaves the survivor running; a rendered frame is of the moment just delivered, and rendering again without another frame draws the same one; and a whole-tree teardown leaves nothing subscribed and nothing wanting. The handshake and the barrier were each removed in turn and the cases that cover them failed. The complete unattended demonstration is re-proved end to end in a real pty, and the renderer still runs out of measurement room part way through and rebuilds. --- scripts/repl-study/animation.ts | 196 +++++++++--------- scripts/repl-study/capture.ts | 9 +- scripts/repl-study/host.ts | 6 +- scripts/repl-study/tree.ts | 83 ++++---- .../play.generated-drawer.midpoint.txt | 4 +- scripts/tests/repl-focus.test.ts | 147 ++++++++++--- scripts/tests/repl-study.test.ts | 4 +- 7 files changed, 270 insertions(+), 179 deletions(-) diff --git a/scripts/repl-study/animation.ts b/scripts/repl-study/animation.ts index 0123a6439..123d36157 100644 --- a/scripts/repl-study/animation.ts +++ b/scripts/repl-study/animation.ts @@ -1,39 +1,59 @@ /** - * One clock, and the components that animate against it. + * One clock, as a stream, and the components that animate against it. * - * The host owns *when* a frame happens: it is the only thing that knows whether - * anything is still moving, how long the next wait should be, and when the - * terminal has been given back. Everything else asks this service for frames - * and is told; nothing else schedules one. + * The host owns the producer: it is the only thing that knows whether anything + * is still moving, how long the next wait should be, and when the terminal has + * been given back. What it hands the interface is a `Stream` — + * not a callback to register against — so a component consumes time the same + * way it consumes anything else in this system, with an Effection operation, in + * a scope that owns it. + * + * That is the whole of the lifetime story. A subscription is taken inside a + * task attached to a Freedom node's scope, so removing the branch closes it. + * There is no registry to keep in step, nothing asking the tree whether a node + * still exists, and nothing deferred to a later frame. An earlier round had all + * three, and each of them was a second structure that could disagree with the + * tree. * * What a component does with a frame is its own. The progress of an arriving * transcript, the position of a travelling playhead — those live in the * component's lifecycle, in its own variables, and its render body reads the - * resulting snapshot and nothing else. An earlier round computed every one of - * them centrally and threaded the answer down through the presentation, which - * is the same second structure the focus rework removed: a number worked out - * somewhere else, free to disagree with the thing it described. + * resulting snapshot and nothing else. * - * Delivery is **synchronous**. A frame is a moment in time, and the component - * has to have moved before the picture of that moment is drawn — a subscription - * that woke a turn later would render the frame before last, and a capture - * would record a screen no viewer ever saw. + * This follows `@effection-contrib/raf`, which is the same shape: one producer + * of timestamps, consumed as a stream. The clock here is the host's rather than + * the browser's, because a terminal has no animation frame and the study has to + * supply time as well as measure it. */ -import { createContext, ensure, suspend } from "effection"; -import type { Operation } from "effection"; +import { createContext, createSignal, sleep } from "effection"; +import type { Operation, Stream } from "effection"; import type { Node } from "./vendor/freedom/upstream/index.ts"; -/** One frame's worth of time, in the unit the renderer measures transitions in. */ +/** One frame: when it happened, on the one clock the host runs. */ export interface Frame { - readonly deltaSeconds: number; + /** + * Seconds since the producer started. + * + * A timestamp rather than a delta, so a component works out its own elapsed + * time by subtraction and never accumulates one — forty additions of sixteen + * milliseconds do not land on 640, and a transition that never quite reaches + * its duration never quite ends. + */ + readonly at: number; } -export type Listener = (frame: Frame) => void; - -export interface FrameService { - /** Subscribe until the release is called. */ - listen(listener: Listener): () => void; +export interface Frames { + /** The one stream every component animates against. */ + readonly stream: Stream; + /** + * Deliver one frame, and return once every subscriber has taken it. + * + * Nothing is drawn from a frame half the interface has not reached yet, so + * this is an operation: the producer hands the moment over and waits for the + * consumers before the caller goes on to render it. + */ + advance(at: number): Operation; /** * Ask for the clock to keep running. * @@ -44,19 +64,17 @@ export interface FrameService { want(): () => void; /** True while at least one component is still animating. */ wanted(): boolean; - /** Deliver one frame. The host calls this; nothing else does. */ - advance(deltaSeconds: number): void; } -export function createFrameService(): FrameService { - const listeners = new Set(); +export function createFrames(): Frames { + const signal = createSignal(); let wants = 0; return { - listen(listener) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; + stream: signal, + *advance(at: number) { + signal.send({ at }); + // Every subscriber takes the frame before anything is drawn from it. + yield* sleep(0); }, want() { wants += 1; @@ -70,102 +88,78 @@ export function createFrameService(): FrameService { }; }, wanted: () => wants > 0, - advance(deltaSeconds) { - for (const listener of [...listeners]) { - listener({ deltaSeconds }); - } - }, }; } -const FrameContext = createContext("xmd:repl:frames"); +const FrameContext = createContext("xmd:repl:frames"); /** * The one frame service this run animates against. * - * The host installs it before anything is mounted, so the tree and the loop - * that drives it are looking at the same clock. A caller that mounts a tree - * without one gets a service of its own — which is what a capture wants: time - * supplied rather than measured, and nothing shared with any other run. + * The host installs it before anything is mounted, so the tree's components and + * the loop that drives them are looking at the same clock. A caller that mounts + * a tree without one gets a producer of its own — which is what a capture + * wants: time supplied rather than measured, and nothing shared with any other + * run. */ -export function useFrames(): Operation { +export function useFrames(): Operation { return { *[Symbol.iterator]() { const existing = yield* FrameContext.get(); if (existing !== undefined) { return existing; } - return yield* FrameContext.set(createFrameService()); + return yield* FrameContext.set(createFrames()); }, }; } /** - * Subscribe one node's own scope to the clock. + * Consume frames for as long as this branch exists. + * + * The consumer is a task in the node's own scope, and the subscription is taken + * inside it — so the scope that ends when the branch is removed is the scope + * that closes the subscription. Nothing else has to know it was ever there. * - * The subscription belongs to the node: it is created in the node's scope and - * released when that scope ends, so removing a branch takes its animation with - * it. Nothing has to remember to unsubscribe, because there is nowhere for the - * registration to outlive the thing it was for. + * A task attaches a turn before it runs, so a caller mounts every consumer it + * means to have and then lets the scheduler reach them before the first frame. + * `useReplTree` does exactly that, which is why nothing here has to guess + * whether it was subscribed in time. */ -export function animates(node: Node, service: FrameService, listener: Listener): void { - // Subscribed **now**, not on the next turn. A task spawned into a scope does - // not begin until the scheduler gets one, and a frame loop that renders - // without yielding never gives it one — the whole journey ran with nothing - // subscribed and nothing moved. - // - // The node's scope owns the registration and releases it when the branch - // goes. It cannot be the *only* thing that does, for the same reason: a - // branch removed before that task ever started would be halted with nothing - // registered to release. So the clock also asks the tree, which is the - // authority on what exists, and a node that is no longer in it is not woken - // again. - let release = (): void => {}; - release = service.listen((frame) => { - if (!attached(node)) { - release(); - return; - } - listener(frame); - }); - const owned = release; - node.scope.run(function* () { - yield* ensure(owned); - yield* suspend(); - }); -} - -/** True while this node is still reachable from the tree it was mounted in. */ -function attached(node: Node): boolean { - for (let at: Node = node; at.parent !== undefined; at = at.parent) { - let found = false; - for (const child of at.parent.children) { - if (child === at) { - found = true; - break; - } - } - if (!found) { - return false; - } - } - return true; +export function animates( + node: Node, + frames: Frames, + apply: (frame: Frame) => void, +): Operation { + return { + *[Symbol.iterator]() { + // A task attaches a turn before it runs, so this does not return until + // the consumer has actually subscribed. Without that, a caller that + // mounted a component and advanced the clock in the same turn would send + // the first frame to nobody — which is the whole of what + // subscribe-before-spawn is about, arranged so that the subscription + // still belongs to the branch rather than to whoever mounted it. + const subscribed = createSignal(); + const ready = yield* subscribed; + yield* node.scope.spawn(function* () { + const subscription = yield* frames.stream; + subscribed.send(); + for (;;) { + const next = yield* subscription.next(); + if (next.done) { + return; + } + apply(next.value); + } + }); + yield* ready.next(); + }, + }; } /** How long one transition takes, in the seconds the renderer measures in. */ export const TRANSITION_SECONDS = 0.64; -/** - * How close to the end counts as the end. - * - * Elapsed time is accumulated a frame at a time, so forty sixteen-millisecond - * steps land a few parts in 10^16 short of the 640 they add up to. A transition - * that close to its duration has ended: there is no frame left to draw the - * difference in, and waiting for exact equality would leave one running for - * ever. - */ -export const SETTLED_SECONDS = 1e-9; - export function easeInOutCubic(fraction: number): number { return fraction < 0.5 ? 4 * fraction * fraction * fraction diff --git a/scripts/repl-study/capture.ts b/scripts/repl-study/capture.ts index 2f1b26a0d..527f0f133 100644 --- a/scripts/repl-study/capture.ts +++ b/scripts/repl-study/capture.ts @@ -307,7 +307,9 @@ export function* playFrames( for (let index = 0; index < limit; index += 1) { const transition = transitionOf(playback, index > 0); const deltaSeconds = index === 0 ? 0 : frameMs / 1000; - clock.advance(deltaSeconds); + // The clock, then the picture: every component has taken this frame before + // anything is drawn from it. + yield* clock.advance((index * frameMs) / 1000); const frame = renderInto(term, { fixture: subject, view, @@ -355,7 +357,6 @@ export function* journeyFrames( const frames: JourneyFrame[] = []; const clock = yield* useFrames(); for (const planned of journeyPlan(JOURNEY, frameMs)) { - clock.advance(planned.deltaMs / 1000); const subject = fixture(planned.fixture); const view = initialView(subject); // One composition per moment, reused across that moment's frames: a @@ -365,6 +366,10 @@ export function* journeyFrames( composition = yield* useComposition(subject, view, size); compositions.set(planned.fixture, composition); } + // Mounted, then told the time, then drawn. A component that was handed its + // first frame before it existed would start its transition from a moment it + // never saw. + yield* clock.advance(planned.elapsedMs / 1000); const request: FrameRequest = { fixture: subject, view, diff --git a/scripts/repl-study/host.ts b/scripts/repl-study/host.ts index 21cb98abc..8fab986fc 100644 --- a/scripts/repl-study/host.ts +++ b/scripts/repl-study/host.ts @@ -374,6 +374,8 @@ export function* runInteractive(options: InteractiveOptions): Operation { // The one clock, installed before anything is mounted, so the tree's // components and the loop that drives them are looking at the same service. const frameClock = yield* useFrames(); + /** Seconds since this run began, which is what a frame is stamped with. */ + let clockAt = 0; // The tree is acquired before the terminal is touched, so its teardown runs // after the terminal has been given back rather than into a restored one. const tree = yield* useReplTree(repl, { cols: state.cols, rows: state.rows }); @@ -514,7 +516,9 @@ export function* runInteractive(options: InteractiveOptions): Operation { const segment = currentSegment(); const transition = playback === undefined ? undefined : transitionOf(playback, elapsed > 0); // The one clock, advanced once per frame, before anything is drawn from it. - frameClock.advance(deltaMs / 1000); + // Every component has taken this moment by the time this returns. + clockAt += deltaMs / 1000; + yield* frameClock.advance(clockAt); const label = finished || segment === undefined ? journey === undefined diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 5c454853e..dcd47a882 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -63,13 +63,7 @@ import type { DrawerView, HistoryView, InputView, ReplView, TranscriptView } fro import { drawerSlots, inputSlot, transportSlots } from "./render.ts"; import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; -import { - animates, - easeInOutCubic, - SETTLED_SECONDS, - TRANSITION_SECONDS, - useFrames, -} from "./animation.ts"; +import { animates, easeInOutCubic, TRANSITION_SECONDS, useFrames } from "./animation.ts"; import { applyAction, layoutOf, reverseTab } from "./store.ts"; import type { Key, ReduceContext, Reduction, ReplState, Size } from "./store.ts"; import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; @@ -314,52 +308,59 @@ export function useReplTree(state: ReplState, composed: Size): Operation void) | undefined; - const releaseReveal = (): void => { - revealWant?.(); - revealWant = undefined; - }; - animates(transcriptNode, frames, ({ deltaSeconds }) => { - if (revealPhase !== "running") { - return; - } - revealed += deltaSeconds; - reveal = easeInOutCubic(Math.min(1, revealed / TRANSITION_SECONDS)); - if (revealed >= TRANSITION_SECONDS - SETTLED_SECONDS) { - // Arrived, and it stays arrived: the transition is still being - // supplied on every frame after this one, and a component that read - // it as a fresh instruction would play the same arrival for ever. - revealPhase = "arrived"; - reveal = 1; - releaseReveal(); - } - }); - let headAt: number | undefined; let travelFrom = 0; let travelTo = 0; + let travelStart = 0; let travelPhase: Phase = "still"; - let travelled = 0; let travelWant: (() => void) | undefined; + const releaseReveal = (): void => { + revealWant?.(); + revealWant = undefined; + }; const releaseTravel = (): void => { travelWant?.(); travelWant = undefined; }; - animates(historyRegionNode, frames, ({ deltaSeconds }) => { - if (travelPhase !== "running") { - return; + yield* animates(root.node, frames, ({ at }) => { + now = at; + if (revealPhase === "running") { + const fraction = Math.min(1, (at - revealFrom) / TRANSITION_SECONDS); + reveal = easeInOutCubic(fraction); + if (fraction >= 1) { + // Arrived, and it stays arrived: the transition is still being + // supplied on every frame after this one, and a component that read + // it as a fresh instruction would play the same arrival for ever. + revealPhase = "arrived"; + releaseReveal(); + } } - travelled += deltaSeconds; - const eased = easeInOutCubic(Math.min(1, travelled / TRANSITION_SECONDS)); - headAt = travelFrom + (travelTo - travelFrom) * eased; - if (travelled >= TRANSITION_SECONDS - SETTLED_SECONDS) { - travelPhase = "arrived"; - headAt = travelTo; - releaseTravel(); + if (travelPhase === "running") { + const fraction = Math.min(1, (at - travelStart) / TRANSITION_SECONDS); + headAt = travelFrom + (travelTo - travelFrom) * easeInOutCubic(fraction); + if (fraction >= 1) { + travelPhase = "arrived"; + headAt = travelTo; + releaseTravel(); + } } }); @@ -370,7 +371,7 @@ export function useReplTree(state: ReplState, composed: Size): Operation ENTER │ markdown · 3 lines ✓ completed planner · turn 1 · returned 5… │ │ ▾ Ask for the project details │ # Northstar - │ │ ● WAITING │ - review-b72e1d │ … │ + │ … │ + review-b72e1d │ │ ● responding reviewer · turn 1 · streaming │ │ streaming · background update · selection u… │ │ │ │ diff --git a/scripts/tests/repl-focus.test.ts b/scripts/tests/repl-focus.test.ts index 1a9139c99..891ef9410 100644 --- a/scripts/tests/repl-focus.test.ts +++ b/scripts/tests/repl-focus.test.ts @@ -30,8 +30,11 @@ import { composeInto, PROFILE_SIZES, renderInto, + useComposition, useTerm, } from "../repl-study/capture.ts"; +import { fixture } from "../repl-study/fixtures.ts"; +import { playbackBetween, transitionOf } from "../repl-study/playback.ts"; import { FRAMES, frame, stateFor, useFrame } from "../repl-study/frames.ts"; import { openingState, scanKeys } from "../repl-study/host.ts"; import { fold, JOURNAL, journalThrough, markers, siblingsOf } from "../repl-study/journal.ts"; @@ -45,6 +48,7 @@ import { import { fixtureFor, hydrate, + initialView, layoutOf, openDrawer, projection, @@ -59,7 +63,8 @@ import { ReplInputApi, sendInput } from "../repl-study/input.ts"; import { ReplActionApi, UnownedActionError } from "../repl-study/actions.ts"; import type { ReplAction } from "../repl-study/actions.ts"; import { boxOf } from "../repl-study/component.ts"; -import { animates, createFrameService, useFrames } from "../repl-study/animation.ts"; +import { animates, createFrames, useFrames } from "../repl-study/animation.ts"; +import type { Frames } from "../repl-study/animation.ts"; import { UNAVAILABLE } from "../repl-study/store.ts"; import type { Node } from "../repl-study/vendor/freedom/upstream/index.ts"; import type { ReplInput } from "../repl-study/input.ts"; @@ -1598,55 +1603,137 @@ describe("input is one gesture, and what it means is an action", () => { }); describe("one clock, and the components that animate against it", () => { - it("wakes a branch's own subscription, and stops when the branch goes", function* () { + /** A branch, and a record of every frame it took. */ + function* subscriber( + tree: ReplTree, + name: string, + seen: number[], + clock: Frames, + ): Operation { + const node = find(tree.root.node, name)!; + yield* animates(node, clock, ({ at }) => seen.push(at)); + return node; + } + + it("takes no frame when the branch goes before its consumer ever ran", function* () { + // A task attaches a turn before it runs. This closes the branch inside that + // turn, while the consumer is still being attached — so the scope that + // would have owned the subscription ends before there is one. const clock = yield* useFrames(); const { state, tree } = yield* opened(DRAWER, "cp-14"); - const drawer = find(tree.root.node, "drawer:project")!; - const woken: number[] = []; - animates(drawer, clock, ({ deltaSeconds }) => woken.push(deltaSeconds)); - - clock.advance(0.016); - expect(woken).toEqual([0.016]); + const seen: number[] = []; + const node = find(tree.root.node, "drawer:project")!; + const mounting = yield* spawn(function* () { + yield* animates(node, clock, ({ at }) => seen.push(at)); + }); - // Closing the drawer removes the branch, and the subscription was the - // branch's: nothing had to remember to take it away. + // No turn was given to the consumer: the branch closes first. yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); expect(find(tree.root.node, "drawer:project")).toBeUndefined(); - clock.advance(0.016); - expect(woken).toEqual([0.016]); + + yield* clock.advance(1); + expect(seen).toEqual([]); + // And the teardown finishes: nothing is left waiting on a branch that has + // gone. + yield* mounting.halt(); + yield* clock.advance(2); + expect(seen).toEqual([]); + }); + + it("closes it when the branch goes after consumption has begun", function* () { + const clock = yield* useFrames(); + const { state, tree } = yield* opened(DRAWER, "cp-14"); + const seen: number[] = []; + yield* subscriber(tree, "drawer:project", seen, clock); + + yield* clock.advance(1); + expect(seen).toEqual([1]); + + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); + yield* clock.advance(2); + expect(seen).toEqual([1]); + }); + + it("gives two owners the same timestamps, and keeps only the survivor", function* () { + const clock = yield* useFrames(); + const { state, tree } = yield* opened(DRAWER, "cp-14"); + const closing: number[] = []; + const staying: number[] = []; + yield* subscriber(tree, "drawer:project", closing, clock); + yield* subscriber(tree, "region:transcript", staying, clock); + + yield* clock.advance(0.5); + yield* clock.advance(1); + // One producer, two owners, the same moments. + expect(closing).toEqual([0.5, 1]); + expect(staying).toEqual(closing); + + yield* tree.sync(hydrate("xmd://repl/e1/transcript/entry-1/document", state.journal)); + yield* clock.advance(1.5); + expect(closing).toEqual([0.5, 1]); + expect(staying).toEqual([0.5, 1, 1.5]); + }); + + it("draws the frame it was just given, with nothing left to arrive", function* () { + // The picture is of the moment that was delivered, not of the one before + // it: every subscriber has taken the frame by the time `advance` returns. + const clock = yield* useFrames(); + const subject = fixture("drawer"); + const view = initialView(subject); + const composition = yield* useComposition(subject, view, WIDE); + const transition = transitionOf(playbackBetween("generated", "drawer")!, true); + const shot = function* (): Operation { + const term = yield* useTerm(WIDE); + return renderInto(term, { + fixture: subject, + view, + composition, + size: WIDE, + transition, + deltaSeconds: 0, + }).text; + }; + + yield* clock.advance(0); + const opening = yield* shot(); + yield* clock.advance(0.32); + const half = yield* shot(); + yield* clock.advance(0.64); + const whole = yield* shot(); + + expect(half).not.toBe(opening); + expect(whole).not.toBe(half); + // Rendering again without another frame draws the same moment: nothing + // arrived between the two, because nothing was sent. + expect(yield* shot()).toBe(whole); + expect(clock.wanted()).toBe(false); }); it("asks for the clock only while something is moving", function* () { const clock = yield* useFrames(); const { state, tree } = yield* opened("xmd://repl/e1/transcript/entry-1/document", "cp-14"); - // An interface with nothing moving schedules nothing at all. expect(clock.wanted()).toBe(false); yield* shot(tree, state); expect(clock.wanted()).toBe(false); }); - it("takes its subscriptions down with the tree that owned them", function* () { - // A teardown is the whole interface going away. What it leaves behind is - // the question: a clock that still had components on it would go on waking - // them into a terminal that has been given back. - const clock = createFrameService(); - let woken = 0; - const inner = yield* spawn(function* () { + it("leaves nothing subscribed and nothing wanting when the whole tree goes", function* () { + const clock = createFrames(); + const seen: number[] = []; + const mounted = yield* spawn(function* () { const { tree } = yield* opened(DRAWER, "cp-14"); - animates(find(tree.root.node, "drawer:project")!, clock, () => { - woken += 1; - }); + yield* animates(find(tree.root.node, "drawer:project")!, clock, ({ at }) => seen.push(at)); yield* suspend(); }); - // A spawned task attaches a turn late, so the tree is mounted only after - // this. + // A spawned task attaches a turn late, so the tree is mounted after this. yield* sleep(0); - clock.advance(0.016); - expect(woken).toBe(1); + yield* clock.advance(1); + expect(seen).toEqual([1]); - yield* inner.halt(); - clock.advance(0.016); - expect(woken).toBe(1); + yield* mounted.halt(); + yield* clock.advance(2); + expect(seen).toEqual([1]); + expect(clock.wanted()).toBe(false); }); }); diff --git a/scripts/tests/repl-study.test.ts b/scripts/tests/repl-study.test.ts index a43e4455a..1013c13c9 100644 --- a/scripts/tests/repl-study.test.ts +++ b/scripts/tests/repl-study.test.ts @@ -942,10 +942,10 @@ describe("animation", () => { expect(yield* shot()).toBe(first); // Time in, picture out. The clock is the only thing that moved. - clock.advance(PLAYBACK.durationMs / 2 / 1000); + yield* clock.advance(PLAYBACK.durationMs / 2 / 1000); const middle = yield* shot(); expect(middle).not.toBe(first); - clock.advance(PLAYBACK.durationMs / 2 / 1000); + yield* clock.advance(PLAYBACK.durationMs / 1000); const settled = yield* shot(); expect(settled).not.toBe(middle); From 95300dd1b4c19c1a9ab800185a8658cf4091dd15 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 16:44:17 -0400 Subject: [PATCH 29/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Let=20a=20branch=20o?= =?UTF-8?q?wn=20its=20demand=20on=20the=20clock,=20and=20acknowledge=20eve?= =?UTF-8?q?ry=20frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demand is a lifecycle's now. `want()` comes from the branch's own animation, and the scope that ends when the node is removed releases every demand still outstanding — during that teardown, with no further frame, no presentation pass and nothing asking the tree whether the node is still there. A transition torn down halfway used to leave the clock running for a component that had gone. The producer is a `Channel`. It sends from inside an operation, which is what a channel is for; `Signal` is for the other direction, a callback arriving from outside Effection, and nothing here is that. The handshake that makes subscribe-before-spawn work is a channel too. Delivery is acknowledged rather than timed. `advance` counts the consumers, sends, and waits for that many acknowledgements, so nothing is ever drawn from a moment half the interface has not reached. A consumer halted holding a frame acknowledges it on the way out, so a teardown can never leave the producer waiting — which is the other half of the evidence: the halt returns. Consuming moved onto the service itself (`frames.animate(node, apply)`). It was a module-scoped `WeakMap` keyed by service, which this repository forbids for the right reason: a process-lifetime registry shared by every run. The producer's own state is now closed over by the one thing that owns it. The whole-tree teardown case is replaced by the path that discriminates: mount a composition against a retained producer, present a real transition and watch `wanted()` become true, tear the composition down before the timestamp that would have settled it, watch the teardown return with `wanted()` false, then deliver that timestamp and watch a witness on the removed root take nothing. Kept: the timestamp stream, the subscribe-before-spawn handshake, one subscription at the nearest common ancestor, and the unattended demonstration. All 26 study captures, 19 focus captures and 22 catalog states are unchanged. Releasing demand, acknowledging a frame and the handshake were each removed in turn and the cases that cover them failed. --- scripts/repl-study/animation.ts | 221 ++++++++++++++++++------------- scripts/repl-study/tree.ts | 8 +- scripts/tests/repl-focus.test.ts | 59 +++++++-- 3 files changed, 183 insertions(+), 105 deletions(-) diff --git a/scripts/repl-study/animation.ts b/scripts/repl-study/animation.ts index 123d36157..698d835c6 100644 --- a/scripts/repl-study/animation.ts +++ b/scripts/repl-study/animation.ts @@ -1,33 +1,37 @@ /** - * One clock, as a stream, and the components that animate against it. + * One clock, as a stream, and the branches that consume it. * * The host owns the producer: it is the only thing that knows whether anything * is still moving, how long the next wait should be, and when the terminal has * been given back. What it hands the interface is a `Stream` — - * not a callback to register against — so a component consumes time the same - * way it consumes anything else in this system, with an Effection operation, in - * a scope that owns it. + * not a callback to register against — so a component consumes time the way it + * consumes anything else here, with an Effection operation, in a scope that + * owns it. * - * That is the whole of the lifetime story. A subscription is taken inside a - * task attached to a Freedom node's scope, so removing the branch closes it. - * There is no registry to keep in step, nothing asking the tree whether a node - * still exists, and nothing deferred to a later frame. An earlier round had all - * three, and each of them was a second structure that could disagree with the - * tree. + * Everything a branch takes from the clock belongs to the branch: its + * subscription, and its demand for more frames. Both are released by the scope + * that ends when the node is removed. There is no registry to keep in step, + * nothing asking the tree whether a node still exists, and nothing deferred to + * a later frame. An earlier round had all three, and each of them was a second + * structure that could disagree with the tree. * - * What a component does with a frame is its own. The progress of an arriving - * transcript, the position of a travelling playhead — those live in the - * component's lifecycle, in its own variables, and its render body reads the - * resulting snapshot and nothing else. + * The producer is a `Channel`, because it sends from inside an operation. + * `Signal` is for the other direction — a callback arriving from outside + * Effection — and nothing here is that. * - * This follows `@effection-contrib/raf`, which is the same shape: one producer - * of timestamps, consumed as a stream. The clock here is the host's rather than - * the browser's, because a terminal has no animation frame and the study has to - * supply time as well as measure it. + * Delivery is acknowledged, not timed. `advance` waits for every consumer to + * say it has applied the frame, so nothing is ever drawn from a moment half the + * interface has not reached. Waiting a scheduler turn instead would be a guess + * that happened to be right. + * + * This follows `@effection-contrib/raf`: one producer of timestamps, consumed + * as a stream. The clock is the host's rather than the browser's, because a + * terminal has no animation frame and the study has to supply time as well as + * measure it. */ -import { createContext, createSignal, sleep } from "effection"; -import type { Operation, Stream } from "effection"; +import { createChannel, createContext, ensure } from "effection"; +import type { Channel, Operation, Stream } from "effection"; import type { Node } from "./vendor/freedom/upstream/index.ts"; /** One frame: when it happened, on the one clock the host runs. */ @@ -47,51 +51,133 @@ export interface Frames { /** The one stream every component animates against. */ readonly stream: Stream; /** - * Deliver one frame, and return once every subscriber has taken it. + * Deliver one frame, and return once every consumer has applied it. * * Nothing is drawn from a frame half the interface has not reached yet, so - * this is an operation: the producer hands the moment over and waits for the - * consumers before the caller goes on to render it. + * this is an operation: the producer hands the moment over and waits to be + * told it has landed. */ advance(at: number): Operation; + /** True while at least one branch is still asking to be woken. */ + wanted(): boolean; + /** + * Consume frames for as long as this branch exists. + * + * The consumer is a task in the node's own scope and the subscription is + * taken inside it, so the scope that ends when the branch is removed is the + * scope that closes it. The same scope releases every demand the branch still + * holds and acknowledges a frame it was halted in the middle of, so a + * teardown can neither leave the clock running nor leave the producer + * waiting. + * + * This does not return until that task has actually subscribed. A task + * attaches a turn before it runs, and a caller that mounted a component and + * advanced the clock in the same turn would otherwise send the first frame to + * nobody. That is subscribe-before-spawn, arranged so the subscription still + * belongs to the branch rather than to whoever mounted it. + */ + animate(node: Node, apply: (frame: Frame) => void): Operation; +} + +/** What a branch gets for animating: its own demand on the clock. */ +export interface Animation { /** - * Ask for the clock to keep running. + * Ask for the clock while a transition runs. * - * A component that is animating says so and releases when it settles. The - * host runs the clock while anything still wants it, so an interface with - * nothing moving schedules nothing at all. + * Released when the transition settles, or by the branch's own teardown if it + * is removed before then. A demand cannot outlive what asked for it. */ want(): () => void; - /** True while at least one component is still animating. */ - wanted(): boolean; } export function createFrames(): Frames { - const signal = createSignal(); - let wants = 0; + const frames = createChannel(); + const acks = createChannel(); + let consumers = 0; + let demands = 0; return { - stream: signal, + stream: frames, *advance(at: number) { - signal.send({ at }); - // Every subscriber takes the frame before anything is drawn from it. - yield* sleep(0); + const expected = consumers; + if (expected === 0) { + yield* frames.send({ at }); + return; + } + // Subscribed to the acknowledgements before the frame goes out, so none + // of them can be missed between sending and waiting for them. + const acked = yield* acks; + yield* frames.send({ at }); + for (let taken = 0; taken < expected; taken += 1) { + yield* acked.next(); + } }, - want() { - wants += 1; - let released = false; - return () => { - if (released) { - return; - } - released = true; - wants -= 1; + wanted: () => demands > 0, + animate(node: Node, apply: (frame: Frame) => void): Operation { + return { + *[Symbol.iterator]() { + const held = new Set<() => void>(); + let owing = false; + const started = createChannel(); + const ready = yield* started; + yield* node.scope.spawn(function* () { + const subscription = yield* frames; + yield* ensure(function* () { + for (const release of [...held]) { + release(); + } + consumers -= 1; + if (owing) { + // Halted holding a frame. The producer is still counting this + // one, and an acknowledgement it never gets is a loop that + // never ends. + owing = false; + yield* acks.send(); + } + }); + consumers += 1; + yield* started.send(); + for (;;) { + const next = yield* subscription.next(); + if (next.done) { + return; + } + owing = true; + apply(next.value); + yield* acks.send(); + owing = false; + } + }); + yield* ready.next(); + return { + want() { + demands += 1; + let released = false; + const release = (): void => { + if (released) { + return; + } + released = true; + demands -= 1; + held.delete(release); + }; + held.add(release); + return release; + }, + }; + }, }; }, - wanted: () => wants > 0, }; } -const FrameContext = createContext("xmd:repl:frames"); +/** + * Where the one service lives for a run. + * + * Exported so a caller can install a producer it retains — which is how the + * evidence keeps hold of the clock while the thing that was animating against + * it is torn down. + */ +export const FrameContext = createContext("xmd:repl:frames"); /** * The one frame service this run animates against. @@ -114,49 +200,6 @@ export function useFrames(): Operation { }; } -/** - * Consume frames for as long as this branch exists. - * - * The consumer is a task in the node's own scope, and the subscription is taken - * inside it — so the scope that ends when the branch is removed is the scope - * that closes the subscription. Nothing else has to know it was ever there. - * - * A task attaches a turn before it runs, so a caller mounts every consumer it - * means to have and then lets the scheduler reach them before the first frame. - * `useReplTree` does exactly that, which is why nothing here has to guess - * whether it was subscribed in time. - */ -export function animates( - node: Node, - frames: Frames, - apply: (frame: Frame) => void, -): Operation { - return { - *[Symbol.iterator]() { - // A task attaches a turn before it runs, so this does not return until - // the consumer has actually subscribed. Without that, a caller that - // mounted a component and advanced the clock in the same turn would send - // the first frame to nobody — which is the whole of what - // subscribe-before-spawn is about, arranged so that the subscription - // still belongs to the branch rather than to whoever mounted it. - const subscribed = createSignal(); - const ready = yield* subscribed; - yield* node.scope.spawn(function* () { - const subscription = yield* frames.stream; - subscribed.send(); - for (;;) { - const next = yield* subscription.next(); - if (next.done) { - return; - } - apply(next.value); - } - }); - yield* ready.next(); - }, - }; -} - /** How long one transition takes, in the seconds the renderer measures in. */ export const TRANSITION_SECONDS = 0.64; diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index dcd47a882..62195692b 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -63,7 +63,7 @@ import type { DrawerView, HistoryView, InputView, ReplView, TranscriptView } fro import { drawerSlots, inputSlot, transportSlots } from "./render.ts"; import type { Layout, Rect } from "./layout.ts"; import { isDrawerKind } from "./fixtures.ts"; -import { animates, easeInOutCubic, TRANSITION_SECONDS, useFrames } from "./animation.ts"; +import { easeInOutCubic, TRANSITION_SECONDS, useFrames } from "./animation.ts"; import { applyAction, layoutOf, reverseTab } from "./store.ts"; import type { Key, ReduceContext, Reduction, ReplState, Size } from "./store.ts"; import { isRouteSurface, ROUTE_SURFACES, topDrawer } from "./route.ts"; @@ -340,7 +340,7 @@ export function useReplTree(state: ReplState, composed: Size): Operation { + const animation = yield* frames.animate(root.node, ({ at }) => { now = at; if (revealPhase === "running") { const fraction = Math.min(1, (at - revealFrom) / TRANSITION_SECONDS); @@ -373,7 +373,7 @@ export function useReplTree(state: ReplState, composed: Size): Operation { clock: Frames, ): Operation { const node = find(tree.root.node, name)!; - yield* animates(node, clock, ({ at }) => seen.push(at)); + yield* clock.animate(node, ({ at }) => seen.push(at)); return node; } @@ -1624,7 +1629,7 @@ describe("one clock, and the components that animate against it", () => { const seen: number[] = []; const node = find(tree.root.node, "drawer:project")!; const mounting = yield* spawn(function* () { - yield* animates(node, clock, ({ at }) => seen.push(at)); + yield* clock.animate(node, ({ at }) => seen.push(at)); }); // No turn was given to the consumer: the branch closes first. @@ -1717,23 +1722,53 @@ describe("one clock, and the components that animate against it", () => { expect(clock.wanted()).toBe(false); }); - it("leaves nothing subscribed and nothing wanting when the whole tree goes", function* () { + it("releases a running transition's demand when its owner is torn down", function* () { + // A demand cannot outlive what asked for it. This tears the composition + // down in the middle of a transition — before the timestamp that would have + // settled it — and nothing is left asking to be woken. const clock = createFrames(); - const seen: number[] = []; + const subject = fixture("drawer"); + const view = initialView(subject); + const transition = transitionOf(playbackBetween("generated", "drawer")!, true); + let text = ""; + + const applied: number[] = []; + const mounted = yield* spawn(function* () { - const { tree } = yield* opened(DRAWER, "cp-14"); - yield* animates(find(tree.root.node, "drawer:project")!, clock, ({ at }) => seen.push(at)); + yield* FrameContext.set(clock); + const composition = yield* useComposition(subject, view, WIDE); + // A witness on the same root, so what the removed tree does with a frame + // is observable rather than inferred. + yield* clock.animate(composition.tree.root.node, ({ at }) => applied.push(at)); + const term = yield* useTerm(WIDE); + // Presenting a real transition is what takes the demand: the transcript + // starts arriving and the playhead starts travelling. + yield* clock.advance(0); + text = renderInto(term, { + fixture: subject, + view, + composition, + size: WIDE, + transition, + deltaSeconds: 0, + }).text; yield* suspend(); }); - // A spawned task attaches a turn late, so the tree is mounted after this. + // A spawned task attaches a turn late, so the composition exists after this. yield* sleep(0); - yield* clock.advance(1); - expect(seen).toEqual([1]); + expect(text).not.toBe(""); + expect(applied).toEqual([0]); + expect(clock.wanted()).toBe(true); + // Torn down before the timestamp that would have settled it. This returns, + // which is the other half: a producer left waiting on a consumer that has + // gone would never let the teardown finish. yield* mounted.halt(); - yield* clock.advance(2); - expect(seen).toEqual([1]); expect(clock.wanted()).toBe(false); + + // And the moment that would have finished the transition reaches nothing. + yield* clock.advance(TRANSITION_SECONDS); + expect(applied).toEqual([0]); }); }); From 30ae4367b96a8eff08676e8dfb7746ceb993fc85 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 24 Sep 2026 16:53:19 -0400 Subject: [PATCH 30/57] =?UTF-8?q?=E2=9C=A8=20Refuse=20a=20URL=20that=20nam?= =?UTF-8?q?es=20a=20place=20the=20execution=20never=20went?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route addresses structure and never creates any. The entry, the scopes inside it, the recorded marker and the suspension a drawer answers are facts the journal holds, and a URL naming one that is not there cannot be opened. `router.ts` asks that question of a route and a journal and answers with nothing, or with the one segment that does not resolve — in words the screen can draw: which segment, what it named, and what the execution actually did. The alternative is what a router usually does by accident: resolve what it can, drop what it cannot, and render a screen that looks like somewhere. That screen is the dangerous one. It shows an execution that never ran at a moment that was never recorded, and nothing on it says which part was made up. `render-partial-route` is that router, kept as a control. A refused location mounts nothing else. There are no panes to focus, reach or type into, because there is nowhere to be — one node that says so, and the screen it draws. Hidden content has no branch, and a location that does not exist is entirely hidden. Refusals can only arrive from a cold URL: every navigation this interface performs is built from what the journal offers, so nothing it does can reach one. Evidence: every location the study actually opens — all 22 catalog states and all 14 study frames — resolves; each kind of segment refuses by name with a reason; the same drawer refuses against a moment before anything suspended and resolves after; a refused route mounts one node, has an empty ring and an empty map, and draws the refusal instead of a screen; and with the control it draws the plausible screen instead. Each of the four resolution rules was broken in turn and the case that covers it failed. Also corrects the reference in `animation.ts`: the package is `@effectionx/raf`. No capture moves. --- scripts/repl-study/animation.ts | 2 +- scripts/repl-study/components.ts | 8 +++ scripts/repl-study/mutations.ts | 2 + scripts/repl-study/render.ts | 24 ++++++++ scripts/repl-study/router.ts | 96 ++++++++++++++++++++++++++++++++ scripts/repl-study/store.ts | 13 ++++- scripts/repl-study/tree.ts | 37 +++++++++++- scripts/tests/repl-focus.test.ts | 91 ++++++++++++++++++++++++++++++ 8 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 scripts/repl-study/router.ts diff --git a/scripts/repl-study/animation.ts b/scripts/repl-study/animation.ts index 698d835c6..1b195cd3b 100644 --- a/scripts/repl-study/animation.ts +++ b/scripts/repl-study/animation.ts @@ -24,7 +24,7 @@ * interface has not reached. Waiting a scheduler turn instead would be a guess * that happened to be right. * - * This follows `@effection-contrib/raf`: one producer of timestamps, consumed + * This follows `@effectionx/raf`: one producer of timestamps, consumed * as a stream. The clock is the host's rather than the browser's, because a * terminal has no animation frame and the study has to supply time as well as * measure it. diff --git a/scripts/repl-study/components.ts b/scripts/repl-study/components.ts index 4f7cfff99..c947af12a 100644 --- a/scripts/repl-study/components.ts +++ b/scripts/repl-study/components.ts @@ -24,6 +24,7 @@ import { focusMark, regionMark, inputRegion, + refusedRegion, rule, surfaceBarRegion, tooSmallRegion, @@ -41,6 +42,7 @@ import type { VisualLine } from "./render.ts"; import type { OverlayEntry } from "./tree.ts"; import type { Layout, Rect, SurfaceName } from "./layout.ts"; import type { Mutation } from "./mutations.ts"; +import type { Refusal } from "./router.ts"; import type { BindingsView, DrawerView, @@ -419,6 +421,12 @@ export interface FocusMapData { readonly visible: boolean; } +/** The refusal a URL that names nowhere gets instead of a plausible screen. */ +export const refusedBody: Body<{ readonly refusal: Refusal; readonly layout: Layout }> = ({ + self, + data, +}) => refusedRegion(self.id, data.refusal, data.layout); + /** The refusal a terminal below the supported minimum gets instead of a screen. */ export const refusalBody: Body = ({ self, data }) => tooSmallRegion(self.id, data); diff --git a/scripts/repl-study/mutations.ts b/scripts/repl-study/mutations.ts index f6a57aa1b..a51b59939 100644 --- a/scripts/repl-study/mutations.ts +++ b/scripts/repl-study/mutations.ts @@ -60,6 +60,8 @@ export const MUTATIONS = [ "keep-route-on-focus", /** A root that implements no action, so every one reaches the unowned default. */ "disown-actions", + /** Resolve what the URL can and render the rest as if it were there. */ + "render-partial-route", /** Forget the selected marker when a state is rebuilt from its URL. */ "drop-selection-on-hydrate", /** Exit on Ctrl+C while a paused entry is still active. */ diff --git a/scripts/repl-study/render.ts b/scripts/repl-study/render.ts index 5edabe374..d7ed1d152 100644 --- a/scripts/repl-study/render.ts +++ b/scripts/repl-study/render.ts @@ -24,6 +24,7 @@ import { placementOf } from "./component.ts"; import { MINIMUM } from "./layout.ts"; import type { View } from "./store.ts"; import type { Mutation } from "./mutations.ts"; +import type { Refusal } from "./router.ts"; import type { OverlayEntry } from "./tree.ts"; export const C = { @@ -1178,6 +1179,29 @@ function refusalColor(badge: string | undefined, notice: string): number { return badge === undefined ? C.dim : C.gold; } +/** + * A location that does not exist, said plainly. + * + * It names the segment, quotes what the URL asked for, and says what the + * execution actually did — because the useful thing about a refusal is not that + * it happened but which part was wrong. + */ +export function refusedRegion(id: string, refusal: Refusal, layout: Layout): Op[] { + return region( + id, + layout.screen, + [ + plain("This location does not exist", C.out), + plain(`${refusal.segment} · ${refusal.named}`, C.hold), + blank(), + plain(refusal.reason, C.dim), + blank(), + plain("The URL addresses the execution; it cannot invent one.", C.settledText), + ], + { bg: BG.app, padding: { left: 2, top: 1 } }, + ); +} + export function tooSmallRegion(id: string, layout: Layout): Op[] { const lines: VisualLine[] = [ plain("Terminal too small", C.out), diff --git a/scripts/repl-study/router.ts b/scripts/repl-study/router.ts new file mode 100644 index 000000000..5d4116634 --- /dev/null +++ b/scripts/repl-study/router.ts @@ -0,0 +1,96 @@ +/** + * Resolving a URL against what the execution actually did. + * + * A route addresses structure; it never creates any. The entry, the scopes + * inside it, the recorded marker and the suspension a drawer answers are all + * facts the journal holds, and a URL naming one that is not there is a URL that + * cannot be opened. Saying so is the whole of this module. + * + * The alternative is what a router usually does by accident: resolve what it + * can, drop what it cannot, and render a screen that looks like somewhere. That + * screen is the dangerous one. It shows an execution that never ran, at a + * moment that was never recorded, and nothing on it says which part was made + * up. + * + * Nothing here keeps navigation state. It is a question asked of a route and a + * journal, and the answer is either nothing — it resolves — or the one segment + * that does not, in words the interface can draw. + */ + +import { ROOT_SCOPE, siblingsOf } from "./journal.ts"; +import type { JournalFixture } from "./journal.ts"; +import { isDrawerKind } from "./fixtures.ts"; +import type { Route } from "./route.ts"; + +/** Which part of a URL could not be resolved, and what it named. */ +export interface Refusal { + readonly segment: "entry" | "scope" | "checkpoint" | "drawer"; + /** The segment's own text, so the refusal quotes the URL rather than paraphrasing it. */ + readonly named: string; + /** One sentence, for the screen. */ + readonly reason: string; +} + +/** + * The entry a journal recorded, by the name a route spells it with. + * + * This study runs one entry at a time, so there is one name. It is derived + * rather than declared: an entry segment resolves because something was + * submitted, not because the URL was well formed. + */ +export function entryOf(journal: JournalFixture): string | undefined { + return journal.some((record) => record.kind === "entry.submitted") ? "entry-1" : undefined; +} + +/** Whether a moment in this journal ever had something waiting for an answer. */ +function suspends(journal: JournalFixture): boolean { + return journal.some((record) => record.kind === "suspension.opened"); +} + +export function resolve(route: Route, journal: JournalFixture): Refusal | undefined { + const [entry, ...scopes] = route.scopes; + if (entry !== undefined) { + const recorded = entryOf(journal); + if (recorded === undefined || entry !== recorded) { + return { + segment: "entry", + named: entry, + reason: + recorded === undefined + ? "this execution has not submitted an entry" + : `this execution recorded ${recorded}`, + }; + } + } + for (let depth = 0; depth < scopes.length; depth += 1) { + const inside = siblingsOf(journal, scopes.slice(0, depth)); + const wanted = scopes[depth]; + if (!inside.includes(wanted)) { + const parent = depth === 0 ? ROOT_SCOPE : scopes[depth - 1]; + return { + segment: "scope", + named: wanted, + reason: + inside.length === 0 + ? `${parent} opened no scopes` + : `${parent} opened ${inside.join(", ")}`, + }; + } + } + if (route.at !== undefined && !journal.some((record) => record.marker === route.at)) { + return { + segment: "checkpoint", + named: route.at, + reason: "no such moment was recorded", + }; + } + for (const drawer of route.drawers) { + if (!isDrawerKind(drawer)) { + return { segment: "drawer", named: drawer, reason: "there is no drawer of that kind" }; + } + if (!suspends(journal)) { + return { segment: "drawer", named: drawer, reason: "nothing is waiting for an answer" }; + } + } + return undefined; +} diff --git a/scripts/repl-study/store.ts b/scripts/repl-study/store.ts index 0ff6cfa4a..0937d63d7 100644 --- a/scripts/repl-study/store.ts +++ b/scripts/repl-study/store.ts @@ -23,6 +23,8 @@ import { layoutFor, SURFACES } from "./layout.ts"; import type { Layout, SurfaceName } from "./layout.ts"; import type { FixtureName, Fixture, TransportMode } from "./model.ts"; import type { Mutation } from "./mutations.ts"; +import { resolve } from "./router.ts"; +import type { Refusal } from "./router.ts"; import type { ReplAction } from "./actions.ts"; import { formatRoute, navigationFor, parseRoute, topDrawer } from "./route.ts"; import type { Route, RouteChange, RouteSurface } from "./route.ts"; @@ -105,6 +107,14 @@ export interface ReplState { * in memory would render a state the URL could not reopen. */ readonly selection: number; + /** + * The part of the URL that could not be resolved, when one could not. + * + * A route addresses structure and never creates any, so a URL naming an entry + * that was never submitted or a scope that was never opened cannot be shown. + * What is shown instead says which segment it was. + */ + readonly refusal: Refusal | undefined; /** Disposable: whether the F1 focus map is drawn. */ readonly overlay: boolean; /** @@ -127,11 +137,12 @@ export interface ReplState { function mint( route: Route, journal: JournalFixture, - rest: Omit, + rest: Omit, ): ReplState { return { route, journal, + refusal: resolve(route, journal), // Selecting a marker and reconstructing it are different things: the fold // follows the head until `inspect` says the reconstruction is open. moment: fold(journal, route.inspect ? route.at : undefined), diff --git a/scripts/repl-study/tree.ts b/scripts/repl-study/tree.ts index 62195692b..32bb67cdc 100644 --- a/scripts/repl-study/tree.ts +++ b/scripts/repl-study/tree.ts @@ -53,6 +53,7 @@ import { inputBody, outletBody, refusalBody, + refusedBody, rootBody, rulesBody, sessionsBody, @@ -258,7 +259,11 @@ interface Mounted { * node each frame and take focus with it, which is the defect a live tree * exists to avoid. */ -export function useReplTree(state: ReplState, composed: Size): Operation { +export function useReplTree( + state: ReplState, + composed: Size, + mutation?: Mutation, +): Operation { return { *[Symbol.iterator]() { let size = composed; @@ -267,6 +272,36 @@ export function useReplTree(state: ReplState, composed: Size): Operation