From efc09eebca2ec7363431e13060e3f5130a5ec627 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 27 Sep 2026 08:31:57 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Draw=20and=20own=20a=20REPL=20t?= =?UTF-8?q?erminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice D of #848: the terminal platform. A committed tree is placed at four sizes, drawn through one layout engine, and answered back with the exact live node a pointer landed on; presentation time has one owner; and every way out of the terminal gives it back. No screens, no retained user data, no `xmd repl` — Slice E consumes this. `layout.ts` is presentation and nothing else. It reads the committed frame and tells the tree nothing, so resizing moves things and changes no selection, no model object and no action's meaning. Four profiles are decided — `160x36` wide, `120x30` medium, `72x20` narrow, and anything smaller a refusal that recovers when the window grows. Wide and medium carry the sidebar, transcript, bindings/history inspection, drawer layer and the fixed full-width footer; narrow shows the one surface the route selected, and keeps the same drawer and footer contracts. Every unrouted surface stays mounted and stays off the narrow frame — in no cell, in no frame map, reachable by no pointer — because a narrow screen that stacked every region would be a wide screen with the columns taken out, and every row of the lists nobody asked for would still be a target. Sessions says so when it is empty. The History band is five rows at every size, and when its labels cannot all fit they are grouped visually while every marker keeps its own identity, so a compact band never costs a reader the ability to select an exact position. A refusal draws no cell at all, which is also why nothing it hides can be pointed at. `renderer.ts` draws only what is mounted. A frame laid out before a removal still names the node that has gone, so the renderer filters against the tree as it stands rather than trusting the frame it was handed. Each result carries an immutable map from the element the engine measured to the live node that asked for it, and the tree revision that produced it travels with it: a pointer from a frame the tree has moved past, one naming a node that has since gone, and one landing behind an open drawer all reach nothing. A pointer that does resolve becomes the same normalized event a key produces and walks the same ancestry, so clicking a control and pressing Enter on it are one action. Every render input is frozen before it crosses into the engine, and the bytes that come back are copied immediately — `render()` returns a view into WASM memory that the next render overwrites and a resize detaches outright. The engine's measurement arenas are sized from the dimensions it was built for, so a frame too large for the engine in hand is redrawn on one rebuilt for that frame's own size, from the retained snapshot: same selection, same focus, same actions, same frame. `frame.ts` is the one acknowledged frame stream. Holding a subscription is what asks for frames; there is no start and no stop. An advance waits until every subscriber has **applied** the last timestamp and said so — receiving a frame is not applying it, because laying out and drawing with a timestamp suspends, and a stream that counted the handover would publish the next frame while this one was still being drawn. A cancelled subscriber's demand goes with it and its acknowledgement does not arrive, because it did not finish the frame it held. When the last subscription goes the stream is settled and no timer is scheduled at all. An animation spanning components belongs to their nearest common mounted ancestor and is refused to anyone else: a participant's own lifetime is shorter than the animation's, and a distant ancestor would hold the clock for a subtree that stopped. `delta` is seconds, because that is what the engine's transitions take. `terminal.ts` is the contextual Api, `terminal-host.ts` the portable half over five capabilities a runtime supplies, and `screen.ts` the one owner of what a full-screen interface takes. It registers each release before taking the thing it releases, so a cancellation in between still restores; success, refusal, failure, cancellation and end of input all stop the reader, remove every listener, restore the modes and write the final reset exactly once. End of input is a lifecycle outcome, not a key. Input is normalized once and never interpreted. The scanner decides what a byte was; this decides which of those the composition layer has a meaning for, and drops the rest rather than inventing an action for them. A pointer carries a column and a row and no target, because which node is there is the committed frame's answer. Text is its own member of the normalized union rather than a key with a payload, and `Backspace` joins the named keys. The difference is what they mean: a named key is a command — submit, dismiss, traverse, erase — that whoever claims it recognizes, and text is content that goes wherever content goes. Text carries what the terminal decoded, so a shifted capital, an accent and a multi-byte character all arrive already assembled, and the `LF` inside a paste arrives as a newline even though the decoder reports it as Control-J with no text of its own. A chord's physical key code is a letter — Control-C is `c`, Alt-a is `a`, and Alt-a even carries the text `a` — so a chord is dropped whole, payload included; reading the code would insert the letter somebody held Control with, which is how an editor types a `c` when a person asked it to stop. Backspace is the terminal's `DEL`, because that is what the key sends; `0x08` is Control-H and is a chord. One thing the pinned scanner does not do: `@bomb.sh/tty` 0.9.0 never resolves a lone Escape — rescanning with no bytes, as its own documentation describes, returns the same pending report and no event, however many times it is called. So `useReplDecoder` makes that decision, where it is a normalization decision rather than a decoding one, and replaces the scanner afterwards because the spent `ESC` would otherwise join the next keystroke. The four runtime adapters name their runtime's spelling of those five capabilities and nothing else. None of them is registered; no entrypoint imports one, and no command mentions a REPL. --- .agents/architecture-rules.md | 8 +- architecture.md | 67 + deno.lock | 6 + packages/cli/deno.json | 1 + packages/cli/package.json | 1 + packages/cli/src/bun-repl-terminal.ts | 52 + packages/cli/src/compiled-repl-terminal.ts | 61 + packages/cli/src/deno-repl-terminal.ts | 56 + packages/cli/src/node-repl-terminal.ts | 52 + packages/cli/src/repl/description.ts | 16 +- packages/cli/src/repl/frame.ts | 295 ++++ packages/cli/src/repl/input.ts | 214 +++ packages/cli/src/repl/layout.ts | 323 ++++ packages/cli/src/repl/renderer.ts | 370 +++++ packages/cli/src/repl/screen.ts | 241 +++ packages/cli/src/repl/terminal-host.ts | 241 +++ packages/cli/src/repl/terminal.ts | 83 + packages/cli/tests/fixtures/repl/surface.ts | 362 +++++ packages/cli/tests/repl-composition.test.ts | 6 +- packages/cli/tests/repl-terminal.test.ts | 1550 +++++++++++++++++++ pnpm-lock.yaml | 9 + 21 files changed, 4008 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/bun-repl-terminal.ts create mode 100644 packages/cli/src/compiled-repl-terminal.ts create mode 100644 packages/cli/src/deno-repl-terminal.ts create mode 100644 packages/cli/src/node-repl-terminal.ts create mode 100644 packages/cli/src/repl/frame.ts create mode 100644 packages/cli/src/repl/input.ts create mode 100644 packages/cli/src/repl/layout.ts create mode 100644 packages/cli/src/repl/renderer.ts create mode 100644 packages/cli/src/repl/screen.ts create mode 100644 packages/cli/src/repl/terminal-host.ts create mode 100644 packages/cli/src/repl/terminal.ts create mode 100644 packages/cli/tests/fixtures/repl/surface.ts create mode 100644 packages/cli/tests/repl-terminal.test.ts diff --git a/.agents/architecture-rules.md b/.agents/architecture-rules.md index b45e58a13..e1b384458 100644 --- a/.agents/architecture-rules.md +++ b/.agents/architecture-rules.md @@ -18,10 +18,16 @@ the system itself; this file governs how new architecture is designed. 9. Immutable view data flows down and typed semantic actions flow up; the root alone owns route intent, selection and rebuildable application state. 10. A component lifetime may own a node; a render body may not create or own one. 11. View data and placement cross the direct parent-child boundary and no other. -12. Normalized host input is decided once and dispatched through the target's own ancestry; a host names a target and an event shape, never an action. +12. Normalized host input is decided once and dispatched through the target's own ancestry; a host names a target and an event shape, never an action. Text is its own event shape carrying what the terminal decoded, and a modified chord is dropped whole rather than read off its physical key code. 13. Freedom alone owns focus; an overlay is a mounted branch, not a second tree. 14. Keyed descriptions reconcile into one mounted tree; validate the complete desired set before mutating any of it. 15. An absent description leaves no node, input, focus, frame contribution or output behind. +16. The mounted tree is the only source of renderable nodes and frame targets; a frame map names the live node behind each drawn element and is valid only for the tree revision that drew it. +17. One acknowledged host frame stream carries presentation time; subscriptions are owned by lifetimes, an advance waits for every subscriber to acknowledge applying the last one rather than receiving it, and a settled tree schedules nothing. +18. The nearest common mounted ancestor owns an animation that spans components. +19. Renderer output is copied out of the engine before returning, and a refused frame is redrawn on a renderer rebuilt for that frame's dimensions without changing selection, focus, actions or the frame itself. +20. A contextual adapter owns terminal modes, listeners and readers, registers their release before taking them, and restores them exactly once on every exit. +21. A narrow surface displays the one route selected; every other surface is absent from the frame, its map and its targets rather than hidden within them. The Architect applies these rules without another approval when they settle a design. A new concept, ambiguous fit, conflict or proposed exception returns to diff --git a/architecture.md b/architecture.md index 0a699cf1f..3b6ccce5d 100644 --- a/architecture.md +++ b/architecture.md @@ -5344,6 +5344,73 @@ Freedom is vendored from a pinned unpublished commit under `packages/cli/src/repl/vendor/freedom/`, as a pristine copy and a patched copy whose every difference belongs to one named patch. See its `PROVENANCE.md`. +### From a frame to a terminal, and back + +Below the tree, one more direction: + +```text +committed frame → placement → engine ops → bytes → the terminal + ↓ + element id + geometry → the live node it came from + ↑ + bytes → normalized key or position → target → the same ancestry walk +``` + +Placement is presentation and nothing else: it reads the committed frame and +tells the tree nothing, so resizing moves things and changes no selection, no +model object and no action's meaning. Four sizes are decided — wide, medium, +narrow, and a refusal below the narrow minimum that recovers when the window +grows — and a control the refusal hides is drawn nowhere and therefore +targetable nowhere. + +Only mounted nodes are drawn. Each rendered frame carries an immutable map from +the element the layout engine measured to the live node that asked for it, and +that map is valid for exactly the tree revision that produced it: a pointer +resolved against it travels with that revision's number, so a pointer from a +frame the tree has moved past, one naming a node that has since gone, and one +landing behind an open modal all reach nothing. A pointer that does resolve +becomes the same normalized event a key produces and walks the same ancestry, so +clicking a control and pressing Enter on it are one action. + +Text is its own member of that normalized union, not a key with a payload: a +named key — submit, dismiss, traverse, erase — is a command whoever claims it +recognizes, and text is content that goes wherever content goes. It carries what +the terminal decoded, so shifted, accented and multi-byte characters arrive +already assembled, and a newline inside a paste arrives as one. A chord carries +a letter in its physical key code, and reading that code would type the letter +somebody held Control with, so a chord is dropped whole — payload included. + +Every render input is retained as a frozen snapshot before it crosses into the +engine, and the bytes that come back are copied immediately, because the engine +hands out a view into its own memory that the next render invalidates. A frame +the engine's arenas cannot hold is redrawn on an engine rebuilt for that frame's +dimensions; recovery changes nothing above it. + +Placement at narrow shows the one surface the route selected. Every other +surface stays mounted and stays off the frame, which is what makes it +unreachable rather than merely hidden: it is in no cell, in no frame map, and no +pointer resolves to it. + +Presentation time has one owner. There is a single acknowledged frame stream: +holding a subscription is what asks for frames, an advance waits until every +subscriber has **applied** the last timestamp — receiving one is not applying +it, because drawing with a timestamp suspends — and a tree with no subscriber +schedules no timer at all. A cancelled subscriber's demand goes with it and its +acknowledgement never arrives, because it did not finish the frame it held. An animation spanning components belongs to their +nearest common mounted ancestor, whose lifetime is the animation's; nothing +below it keeps a clock of its own. The stream carries presentation time only — +Journal records, model selection, routes and execution pause state are outside +it. + +The terminal itself is a contextual Api, and a runtime-named adapter installs +it. Shared code never asks which runtime it is on; it asks for the size, bytes +in, bytes out, raw mode and resize notifications. Whoever opens the terminal +registers the release of each of those before taking it, so a cancellation +between the two still gives the terminal back — and every exit, whether the run +finished, refused, failed, was cancelled or reached end of input, stops the +reader, removes every listener, restores the modes and writes the final reset +exactly once. + ## Changing these rules Spec, tests, and mechanics move together, in the same PR. If a workaround diff --git a/deno.lock b/deno.lock index ec96c9990..da4b608c9 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": [ @@ -4047,11 +4051,13 @@ }, "packages/cli": { "dependencies": [ + "npm:@bomb.sh/tty@0.9.0", "npm:@standard-schema/spec@1", "npm:zod@^4.3.6" ], "packageJson": { "dependencies": [ + "npm:@bomb.sh/tty@0.9.0", "npm:@effectionx/stream-helpers@0.8.3", "npm:@standard-schema/spec@1", "npm:configliere@0.4", diff --git a/packages/cli/deno.json b/packages/cli/deno.json index c0495c535..5594c615c 100644 --- a/packages/cli/deno.json +++ b/packages/cli/deno.json @@ -4,6 +4,7 @@ "license": "MIT", "exports": "./src/deno.ts", "imports": { + "@bomb.sh/tty": "npm:@bomb.sh/tty@0.9.0", "@standard-schema/spec": "npm:@standard-schema/spec@^1.0.0", "zod": "npm:zod@^4.3.6" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 36cd9befb..a4c7a2af0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,6 +11,7 @@ "default": "./src/node.ts" }, "dependencies": { + "@bomb.sh/tty": "0.9.0", "@effectionx/stream-helpers": "0.8.3", "@executablemd/acp": "workspace:*", "@executablemd/core": "workspace:*", diff --git a/packages/cli/src/bun-repl-terminal.ts b/packages/cli/src/bun-repl-terminal.ts new file mode 100644 index 000000000..b4bf3f5eb --- /dev/null +++ b/packages/cli/src/bun-repl-terminal.ts @@ -0,0 +1,52 @@ +/** + * The REPL's terminal on Bun. + * + * Named for its runtime because every line of it is a Node-compatible capability Bun implements: the + * stream's own reported width and height, `setRawMode` on the tty read stream, + * the `SIGWINCH` process event, and the two writes. Nothing registers it — + * `bun.ts` installs it when the REPL opens, so an ordinary `xmd run` never + * touches raw mode. + * + * Standard input is a single source, so one subscriber consumes it; a second + * would be a second decoder racing the first for the same bytes, which is not + * something this REPL does. + */ + +import { writeSync } from "node:fs"; +import process from "node:process"; +import type { Operation } from "effection"; +import { decodedChunks, installReplTerminal, writeThrough } from "./repl/terminal-host.ts"; +import type { ReplTerminalSize } from "./repl/terminal.ts"; + +/** Install the Bun-backed terminal for the calling scope. */ +export function useBunReplTerminal(): Operation { + return installReplTerminal({ + size(): ReplTerminalSize { + return { columns: process.stdout.columns, rows: process.stdout.rows }; + }, + write(bytes: Uint8Array): Promise { + return writeThrough((chunk, done) => process.stdout.write(chunk, done), bytes); + }, + writeNow(bytes: Uint8Array): void { + // Synchronous on purpose: the final reset must land as one uninterrupted + // act, and a suspension here would let another teardown step write over a + // half-restored terminal. Written to the descriptor rather than the stream + // because the stream may still be holding buffered output of its own. + // oxlint-disable-next-line local/no-sync-filesystem + writeSync(1, bytes); + }, + setRaw(raw: boolean): void { + process.stdin.setRawMode(raw); + }, + bytes(): AsyncIterable { + process.stdin.resume(); + return decodedChunks(process.stdin); + }, + onResize(listener: () => void): () => void { + process.on("SIGWINCH", listener); + return () => { + process.off("SIGWINCH", listener); + }; + }, + }); +} diff --git a/packages/cli/src/compiled-repl-terminal.ts b/packages/cli/src/compiled-repl-terminal.ts new file mode 100644 index 000000000..f029642c9 --- /dev/null +++ b/packages/cli/src/compiled-repl-terminal.ts @@ -0,0 +1,61 @@ +/** + * The REPL's terminal inside the compiled binary. + * + * The compiled binary is Deno, so the capabilities are Deno's — but it is its + * own named factory because what installs a terminal is a distribution + * decision, and a binary that shipped without a REPL should not be reached + * through the factory the source runtime uses. + * + * Every line of it is a Deno capability: the + * console size, raw mode on the standard input descriptor, `SIGWINCH`, and the + * two writes. Nothing registers it — `compiled.ts` installs it when the REPL opens, + * so an ordinary `xmd run` never touches raw mode or the terminal's modes at + * all. + * + * Standard input is a single source, so one subscriber consumes it; a second + * would be a second decoder racing the first for the same bytes, which is not + * something this REPL does. + */ + +import type { Operation } from "effection"; +import { installReplTerminal, writeAllTo } from "./repl/terminal-host.ts"; +import type { ReplTerminalSize } from "./repl/terminal.ts"; + +/** Install the compiled binary's terminal for the calling scope. */ +export function useCompiledReplTerminal(): Operation { + return installReplTerminal({ + size(): ReplTerminalSize { + const { columns, rows } = Deno.consoleSize(); + return { columns, rows }; + }, + write(bytes: Uint8Array): Promise { + return writeAllTo((chunk) => Deno.stdout.write(chunk), bytes); + }, + writeNow(bytes: Uint8Array): void { + let written = 0; + while (written < bytes.length) { + // Synchronous on purpose: the final reset must land as one uninterrupted + // act, and a suspension here would let another teardown step write over + // a half-restored terminal. + // oxlint-disable-next-line local/no-sync-filesystem + const count = Deno.stdout.writeSync(bytes.subarray(written)); + if (count <= 0) { + return; + } + written += count; + } + }, + setRaw(raw: boolean): void { + Deno.stdin.setRaw(raw); + }, + bytes(): AsyncIterable { + return Deno.stdin.readable; + }, + onResize(listener: () => void): () => void { + Deno.addSignalListener("SIGWINCH", listener); + return () => { + Deno.removeSignalListener("SIGWINCH", listener); + }; + }, + }); +} diff --git a/packages/cli/src/deno-repl-terminal.ts b/packages/cli/src/deno-repl-terminal.ts new file mode 100644 index 000000000..4927cad8a --- /dev/null +++ b/packages/cli/src/deno-repl-terminal.ts @@ -0,0 +1,56 @@ +/** + * The REPL's terminal on Deno. + * + * Named for its runtime because every line of it is a Deno capability: the + * console size, raw mode on the standard input descriptor, `SIGWINCH`, and the + * two writes. Nothing registers it — `deno.ts` installs it when the REPL opens, + * so an ordinary `xmd run` never touches raw mode or the terminal's modes at + * all. + * + * Standard input is a single source, so one subscriber consumes it; a second + * would be a second decoder racing the first for the same bytes, which is not + * something this REPL does. + */ + +import type { Operation } from "effection"; +import { installReplTerminal, writeAllTo } from "./repl/terminal-host.ts"; +import type { ReplTerminalSize } from "./repl/terminal.ts"; + +/** Install the Deno-backed terminal for the calling scope. */ +export function useDenoReplTerminal(): Operation { + return installReplTerminal({ + size(): ReplTerminalSize { + const { columns, rows } = Deno.consoleSize(); + return { columns, rows }; + }, + write(bytes: Uint8Array): Promise { + return writeAllTo((chunk) => Deno.stdout.write(chunk), bytes); + }, + writeNow(bytes: Uint8Array): void { + let written = 0; + while (written < bytes.length) { + // Synchronous on purpose: the final reset must land as one uninterrupted + // act, and a suspension here would let another teardown step write over + // a half-restored terminal. + // oxlint-disable-next-line local/no-sync-filesystem + const count = Deno.stdout.writeSync(bytes.subarray(written)); + if (count <= 0) { + return; + } + written += count; + } + }, + setRaw(raw: boolean): void { + Deno.stdin.setRaw(raw); + }, + bytes(): AsyncIterable { + return Deno.stdin.readable; + }, + onResize(listener: () => void): () => void { + Deno.addSignalListener("SIGWINCH", listener); + return () => { + Deno.removeSignalListener("SIGWINCH", listener); + }; + }, + }); +} diff --git a/packages/cli/src/node-repl-terminal.ts b/packages/cli/src/node-repl-terminal.ts new file mode 100644 index 000000000..58d55e96d --- /dev/null +++ b/packages/cli/src/node-repl-terminal.ts @@ -0,0 +1,52 @@ +/** + * The REPL's terminal on Node. + * + * Named for its runtime because every line of it is a Node capability: the + * stream's own reported width and height, `setRawMode` on the tty read stream, + * the `SIGWINCH` process event, and the two writes. Nothing registers it — + * `node.ts` installs it when the REPL opens, so an ordinary `xmd run` never + * touches raw mode. + * + * Standard input is a single source, so one subscriber consumes it; a second + * would be a second decoder racing the first for the same bytes, which is not + * something this REPL does. + */ + +import { writeSync } from "node:fs"; +import process from "node:process"; +import type { Operation } from "effection"; +import { decodedChunks, installReplTerminal, writeThrough } from "./repl/terminal-host.ts"; +import type { ReplTerminalSize } from "./repl/terminal.ts"; + +/** Install the Node-backed terminal for the calling scope. */ +export function useNodeReplTerminal(): Operation { + return installReplTerminal({ + size(): ReplTerminalSize { + return { columns: process.stdout.columns, rows: process.stdout.rows }; + }, + write(bytes: Uint8Array): Promise { + return writeThrough((chunk, done) => process.stdout.write(chunk, done), bytes); + }, + writeNow(bytes: Uint8Array): void { + // Synchronous on purpose: the final reset must land as one uninterrupted + // act, and a suspension here would let another teardown step write over a + // half-restored terminal. Written to the descriptor rather than the stream + // because the stream may still be holding buffered output of its own. + // oxlint-disable-next-line local/no-sync-filesystem + writeSync(1, bytes); + }, + setRaw(raw: boolean): void { + process.stdin.setRawMode(raw); + }, + bytes(): AsyncIterable { + process.stdin.resume(); + return decodedChunks(process.stdin); + }, + onResize(listener: () => void): () => void { + process.on("SIGWINCH", listener); + return () => { + process.off("SIGWINCH", listener); + }; + }, + }); +} diff --git a/packages/cli/src/repl/description.ts b/packages/cli/src/repl/description.ts index 595c3d07d..feafb5761 100644 --- a/packages/cli/src/repl/description.ts +++ b/packages/cli/src/repl/description.ts @@ -49,8 +49,13 @@ export type ReplViewData = /** Placement a parent decides for one direct child. Opaque to everyone else. */ export type ReplPlacement = { readonly [key: string]: string | number | boolean }; -/** The named keys this composition layer understands. */ -export type ReplKey = "Enter" | "Escape" | "Tab" | "Backtab"; +/** + * The named keys this composition layer understands. + * + * Named because each one means something structural — submit, dismiss, move + * focus, erase — as opposed to text, which means only itself. + */ +export type ReplKey = "Enter" | "Escape" | "Tab" | "Backtab" | "Backspace"; /** * One normalized event, as a host hands it over. @@ -58,9 +63,16 @@ export type ReplKey = "Enter" | "Escape" | "Tab" | "Backtab"; * A discriminated union, so a key event carrying a frame and a pointer event * carrying a key name are not values anybody can build. The host says what * happened and where; it never says what it means. + * + * Text is its own member rather than a key with a payload, because the two are + * answered differently: a named key is a command whoever claims it recognizes, + * and text is content that goes wherever content goes. It carries the string the + * terminal decoded — one grapheme as typed, or `"\n"` for a newline inside a + * paste — and never a raw terminal event. */ export type ReplInputEvent = | { readonly kind: "key"; readonly key: ReplKey } + | { readonly kind: "text"; readonly text: string } | { readonly kind: "pointer"; readonly target: string; readonly frame: number }; /** What a mounted child may do, for as long as it is mounted. */ diff --git a/packages/cli/src/repl/frame.ts b/packages/cli/src/repl/frame.ts new file mode 100644 index 000000000..510415905 --- /dev/null +++ b/packages/cli/src/repl/frame.ts @@ -0,0 +1,295 @@ +/** + * One clock for the whole screen, and nobody drawing ahead of anybody else. + * + * Animation needs a timestamp, and a timestamp that different parts of the + * screen read at different moments is two animations pretending to be one. So + * there is exactly one frame stream, the host owns it, and it is + * **acknowledged**: the next timestamp is not published until every subscriber + * has applied the current one. Nothing can run a frame ahead, and nothing can + * fall a frame behind without holding the rest back — which is the visible, + * debuggable failure rather than a silent drift. + * + * ## Subscription is the demand + * + * There is no start and no stop. A subscription is a resource, and holding one + * is what asks for frames; releasing it withdraws the request. When the last + * one goes, the stream is settled and **no timer is scheduled at all** — an + * idle REPL costs nothing, which a stream that kept ticking into an empty + * subscriber set would not. + * + * ## Who owns an animation that spans components + * + * The nearest common ancestor of everything taking part, and only it. A + * participant that subscribed for itself would keep its own clock running after + * a sibling left, and a distant ancestor would hold the timer alive for a + * subtree that stopped animating. The ancestor in between is the one whose + * lifetime matches the animation's, so a claim from anywhere else is refused. + * + * `delta` is in **seconds**, because that is what the layout engine's + * transitions take; handing it milliseconds runs every transition 1000x fast. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { + Err, + ensure, + Ok, + type Operation, + resource, + type Result, + sleep, + spawn, + withResolvers, +} from "effection"; + +/** Sixty frames a second, as seconds. */ +export const FRAME_INTERVAL = 1 / 60; + +/** The host clock, so a test can hold time still and count what was scheduled. */ +export interface ReplClockApi { + /** Now, in seconds. */ + now(): Operation; + /** Wait this many seconds. */ + wait(seconds: number): Operation; +} + +export const ReplClock: Api = createApi("ReplClock", { + // deno-lint-ignore require-yield + *now(): Operation { + return performance.now() / 1000; + }, + *wait(seconds: number): Operation { + yield* sleep(Math.max(0, Math.round(seconds * 1000))); + }, +}); + +/** One published frame. */ +export interface ReplFrameTick { + /** Monotonic from one, so a subscriber can say which frame it applied. */ + readonly id: number; + /** The host clock, in seconds. */ + readonly at: number; + /** Seconds since the previous frame. Zero for the first. */ + readonly delta: number; +} + +/** + * An ancestry as the composition kernel reports one: the node itself first, + * its root last. + */ +export type ReplAncestry = readonly string[]; + +/** Who wants frames, and for what. */ +export interface ReplFrameRequest { + /** The node claiming ownership. */ + readonly owner: string; + /** The ancestry of each participant, one entry per participating node. */ + readonly participants: readonly ReplAncestry[]; +} + +/** A claim that is not the nearest common ancestor of its participants. */ +export class ReplFrameOwnerError extends Error { + constructor(owner: string, expected: string | undefined) { + super( + expected === undefined + ? `${owner} claimed an animation whose participants share no ancestor, so nothing can own it` + : `${owner} claimed an animation owned by ${expected}, the nearest common ancestor of its ` + + `participants`, + ); + this.name = "ReplFrameOwnerError"; + } +} + +/** One subscriber's view of the stream. */ +export interface ReplFrameSubscription { + /** + * The next frame. + * + * Receiving one is not applying it. Applying a timestamp means laying out and + * rendering with it, and that suspends — so a stream that counted the return + * of `next()` as the acknowledgement would publish the following frame while + * this one was still being drawn, which is the drift the acknowledgement + * exists to prevent. + */ + next(): Operation; + /** + * Say the received frame has been applied. + * + * Until every subscriber says so, the stream publishes nothing further. + */ + acknowledge(): void; + /** The last frame this subscriber acknowledged. */ + applied(): number; + /** The last frame this subscriber received, applied or not. */ + received(): number; +} + +/** The one stream. */ +export interface ReplFrames { + /** + * Ask for frames for as long as the result is held. + * + * A refused claim registers no demand at all, so holding an `Err` keeps no + * timer alive. + */ + subscribe(request: ReplFrameRequest): Operation>; + /** Whether anything is currently asking for frames. */ + demanded(): boolean; + /** How many frames the host has published. */ + published(): number; + /** Subscribers that have received or owe the current frame but not applied it. */ + outstanding(): number; +} + +/** + * The nearest common ancestor of some participants, or none. + * + * Ancestries arrive node-first, so the shared *suffix* is the shared ancestry; + * reversing makes it a prefix, and the last element of the common prefix is the + * nearest one. + */ +export function nearestCommonAncestor(participants: readonly ReplAncestry[]): string | undefined { + if (participants.length === 0) { + return undefined; + } + const paths = participants.map((ancestry) => [...ancestry].reverse()); + const [first, ...rest] = paths; + let shared = 0; + while (shared < first.length && rest.every((path) => path[shared] === first[shared])) { + shared += 1; + } + return shared === 0 ? undefined : first[shared - 1]; +} + +interface Subscriber { + /** The last frame acknowledged. */ + applied: number; + /** The last frame handed over, which may still be being drawn. */ + received: number; + waiting: { resolve(tick: ReplFrameTick): void } | undefined; +} + +/** Open the frame stream for the calling scope. */ +export function useReplFrames(interval: number = FRAME_INTERVAL): Operation { + return resource(function* (provide) { + let ticks = 0; + let current: ReplFrameTick | undefined; + let previous: number | undefined; + const live = new Set(); + let outstanding = new Set(); + let gate = withResolvers(); + + /** Let the driver reconsider: demand or acknowledgement changed. */ + function nudge(): void { + gate.resolve(); + } + + yield* spawn(function* driver(): Operation { + while (true) { + if (live.size === 0 || outstanding.size > 0) { + // Settled, or still waiting on somebody. Either way there is nothing + // to schedule, so this suspends on the gate rather than on a timer. + yield* gate.operation; + gate = withResolvers(); + continue; + } + yield* ReplClock.operations.wait(interval); + if (live.size === 0) { + continue; + } + const at = yield* ReplClock.operations.now(); + ticks += 1; + const tick: ReplFrameTick = Object.freeze({ + id: ticks, + at, + delta: previous === undefined ? 0 : at - previous, + }); + previous = at; + current = tick; + outstanding = new Set(live); + for (const subscriber of live) { + const waiter = subscriber.waiting; + subscriber.waiting = undefined; + waiter?.resolve(tick); + } + } + }); + + yield* provide({ + subscribe(request) { + return resource>(function* (give) { + const expected = nearestCommonAncestor(request.participants); + if (expected !== request.owner) { + // Nothing registered, so an unheld claim cannot keep the stream + // awake while its holder waits for a frame that will never come. + yield* give(Err(new ReplFrameOwnerError(request.owner, expected))); + return; + } + + const subscriber: Subscriber = { applied: 0, received: 0, waiting: undefined }; + // Registered before the subscriber is anywhere the driver can see it. + // Adding first would put a subscriber in the demand set with no way to + // take it back out, and a cancellation there leaves the stream awake + // for something that has already gone. + yield* ensure(function* () { + live.delete(subscriber); + // Its demand goes; its acknowledgement does not arrive. A cancelled + // subscriber did not apply the frame it was holding, and saying it + // did would be the stream lying about what is on screen. + outstanding.delete(subscriber); + // Leaving can be what finally completes a frame, and it is always + // what settles the stream. + nudge(); + }); + + live.add(subscriber); + if (current !== undefined) { + // Joining mid-flight owes the current frame like everyone else. + outstanding.add(subscriber); + } + nudge(); + + yield* give( + Ok({ + *next(): Operation { + if (current !== undefined && subscriber.received !== current.id) { + const tick = current; + subscriber.received = tick.id; + return tick; + } + const waiter = withResolvers(); + subscriber.waiting = waiter; + const tick = yield* waiter.operation; + subscriber.received = tick.id; + return tick; + }, + acknowledge(): void { + if (subscriber.received === subscriber.applied) { + return; + } + subscriber.applied = subscriber.received; + outstanding.delete(subscriber); + nudge(); + }, + applied(): number { + return subscriber.applied; + }, + received(): number { + return subscriber.received; + }, + }), + ); + }); + }, + demanded(): boolean { + return live.size > 0; + }, + published(): number { + return ticks; + }, + outstanding(): number { + return outstanding.size; + }, + }); + }); +} diff --git a/packages/cli/src/repl/input.ts b/packages/cli/src/repl/input.ts new file mode 100644 index 000000000..5f8f53754 --- /dev/null +++ b/packages/cli/src/repl/input.ts @@ -0,0 +1,214 @@ +/** + * Turning terminal bytes into the events the tree already understands. + * + * The tty scanner does the decoding; this decides which decoded events the + * composition layer has a meaning for and hands them over in its own closed + * shape. Nothing here knows an action name: a normalized event says what + * happened and where, and what it means is decided by whichever mounted node + * claims it. + * + * ## A lone Escape is not yet Escape + * + * `ESC` begins most of the sequences a terminal sends, so a scanner that saw + * one and nothing else cannot know whether a person pressed Escape or an arrow + * key is still arriving. It says so, with a latency to wait. Two things can + * happen next. More bytes arrive, and they are rescanned *with the pending + * prefix still held*, so one sequence is never split into a false Escape + * followed by text — that one the scanner does itself. Or the latency expires + * with nothing following, and the held byte was the Escape key. + * + * The scanner in `@bomb.sh/tty` 0.9.0 does not resolve that second case: + * rescanning with no bytes, as its documentation describes, returns the same + * pending report and no event, however many times it is called. So the decision + * is made here, where it is a normalization decision rather than a decoding + * one: a scan with nothing new that still reports a held Escape *is* the + * Escape, because "pending" means the scanner is holding a lone `ESC` and + * nothing followed it within the latency it asked for. + * + * Emitting it leaves the spent `ESC` in the scanner's buffer, where it would + * join the next keystroke and turn an `a` into an Alt-`a`. There is no way to + * clear that buffer, so the decoder is replaced instead. That costs one + * instantiation, and only when somebody actually presses Escape by itself. + */ + +import { createInput, type Input, type InputEvent, type ScanResult } from "@bomb.sh/tty"; +import { type Operation, resource, until } from "effection"; + +import type { ReplInputEvent, ReplKey } from "./description.ts"; + +/** Where a pointer event landed, before any frame has been consulted. */ +export interface ReplPointerAt { + readonly column: number; + readonly row: number; +} + +/** A normalized event together with where a pointer one landed. */ +export interface ReplNormalized { + readonly event: ReplInputEvent | undefined; + readonly at: ReplPointerAt | undefined; +} + +/** + * The keys this composition layer has a meaning for. + * + * Everything else a terminal can send is decoded and then dropped here rather + * than invented into an action: a key nothing claims is not an event the tree + * should be asked about. + * + * `Backspace` is the terminal's `DEL` (`0x7f`), which is what a keyboard's + * Backspace key actually sends. `0x08` decodes as Control-H and is a chord, not + * this key. + */ +const KEYS: ReadonlyMap = new Map([ + ["Enter", "Enter"], + ["Escape", "Escape"], + ["Tab", "Tab"], + ["Backtab", "Backtab"], + ["Backspace", "Backspace"], +]); + +/** + * The physical key a pasted newline arrives as. + * + * A terminal sends `LF` inside a paste, and the decoder reports `LF` as + * Control-J with no text of its own. Pasting a multi-line document is the whole + * point of an XMD draft, so this one chord is text — and it is the only chord + * that is. + */ +const NEWLINE_CHORD = "j"; + +/** + * Normalize one decoded event. + * + * A pointer event carries where it landed but no target: which node is there is + * the committed frame's answer, and asking the decoder to know it would put the + * frame's geometry in two places. + * + * Text comes from the payload the decoder produced, never from the physical key + * code. A chord's code is a letter — Control-C is `c`, Alt-a is `a` — so reading + * the code would type the letter somebody pressed Control with, which is how an + * editor ends up inserting a `c` when a person asked it to stop. + */ +export function normalize(event: InputEvent): ReplNormalized { + if (event.type === "keydown" || event.type === "keyrepeat") { + if (event.ctrl === true && event.alt !== true && event.code === NEWLINE_CHORD) { + return { event: Object.freeze({ kind: "text", text: "\n" }), at: undefined }; + } + if (event.ctrl === true || event.alt === true) { + // A chord nothing here claims. Dropped whole, payload included. + return { event: undefined, at: undefined }; + } + const key = KEYS.get(event.code); + if (key !== undefined) { + return { event: Object.freeze({ kind: "key", key }), at: undefined }; + } + const text = event.text; + if (text !== undefined && text.length > 0) { + // As decoded: shifted, accented and multi-byte text all arrive here + // already assembled, and a string cannot be changed by whoever receives it. + return { event: Object.freeze({ kind: "text", text }), at: undefined }; + } + return { event: undefined, at: undefined }; + } + if (event.type === "mousedown" && event.button === "left") { + // The target is filled in by whoever holds the current frame; until then + // this is a position and nothing more. + return { event: undefined, at: { column: event.x, row: event.y } }; + } + return { event: undefined, at: undefined }; +} + +/** What one scan of the terminal's bytes produced. */ +export interface ReplInputScan { + /** Normalized keys and text, in the order the terminal sent them. */ + readonly events: readonly ReplInputEvent[]; + readonly pointers: readonly ReplPointerAt[]; + readonly resized: boolean; + /** + * Milliseconds to wait before rescanning with no bytes, when a lone Escape is + * held. Absent when nothing is pending. + */ + readonly pendingFor: number | undefined; +} + +/** + * Scan bytes, or rescan with none to settle a pending Escape. + * + * Resize arrives here because the scanner decodes it, and leaves again as a + * flag rather than an event: it is a host fact about the terminal, not + * something a mounted node claims. + */ +export function scanInput(input: Input, bytes?: Uint8Array): ReplInputScan { + const result: ScanResult = bytes === undefined ? input.scan() : input.scan(bytes); + const events: ReplInputEvent[] = []; + const pointers: ReplPointerAt[] = []; + let resized = false; + + for (const event of result.events) { + if (event.type === "resize") { + resized = true; + continue; + } + const normalized = normalize(event); + if (normalized.event !== undefined) { + events.push(normalized.event); + } + if (normalized.at !== undefined) { + pointers.push(normalized.at); + } + } + + return Object.freeze({ + events: Object.freeze(events), + pointers: Object.freeze(pointers), + resized, + pendingFor: result.pending?.delay, + }); +} + +/** The one Escape a flush can decide on. */ +const ESCAPE: ReplInputEvent = Object.freeze({ kind: "key", key: "Escape" }); + +/** A scanner that also settles a held Escape. */ +export interface ReplDecoder { + /** + * Scan bytes, or settle a held Escape by scanning with none. + * + * An operation because settling one replaces the scanner, and replacing it + * means building one. + */ + scan(bytes?: Uint8Array): Operation; +} + +/** Open a decoder owned by the calling scope. */ +export function useReplDecoder( + options: { readonly escLatency?: number } = {}, +): Operation { + const settings = options.escLatency === undefined ? {} : { escLatency: options.escLatency }; + return resource(function* (provide) { + let input: Input = yield* until(createInput(settings)); + yield* provide({ + *scan(bytes?: Uint8Array): Operation { + const scanned = scanInput(input, bytes); + const settling = + bytes === undefined && + scanned.pendingFor !== undefined && + scanned.events.length === 0 && + scanned.pointers.length === 0; + if (!settling) { + return scanned; + } + // Nothing followed the held ESC, so it was the key. A fresh scanner, + // because the spent byte is still in this one's buffer and would + // otherwise become the first byte of whatever is typed next. + input = yield* until(createInput(settings)); + return Object.freeze({ + events: Object.freeze([ESCAPE]), + pointers: Object.freeze([]), + resized: scanned.resized, + pendingFor: undefined, + }); + }, + }); + }); +} diff --git a/packages/cli/src/repl/layout.ts b/packages/cli/src/repl/layout.ts new file mode 100644 index 000000000..91f845b3a --- /dev/null +++ b/packages/cli/src/repl/layout.ts @@ -0,0 +1,323 @@ +/** + * Where everything goes, at whatever size the terminal happens to be. + * + * Presentation and nothing else. Layout decides placement; it never changes + * which model objects are selected, which node a control belongs to or what an + * action means. Resizing a terminal therefore moves things and changes nothing + * else — which is why the frame it produces names the live node behind every + * cell rather than carrying a copy of what that node holds. + * + * ## Four profiles, and a refusal + * + * `160x36` is wide, `120x30` is medium, `72x20` is narrow, and anything smaller + * than narrow is a refusal rather than a squeezed layout. The refusal is + * explicit and recovers on resize: nothing is torn down, and no control it hides + * can be activated while it is showing. + * + * Wide and medium carry the sidebar, the transcript, the bindings/history + * inspection column, the drawer layer and the fixed full-width footer. Narrow + * keeps the drawer and the footer and gives the rest of the screen to one routed + * content surface. The History band is five rows at every size; when the labels + * of several markers cannot fit, their *visual* labels are grouped and every + * marker keeps its own identity in the frame. + * + * Geometry is integer and deterministic: the same size and the same surface + * produce the same frame, cell for cell and bound for bound. + */ + +import type { ReplTerminalSize } from "./terminal.ts"; + +/** Which layout one size gets. */ +export type ReplProfile = "wide" | "medium" | "narrow" | "too-small"; + +/** The named areas a frame can have. */ +export type ReplRegion = + | "sidebar" + | "transcript" + | "inspection" + | "content" + | "drawer" + | "footer" + | "refusal"; + +/** Integer cell geometry. */ +export interface ReplBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** One thing the application wants shown, and the node it belongs to. */ +export interface ReplSurfaceCell { + /** The live node this came from. Presentation never invents one. */ + readonly node: string; + readonly text: string; + /** Whether a pointer may activate it. */ + readonly targetable?: true; +} + +/** One selectable history position, as the band would label it. */ +export interface ReplSurfaceMarker { + readonly marker: string; + readonly label: string; +} + +/** What the application wants on screen, before anything decides where. */ +export interface ReplSurface { + /** + * The one surface a narrow frame shows, chosen by the route. + * + * Named separately rather than derived from the regions below, because which + * surface a narrow screen is showing is a routing decision the application + * makes and not one placement is entitled to invent. + */ + readonly content: readonly ReplSurfaceCell[]; + /** Present and possibly empty: an empty Sessions section is still a section. */ + readonly sessions: readonly ReplSurfaceCell[]; + readonly entries: readonly ReplSurfaceCell[]; + readonly transcript: readonly ReplSurfaceCell[]; + readonly inspection: readonly ReplSurfaceCell[]; + /** Absent when no drawer is open. */ + readonly drawer: readonly ReplSurfaceCell[]; + readonly footer: readonly ReplSurfaceCell[]; + readonly history: readonly ReplSurfaceMarker[]; +} + +/** One placed cell: what to draw, where, and which node it is. */ +export interface ReplPlacedCell { + /** The rendered element's id. Stable for one node in one region. */ + readonly id: string; + readonly node: string; + readonly region: ReplRegion; + readonly text: string; + readonly bounds: ReplBounds; + readonly targetable: boolean; +} + +/** One marker as the band shows it, grouped or not. */ +export interface ReplPlacedMarker { + readonly marker: string; + /** The label shown. Several markers may share one when space is short. */ + readonly label: string; + /** The other markers sharing that label, this one excluded. */ + readonly grouped: readonly string[]; +} + +/** One frame, decided and nothing more. */ +export interface ReplSemanticFrame { + readonly profile: ReplProfile; + readonly size: ReplTerminalSize; + readonly regions: readonly { readonly region: ReplRegion; readonly bounds: ReplBounds }[]; + readonly cells: readonly ReplPlacedCell[]; + /** Exactly five rows, whatever the size. */ + readonly historyRows: readonly string[]; + readonly markers: readonly ReplPlacedMarker[]; + /** The sentence a too-small frame shows, and nothing else it shows. */ + readonly refusal: string | undefined; +} + +/** The narrow profile's size, which is also the minimum this REPL draws at. */ +export const NARROW: ReplTerminalSize = { columns: 72, rows: 20 }; +const MEDIUM: ReplTerminalSize = { columns: 120, rows: 30 }; +const WIDE: ReplTerminalSize = { columns: 160, rows: 36 }; + +/** The footer is one border row, five History rows and one input row. */ +const FOOTER_ROWS = 7; +/** The History band's own five rows. */ +export const HISTORY_ROWS = 5; + +/** How wide a column is at each profile that has one. */ +interface ColumnWidths { + readonly wide: number; + readonly medium: number; +} + +const SIDEBAR: ColumnWidths = { wide: 32, medium: 28 }; +const INSPECTION: ColumnWidths = { wide: 36, medium: 28 }; + +/** Which profile a size gets. */ +export function profileFor(size: ReplTerminalSize): ReplProfile { + if (size.columns < NARROW.columns || size.rows < NARROW.rows) { + return "too-small"; + } + if (size.columns >= WIDE.columns && size.rows >= WIDE.rows) { + return "wide"; + } + if (size.columns >= MEDIUM.columns && size.rows >= MEDIUM.rows) { + return "medium"; + } + return "narrow"; +} + +/** Lay one surface out at one size. */ +export function layout(size: ReplTerminalSize, surface: ReplSurface): ReplSemanticFrame { + const profile = profileFor(size); + if (profile === "too-small") { + return refuse(size); + } + + const body = size.rows - FOOTER_ROWS; + const footer: ReplBounds = { x: 0, y: body, width: size.columns, height: FOOTER_ROWS }; + const regions: { region: ReplRegion; bounds: ReplBounds }[] = []; + const cells: ReplPlacedCell[] = []; + + if (profile === "narrow") { + const content: ReplBounds = { x: 0, y: 0, width: size.columns, height: body }; + regions.push({ region: "content", bounds: content }); + // Exactly the routed surface, and nothing else. A narrow screen that stacked + // every region would be a wide screen with the columns removed: the reader + // would scroll past three lists to reach the one they asked for, and every + // row of the other two would still be a target. + place(cells, "content", content, surface.content); + } else { + const sidebarWidth = profile === "wide" ? SIDEBAR.wide : SIDEBAR.medium; + const inspectionWidth = profile === "wide" ? INSPECTION.wide : INSPECTION.medium; + const sidebar: ReplBounds = { x: 0, y: 0, width: sidebarWidth, height: body }; + const inspection: ReplBounds = { + x: size.columns - inspectionWidth, + y: 0, + width: inspectionWidth, + height: body, + }; + const transcript: ReplBounds = { + x: sidebarWidth, + y: 0, + width: size.columns - sidebarWidth - inspectionWidth, + height: body, + }; + regions.push( + { region: "sidebar", bounds: sidebar }, + { region: "transcript", bounds: transcript }, + { region: "inspection", bounds: inspection }, + ); + place(cells, "sidebar", sidebar, [...surface.sessions, ...surface.entries]); + place(cells, "transcript", transcript, surface.transcript); + place(cells, "inspection", inspection, surface.inspection); + } + + if (surface.drawer.length > 0) { + // Over the body, inset, and above the footer rather than across it. + const drawer: ReplBounds = { + x: Math.floor(size.columns / 8), + y: Math.floor(body / 8), + width: size.columns - 2 * Math.floor(size.columns / 8), + height: body - 2 * Math.floor(body / 8), + }; + regions.push({ region: "drawer", bounds: drawer }); + place(cells, "drawer", drawer, surface.drawer); + } + + regions.push({ region: "footer", bounds: footer }); + place(cells, "footer", footer, surface.footer); + + const band = grouped(surface.history, size.columns); + return Object.freeze({ + profile, + size: Object.freeze({ ...size }), + regions: Object.freeze( + regions.map((region) => Object.freeze({ ...region, bounds: Object.freeze(region.bounds) })), + ), + cells: Object.freeze(cells), + historyRows: Object.freeze(rowsFor(band, size.columns)), + markers: Object.freeze(band), + refusal: undefined, + }); +} + +/** The one frame a terminal too small to draw in gets. */ +function refuse(size: ReplTerminalSize): ReplSemanticFrame { + const bounds: ReplBounds = { x: 0, y: 0, width: size.columns, height: size.rows }; + return Object.freeze({ + profile: "too-small", + size: Object.freeze({ ...size }), + regions: Object.freeze([Object.freeze({ region: "refusal", bounds: Object.freeze(bounds) })]), + // No cell at all, so nothing this frame hides can be pointed at: a control + // that is not in the frame is not in its target map either. + cells: Object.freeze([]), + historyRows: Object.freeze(new Array(HISTORY_ROWS).fill("")), + markers: Object.freeze([]), + refusal: + `This REPL needs at least ${NARROW.columns}x${NARROW.rows}; this terminal is ` + + `${size.columns}x${size.rows}. Make the window larger.`, + }); +} + +/** Stack one region's cells, one row each, clipped to the region's height. */ +function place( + into: ReplPlacedCell[], + region: ReplRegion, + bounds: ReplBounds, + contents: readonly ReplSurfaceCell[], +): void { + contents.slice(0, bounds.height).forEach((content, row) => { + into.push( + Object.freeze({ + id: `${region}:${content.node}`, + node: content.node, + region, + text: content.text, + bounds: Object.freeze({ x: bounds.x, y: bounds.y + row, width: bounds.width, height: 1 }), + targetable: content.targetable === true, + }), + ); + }); +} + +/** + * Label the markers, grouping their labels when they cannot all fit. + * + * Grouping is visual. Every marker stays in the frame under its own name, and + * the ones sharing a label say which others they share it with — so a compact + * band never costs a reader the ability to select an exact position. + */ +function grouped(markers: readonly ReplSurfaceMarker[], columns: number): ReplPlacedMarker[] { + // Five rows wide as the band is wide. Grouping is about the band's whole + // budget, not one row's: a label that will not fit row three may fit row four. + const budget = HISTORY_ROWS * Math.max(1, columns - 2); + const separate = markers.reduce((width, marker) => width + marker.label.length + 1, 0); + if (markers.length === 0 || separate <= budget) { + return markers.map((marker) => Object.freeze({ ...marker, grouped: Object.freeze([]) })); + } + + // A grouped label is its first member's plus a count, so budget for that. + const widest = markers.reduce((width, marker) => Math.max(width, marker.label.length), 1) + 4; + const affordable = Math.max(1, Math.floor(budget / (widest + 1))); + // At least two: reaching here means one-per-group would group nothing and the + // band would overflow exactly as before. + const perGroup = Math.max(2, Math.ceil(markers.length / affordable)); + const placed: ReplPlacedMarker[] = []; + for (let start = 0; start < markers.length; start += perGroup) { + const group = markers.slice(start, start + perGroup); + const label = group.length === 1 ? group[0].label : `${group[0].label} +${group.length - 1}`; + for (const marker of group) { + placed.push( + Object.freeze({ + marker: marker.marker, + label, + grouped: Object.freeze( + group.filter((other) => other !== marker).map((other) => other.marker), + ), + }), + ); + } + } + return placed; +} + +/** The five rows the History band shows. */ +function rowsFor(markers: readonly ReplPlacedMarker[], columns: number): string[] { + const rows = new Array(HISTORY_ROWS).fill(""); + const labels: string[] = []; + for (const marker of markers) { + if (!labels.includes(marker.label)) { + labels.push(marker.label); + } + } + labels.forEach((label, index) => { + const row = index % HISTORY_ROWS; + const next = rows[row] === "" ? label : `${rows[row]} ${label}`; + rows[row] = next.length <= columns ? next : rows[row]; + }); + return rows; +} diff --git a/packages/cli/src/repl/renderer.ts b/packages/cli/src/repl/renderer.ts new file mode 100644 index 000000000..76ea2357d --- /dev/null +++ b/packages/cli/src/repl/renderer.ts @@ -0,0 +1,370 @@ +/** + * Drawing one decided frame, and answering where things ended up. + * + * The renderer is the only thing that talks to `@bomb.sh/tty`'s layout engine, + * and it does two jobs: it turns a laid-out frame into terminal bytes, and it + * reports the geometry that came back so a pointer landing at a column and row + * can be answered with **the exact live node** that occupies it. It never + * invents a node: every target it publishes came from a cell that named one, + * and a cell whose node is not currently mounted is not drawn at all. + * + * ## Why a snapshot + * + * Every render input is retained as a frozen snapshot, because the layout + * engine has fixed arenas and can refuse a frame that is too large for them. + * Recovering from that means throwing the engine away and building a new one at + * the dimensions the frame was laid out for — those arenas are sized from the + * terminal's width and height, so a frame that overflows an engine built for a + * smaller terminal fits one built for this frame's own size. Then the same + * logical frame is drawn again. Without the snapshot, "the same frame" would + * have to be reassembled from live state that may already have moved on, and a + * recovery would quietly change what the user is looking at. With it, recovery + * is invisible: the same selection, the same focus, the same actions, the same + * frame map. + * + * Output bytes are copied out of the engine's memory the moment they arrive. + * `render()` hands back a view into WASM memory that the next render + * invalidates, so anything held past that call has to own its bytes. + */ + +import { Err, Ok, type Operation, resource, type Result, until } from "effection"; +import { close, createTerm, fixed, type Op, open, type PointerEvent, text } from "@bomb.sh/tty"; +import type { Term } from "@bomb.sh/tty"; +import type { ReplInputEvent } from "./description.ts"; +import type { ReplBounds, ReplPlacedCell, ReplSemanticFrame } from "./layout.ts"; + +/** The layout-engine failures that a bigger arena would fix. */ +const CAPACITY = new Set([ + "ARENA_CAPACITY_EXCEEDED", + "ELEMENTS_CAPACITY_EXCEEDED", + "TEXT_MEASUREMENT_CAPACITY_EXCEEDED", +]); + +/** A render that could not be completed. */ +export class ReplRenderError extends Error { + override name = "ReplRenderError"; +} + +/** Everything one render needs, frozen so a recovery can repeat it exactly. */ +export interface ReplRenderSnapshot { + /** Where everything goes. */ + readonly frame: ReplSemanticFrame; + /** The Slice C frame id this was laid out from, carried for identity. */ + readonly tree: number; + /** Every currently mounted node id. A cell naming anything else is dropped. */ + readonly mounted: readonly string[]; + /** Seconds since the last frame, for the engine's transitions. */ + readonly deltaTime: number; + /** The pointer position and button state, when there is one. */ + readonly pointer: { readonly x: number; readonly y: number; readonly down: boolean } | undefined; +} + +/** One drawn element and the live node behind it. */ +export interface ReplTarget { + /** The element id the engine measured. */ + readonly id: string; + /** The live node. Never fabricated. */ + readonly node: string; + readonly bounds: ReplBounds; +} + +/** Where everything landed. Immutable, and retained with its frame. */ +export interface ReplFrameMap { + /** In draw order, so a later entry is drawn over an earlier one. */ + readonly targets: readonly ReplTarget[]; + /** The live node at one position, topmost first, or none. */ + at(x: number, y: number): string | undefined; + /** The geometry one element id was given, or none if it was not drawn. */ + boundsOf(id: string): ReplBounds | undefined; + /** The live node one element id belongs to. */ + nodeOf(id: string): string | undefined; +} + +/** What one render produced. */ +export interface ReplRendered { + /** The Slice C frame id that was drawn. */ + readonly tree: number; + /** Bytes this result owns. Safe to hold. */ + readonly output: Uint8Array; + readonly map: ReplFrameMap; + readonly pointers: readonly PointerEvent[]; + readonly animating: boolean; + /** Whether the engine had to be rebuilt to fit this frame. */ + readonly recovered: boolean; +} + +/** The renderer, for as long as its scope lives. */ +export interface ReplRenderer { + /** Draw one snapshot. */ + render(snapshot: ReplRenderSnapshot): Operation>; + /** Tell the engine the terminal changed size. */ + resize(size: { readonly columns: number; readonly rows: number }): void; + /** The last snapshot drawn, for a caller that wants to redraw it verbatim. */ + last(): ReplRenderSnapshot | undefined; +} + +/** Freeze one render input so repeating it cannot depend on anything mutable. */ +export function snapshotRender(input: ReplRenderSnapshot): ReplRenderSnapshot { + return Object.freeze({ + frame: input.frame, + tree: input.tree, + mounted: Object.freeze([...input.mounted]), + deltaTime: input.deltaTime, + pointer: input.pointer === undefined ? undefined : Object.freeze({ ...input.pointer }), + }); +} + +/** + * Build a renderer owned by the calling scope. + * + * The engine is a WASM instance, so it is a resource: one per renderer, torn + * down with the scope that asked for it, and replaced in place when a frame + * overflows its arenas. + */ +export function useReplRenderer(size: { + readonly columns: number; + readonly rows: number; +}): Operation { + return resource(function* (provide) { + let term = yield* until(createTerm({ width: size.columns, height: size.rows })); + let retained: ReplRenderSnapshot | undefined; + + function* rebuild(frame: ReplSemanticFrame): Operation { + // Sized for the frame in hand, which is the terminal's current size: the + // engine's measurement arenas scale with width and height, so an engine + // built when the terminal was smaller is exactly the one that overflows. + term = yield* until(createTerm({ width: frame.size.columns, height: frame.size.rows })); + } + + yield* provide({ + *render(snapshot) { + let frozen = snapshotRender(snapshot); + retained = frozen; + let ops = opsFor(frozen); + + let first = attempt(term, ops, frozen); + if (first.kind === "drawn") { + return Ok({ ...first.rendered, recovered: false }); + } + if (!first.capacity) { + return Err(new ReplRenderError(first.reason)); + } + + yield* rebuild(frozen.frame); + let second = attempt(term, ops, frozen); + if (second.kind === "drawn") { + return Ok({ ...second.rendered, recovered: true }); + } + return Err( + new ReplRenderError( + `the frame does not fit the layout engine even after rebuilding it: ${second.reason}`, + ), + ); + }, + resize(next) { + term.update({ width: next.columns, height: next.rows }); + }, + last() { + return retained; + }, + }); + }); +} + +/** + * What one pass at the engine did. + * + * Not a `Result`: the caller has to tell a refusal a bigger engine would fix + * from one it would not, and that is a second question about the same failure + * rather than a different outcome. + */ +type Attempted = + | { readonly kind: "drawn"; readonly rendered: Omit } + | { readonly kind: "refused"; readonly capacity: boolean; readonly reason: string }; + +/** One pass at the engine, classifying whatever came back. */ +function attempt(term: Term, ops: Op[], snapshot: ReplRenderSnapshot): Attempted { + let result; + try { + result = term.render(ops, { + row: 1, + deltaTime: snapshot.deltaTime, + ...(snapshot.pointer === undefined ? {} : { pointer: { ...snapshot.pointer } }), + }); + } catch (error) { + // `pack` throws rather than reporting when the ops themselves outgrow the + // transfer buffer, which is the same problem a bigger engine solves. + let reason = error instanceof Error ? error.message : String(error); + return { kind: "refused", capacity: error instanceof RangeError, reason }; + } + + let capacity = result.errors.filter((error) => CAPACITY.has(error.type)); + if (capacity.length > 0) { + return { + kind: "refused", + capacity: true, + reason: capacity.map((error) => `${error.type}: ${error.message}`).join("; "), + }; + } + if (result.errors.length > 0) { + return { + kind: "refused", + capacity: false, + reason: result.errors.map((error) => `${error.type}: ${error.message}`).join("; "), + }; + } + + return { + kind: "drawn", + rendered: { + tree: snapshot.tree, + // Copied now: this view points into the engine's memory and the next + // render invalidates it. + output: new Uint8Array(result.output), + map: mapOf(snapshot, result.info), + pointers: Object.freeze([...result.events]), + animating: result.animating, + }, + }; +} + +/** The cells of one frame that name a node the tree still has mounted. */ +function drawn(snapshot: ReplRenderSnapshot): readonly ReplPlacedCell[] { + let mounted = new Set(snapshot.mounted); + return snapshot.frame.cells.filter((cell) => mounted.has(cell.node)); +} + +/** Turn one laid-out frame into engine ops. */ +function opsFor(snapshot: ReplRenderSnapshot): Op[] { + let { frame } = snapshot; + let ops: Op[] = [ + open("repl", { + layout: { + width: fixed(frame.size.columns), + height: fixed(frame.size.rows), + direction: "ttb", + }, + }), + ]; + + if (frame.refusal !== undefined) { + ops.push(open("repl:refusal", { layout: { width: fixed(frame.size.columns) } })); + ops.push(text(frame.refusal)); + ops.push(close()); + ops.push(close()); + return ops; + } + + for (let cell of drawn(snapshot)) { + ops.push( + open(cell.id, { + layout: { width: fixed(cell.bounds.width), height: fixed(cell.bounds.height) }, + floating: { x: cell.bounds.x, y: cell.bounds.y, attachTo: "root" }, + }), + ); + ops.push(text(cell.text)); + ops.push(close()); + } + + ops.push( + open("repl:history", { + layout: { width: fixed(frame.size.columns), height: fixed(frame.historyRows.length) }, + }), + ); + for (let row of frame.historyRows) { + ops.push(text(row)); + } + ops.push(close()); + + ops.push(close()); + return ops; +} + +/** The frame map: what the engine measured, paired with the node that asked. */ +function mapOf( + snapshot: ReplRenderSnapshot, + info: { + get( + id: string, + ): { bounds: { x: number; y: number; width: number; height: number } } | undefined; + }, +): ReplFrameMap { + let targets: ReplTarget[] = []; + for (let cell of drawn(snapshot)) { + let measured = info.get(cell.id); + if (measured === undefined) { + // Not measured means not on screen, and something not on screen must not + // be pointable: leaving it out is the whole point of the map. + continue; + } + if (!cell.targetable) { + continue; + } + targets.push( + Object.freeze({ + id: cell.id, + node: cell.node, + bounds: Object.freeze({ + x: measured.bounds.x, + y: measured.bounds.y, + width: measured.bounds.width, + height: measured.bounds.height, + }), + }), + ); + } + + let frozen = Object.freeze(targets); + let byId = new Map(frozen.map((target) => [target.id, target])); + let map: ReplFrameMap = { + targets: frozen, + at(x, y) { + // Back to front: the last thing drawn is the thing on top, which is how a + // drawer keeps a pointer off the controls it covers. + for (let index = frozen.length - 1; index >= 0; index -= 1) { + let { bounds, node } = frozen[index]; + if ( + x >= bounds.x && + x < bounds.x + bounds.width && + y >= bounds.y && + y < bounds.y + bounds.height + ) { + return node; + } + } + return undefined; + }, + boundsOf(id) { + return byId.get(id)?.bounds; + }, + nodeOf(id) { + return byId.get(id)?.node; + }, + }; + return Object.freeze(map); +} + +/** The pointer member of the normalized union, which is all this can produce. */ +export type ReplPointerInput = Extract; + +/** + * Which node a pointer landed on, according to the frame it was resolved + * against. + * + * The frame id travels with the answer, so a pointer resolved against a frame + * the tree has moved past arrives at `dispatch` carrying that older number and + * is dropped there. That is why this takes a whole render result rather than a + * map: a map on its own could be paired with any frame id, and a pointer that + * borrowed a current number would reach a node from a tree that no longer + * exists. + */ +export function resolvePointer( + rendered: ReplRendered, + at: { readonly column: number; readonly row: number }, +): ReplPointerInput | undefined { + const target = rendered.map.at(at.column, at.row); + if (target === undefined) { + return undefined; + } + return { kind: "pointer", target, frame: rendered.tree }; +} diff --git a/packages/cli/src/repl/screen.ts b/packages/cli/src/repl/screen.ts new file mode 100644 index 000000000..79ca0d931 --- /dev/null +++ b/packages/cli/src/repl/screen.ts @@ -0,0 +1,241 @@ +/** + * Everything the REPL has to give back, and the one place that gives it back. + * + * Opening a terminal for a full-screen interface changes things that belong to + * whoever ran the command: raw mode, the alternate buffer, the cursor, mouse + * reporting, the keyboard protocol. Every one of those has to be undone on + * every way out — the run finishing, a refusal, a failure, a cancellation, and + * the person closing the input. So there is exactly one owner of all of it, it + * registers the undo **before** it does the do, and the final reset is written + * once whatever happened. + * + * ## Cleanup before capability + * + * Each step here registers its teardown first and only then takes the thing it + * is undoing. A cancellation that lands between the two orders would otherwise + * leave a raw terminal with no cursor and nobody left who knows to fix it — + * which is not a crash, it is a shell the person has to blindly type `reset` + * into. + * + * ## What comes out + * + * Normalized events and nothing else. The screen decodes bytes, reads the + * terminal's size and reports both; it does not know what any of it means. A + * key is a key, a pointer is a column and a row, a resize is a size, and end of + * input is a lifecycle outcome rather than a keystroke. + */ + +import { + createSignal, + ensure, + type Operation, + resource, + spawn, + type Stream, + type Subscription, + type Task, +} from "effection"; +import { + alternateBuffer, + cursor, + mouseTracking, + progressiveInput, + type Setting, + settings, +} from "@bomb.sh/tty"; +import { ReplTerminal, type ReplTerminalSize } from "./terminal.ts"; +import { ReplClock } from "./frame.ts"; +import { type ReplPointerAt, useReplDecoder } from "./input.ts"; +import type { ReplInputEvent } from "./description.ts"; + +/** What the screen reports. */ +export type ReplScreenEvent = + | { readonly kind: "input"; readonly event: ReplInputEvent } + | { readonly kind: "pointer"; readonly at: ReplPointerAt } + | { readonly kind: "resize"; readonly size: ReplTerminalSize } + /** Input ended. A lifecycle outcome, not a key. */ + | { readonly kind: "eof" }; + +/** The open terminal, for as long as its scope lives. */ +export interface ReplScreen { + /** The size as it stands. */ + size(): Operation; + /** Put one rendered frame on the terminal. */ + present(bytes: Uint8Array): Operation; + /** + * Everything that happened, in order, from the moment the screen opened. + * + * One consumer. The subscription is taken when the screen opens rather than + * when the caller gets around to reading, so nothing that arrives in between + * is lost. + */ + events(): Stream; +} + +/** + * The modes a full-screen REPL takes, and therefore has to give back. + * + * One `Setting`, so there is one `apply` and one `revert` and no way to restore + * three of four things. + */ +export function replModes(): Setting { + return settings( + alternateBuffer({ clear: true }), + cursor(false), + mouseTracking(), + // Level 1: disambiguated escape codes, which is what makes a lone Escape + // decidable at all on terminals that support it. + progressiveInput(1), + ); +} + +/** Open the terminal for the calling scope. */ +export function useReplScreen(options?: { readonly escLatency?: number }): Operation { + return resource(function* (provide) { + const decoder = yield* useReplDecoder( + options?.escLatency === undefined ? {} : { escLatency: options.escLatency }, + ); + const modes = replModes(); + + let restored = false; + // Registered before the modes are applied, so a cancellation in between + // still restores. Written once: the flag is what makes "exactly once" true + // across a teardown that runs after an earlier failure already reset. + yield* ensure(function* (): Operation { + if (restored) { + return; + } + restored = true; + yield* ReplTerminal.operations.setRaw(false); + yield* ReplTerminal.operations.writeNow(modes.revert); + }); + yield* ReplTerminal.operations.setRaw(true); + yield* ReplTerminal.operations.write(modes.apply); + + const events = createSignal(); + // Subscribed here, not in `events()`: a subscription taken later would miss + // whatever arrived first, and the first thing that arrives is usually the + // size the renderer needs. + const reported: Subscription = yield* events; + + // Bytes, or nothing at all — which means "the pending Escape's latency + // expired, scan again with no new input". One queue and therefore one place + // the scanner ever waits: a scanner waiting on two things would have to race + // them, and the loser of that race is a read that was already in flight. + const chunks = createSignal(); + const incoming: Subscription = yield* chunks; + + let delivered: ReplTerminalSize | undefined; + function deliverResize(size: ReplTerminalSize): void { + if ( + delivered !== undefined && + delivered.columns === size.columns && + delivered.rows === size.rows + ) { + // The same size twice is the same frame twice. Terminals report a + // resize both in band and out of band, and either one is enough. + return; + } + delivered = size; + events.send({ kind: "resize", size }); + } + + /** Publish what one scan produced. */ + function* publish(bytes?: Uint8Array): Operation { + const scan = yield* decoder.scan(bytes); + for (const event of scan.events) { + events.send({ kind: "input", event }); + } + for (const at of scan.pointers) { + events.send({ kind: "pointer", at }); + } + if (scan.resized) { + deliverResize(yield* ReplTerminal.operations.size()); + } + return scan.pendingFor; + } + + yield* spawn(function* reader(): Operation { + const source = yield* ReplTerminal.operations.input(); + while (true) { + const next = yield* source.next(); + if (next.done === true) { + chunks.close(); + return; + } + chunks.send(next.value); + } + }); + + yield* spawn(function* scanner(): Operation { + // The one-shot task that turns a held Escape into an Escape. It exists for + // exactly as long as the Escape is undecided. + let latency: Task | undefined; + + while (true) { + const next = yield* incoming.next(); + if (next.done === true) { + // Flush first: a held Escape is something the person pressed, and + // losing it because the stream then ended would be a lost keystroke. + yield* publish(); + events.send({ kind: "eof" }); + events.close(); + return; + } + + const waiting = latency; + latency = undefined; + if (waiting !== undefined) { + // Either it fired and sent this, or bytes beat it. Either way it is + // done, and leaving it running would flush an Escape that the bytes + // just now decided was part of a sequence. + yield* waiting.halt(); + } + + // `undefined` is the flush: rescanning with no bytes emits the Escape. + // Bytes are rescanned with the held prefix still in the decoder, so one + // sequence is never split into a false Escape followed by text. + const pending = yield* publish(next.value); + if (pending !== undefined) { + latency = yield* spawn(function* (): Operation { + yield* ReplClock.operations.wait(pending / 1000); + chunks.send(undefined); + }); + } + } + }); + + // Subscribed before the task that reads it, because a task starts a turn + // after it is spawned and a terminal resized during that turn would be a + // resize nobody was listening for. + const sizes: Subscription = yield* ReplTerminal.operations.resizes(); + yield* spawn(function* watcher(): Operation { + while (true) { + const next = yield* sizes.next(); + if (next.done === true) { + return; + } + deliverResize(next.value); + } + }); + + deliverResize(yield* ReplTerminal.operations.size()); + + yield* provide({ + size(): Operation { + return ReplTerminal.operations.size(); + }, + present(bytes: Uint8Array): Operation { + return ReplTerminal.operations.write(bytes); + }, + events(): Stream { + return { + // deno-lint-ignore require-yield + *[Symbol.iterator]() { + return reported; + }, + }; + }, + }); + }); +} diff --git a/packages/cli/src/repl/terminal-host.ts b/packages/cli/src/repl/terminal-host.ts new file mode 100644 index 000000000..6013e4c28 --- /dev/null +++ b/packages/cli/src/repl/terminal-host.ts @@ -0,0 +1,241 @@ +/** + * The portable half of a REPL terminal, over capabilities a runtime supplies. + * + * `terminal.ts` says what the REPL needs; this says how to build it + * once a host has handed over five things it alone can do. Everything that is + * the same on every runtime — turning an async byte source into a stream with + * real backpressure, turning a resize notification into a stream of sizes, + * ending both when the scope ends — lives here, so the runtime-named factories + * contain nothing but their own runtime's spelling of those five capabilities. + * + * Nothing registers this. A runtime entrypoint installs it when the REPL + * command exists; until then it is a factory nobody calls, which is what keeps + * `@bomb.sh/tty` and raw mode off the path of every other command. + */ + +import { + createSignal, + ensure, + type Operation, + resource, + type Stream, + type Subscription, + until, +} from "effection"; +import { ReplTerminal, type ReplTerminalSize } from "./terminal.ts"; + +/** + * What a runtime has to provide, stated structurally. + * + * No host global appears in this module's types: a factory passes its own + * runtime's functions in by value, the same way the standard-input adapter + * takes a stream (Code Rule 12). + */ +export interface ReplTerminalCapabilities { + /** The size right now. */ + size(): ReplTerminalSize; + /** + * Hand bytes to the terminal, resolving once it has them. + * + * A promise rather than a fire-and-forget call, so a slow terminal slows the + * renderer down instead of queueing frames the user will never see. + */ + write(bytes: Uint8Array): Promise; + /** Hand bytes over with no suspension point inside the call. */ + writeNow(bytes: Uint8Array): void; + /** Turn raw mode on or off. */ + setRaw(raw: boolean): void; + /** + * Open the byte source. + * + * Called once per subscriber and closed when that subscriber's scope ends, + * which is what releases the terminal's input for whatever runs next. + */ + bytes(): AsyncIterable; + /** Watch for size changes; the returned function stops watching. */ + onResize(listener: () => void): () => void; +} + +/** Install one runtime's terminal for the calling scope. */ +export function installReplTerminal(host: ReplTerminalCapabilities): Operation { + return ReplTerminal.around( + { + // deno-lint-ignore require-yield + *size(): Operation { + return host.size(); + }, + *write([bytes]: [Uint8Array]): Operation { + yield* until(host.write(bytes)); + }, + writeNow([bytes]: [Uint8Array]): void { + host.writeNow(bytes); + }, + // deno-lint-ignore require-yield + *setRaw([raw]: [boolean]): Operation { + host.setRaw(raw); + }, + input(): Stream { + return byteStream(host); + }, + resizes(): Stream { + return sizeStream(host); + }, + }, + { at: "min" }, + ); +} + +/** + * The host's bytes, pulled one chunk at a time. + * + * Pulled, not pushed: the subscriber's `next()` is what asks the host for more, + * so an input burst cannot outrun whatever is decoding it and nothing has to + * buffer on its behalf. + */ +function byteStream(host: ReplTerminalCapabilities): Stream { + return resource>(function* (provide) { + // Declared first, released second, opened third. Opening the source before + // its release was registered leaves a window where a cancellation abandons a + // reader holding the terminal's input, and nothing is left that knows to + // close it. + let iterator: AsyncIterator | undefined; + yield* ensure(function* () { + const open = iterator; + if (open === undefined) { + return; + } + const close = open.return; + if (close !== undefined) { + // Releasing the source is what lets the next thing to run read the + // terminal; a reader left open holds input away from it. + yield* until(close.call(open)); + } + }); + iterator = host.bytes()[Symbol.asyncIterator](); + + const reading = iterator; + yield* provide({ + *next(): Operation> { + const result = yield* until(reading.next()); + if (result.done === true) { + return { done: true, value: undefined }; + } + return { done: false, value: result.value }; + }, + }); + }); +} + +/** + * Each size the terminal becomes. + * + * The size is read when the notification arrives rather than carried by it, + * because a resize that happens twice in quick succession should deliver where + * the terminal actually ended up. + */ +function sizeStream(host: ReplTerminalCapabilities): Stream { + return resource>(function* (provide) { + const sizes = createSignal(); + // Removal registered before the listener exists, for the same reason: a + // listener attached in a window with no teardown behind it outlives whoever + // wanted it and sends into a signal nobody is reading. + let stop: (() => void) | undefined; + yield* ensure(() => { + stop?.(); + }); + stop = host.onResize(() => { + sizes.send(host.size()); + }); + yield* provide(yield* sizes); + }); +} + +/** + * Write every byte through a sink that may take fewer than it was given. + * + * A partial write that nobody continued leaves a frame half drawn, which on a + * terminal means escape sequences cut in the middle. Portable because it is the + * same loop on every runtime whose write returns a count. + */ +export function writeAllTo( + sink: (chunk: Uint8Array) => Promise, + bytes: Uint8Array, +): Promise { + let written = 0; + const step = (): Promise => { + if (written >= bytes.length) { + return Promise.resolve(); + } + return sink(bytes.subarray(written)).then((count) => { + if (count <= 0) { + return Promise.reject(new Error("the terminal accepted none of the bytes it was given")); + } + written += count; + return step(); + }); + }; + return step(); +} + +/** + * The same sink for a host whose write takes a callback instead of returning. + * + * Node-shaped streams report completion rather than a count and handle partial + * writes themselves, so there is nothing to loop over — only a callback to turn + * into the promise the capability promises. + */ +export function writeThrough( + sink: (chunk: Uint8Array, done: (error?: Error | null) => void) => unknown, + bytes: Uint8Array, +): Promise { + return new Promise((resolve, reject) => { + sink(bytes, (error) => { + if (error === undefined || error === null) { + resolve(); + } else { + reject(error); + } + }); + }); +} + +/** + * A byte source built from one that may hand back text. + * + * Node-shaped standard input yields strings when an encoding was set on it, and + * the decoder downstream reads bytes; converting here keeps that difference out + * of the shared input path. Written with promise combinators rather than `async + * function*`, which this repository does not use. + */ +export function decodedChunks( + source: AsyncIterable, +): AsyncIterable { + const encoder = new TextEncoder(); + return { + [Symbol.asyncIterator](): AsyncIterator { + const iterator = source[Symbol.asyncIterator](); + const close = iterator.return; + return { + next(): Promise> { + return iterator.next().then((result) => { + if (result.done === true) { + return { done: true, value: undefined }; + } + const chunk = result.value; + return { + done: false, + value: typeof chunk === "string" ? encoder.encode(chunk) : chunk, + }; + }); + }, + ...(close === undefined + ? {} + : { + return(): Promise> { + return close.call(iterator).then(() => ({ done: true, value: undefined })); + }, + }), + }; + }, + }; +} diff --git a/packages/cli/src/repl/terminal.ts b/packages/cli/src/repl/terminal.ts new file mode 100644 index 000000000..276c9e232 --- /dev/null +++ b/packages/cli/src/repl/terminal.ts @@ -0,0 +1,83 @@ +/** + * What the REPL needs from a terminal, and who restores it. + * + * Shared code never asks which runtime it is on. It asks this Api, and a + * runtime-named adapter installs the answers: writing bytes, reading them, + * reporting the size, being told when the size changes, and turning raw mode on + * and off. Everything else — modes, the reader, the reset sequence — is + * ordinary portable code built on top of those five things. + * + * ## One unsuspending write, and why + * + * Restoring a terminal has to happen in order, and the last step of it happens + * while the process is already unwinding. `writeNow` is that one step: the + * adapter hands the bytes to the terminal with no suspension point inside the + * call, so nothing else being torn down can interleave between deciding to + * reset and the reset landing. Every other write suspends and yields the + * interpreter like any other operation. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation, Stream } from "effection"; + +/** How much terminal there is, in cells. */ +export interface ReplTerminalSize { + readonly columns: number; + readonly rows: number; +} + +/** No adapter installed this operation, so the REPL cannot perform it. */ +export class ReplTerminalError extends Error { + constructor(operation: string) { + super( + `the REPL has no terminal for ${operation}. A runtime adapter installs one with ` + + 'ReplTerminal.around({ ... }, { at: "min" }) before the terminal is opened.', + ); + this.name = "ReplTerminalError"; + } +} + +export interface ReplTerminalApi { + /** The size as it stands. */ + size(): Operation; + /** Write bytes, returning once the terminal has them. */ + write(bytes: Uint8Array): Operation; + /** + * Write bytes without suspending. + * + * The one ordered-teardown case: the final reset must not be interleaved with + * anything else unwinding, so the adapter completes it inside the call rather + * than at a suspension point. Nothing else uses it. + */ + writeNow(bytes: Uint8Array): void; + /** Turn raw mode on or off. */ + setRaw(raw: boolean): Operation; + /** Each chunk of input bytes, closing at end of input. */ + input(): Stream; + /** Each size the terminal becomes. A host event, never a decoded key. */ + resizes(): Stream; +} + +export const ReplTerminal: Api = createApi("ReplTerminal", { + // deno-lint-ignore require-yield + *size(): Operation { + throw new ReplTerminalError("reading its size"); + }, + // deno-lint-ignore require-yield + *write(_bytes: Uint8Array): Operation { + throw new ReplTerminalError("writing to it"); + }, + writeNow(_bytes: Uint8Array): void { + throw new ReplTerminalError("writing to it"); + }, + // deno-lint-ignore require-yield + *setRaw(_raw: boolean): Operation { + throw new ReplTerminalError("changing its modes"); + }, + input(): Stream { + throw new ReplTerminalError("reading from it"); + }, + resizes(): Stream { + throw new ReplTerminalError("watching its size"); + }, +}); diff --git a/packages/cli/tests/fixtures/repl/surface.ts b/packages/cli/tests/fixtures/repl/surface.ts new file mode 100644 index 000000000..8094cf847 --- /dev/null +++ b/packages/cli/tests/fixtures/repl/surface.ts @@ -0,0 +1,362 @@ +/** + * A semantic REPL screen, described through the composition factories. + * + * Enough of a screen to lay out and render: a Sessions list that can be empty, + * Entries, the selected entry's scopes, a transcript, a bindings/history + * inspection column, an optional drawer and a footer holding History and an + * input. Every one of them is an ordinary described node, so what is under test + * is placement and rendering rather than a screen this fixture invented. + * + * The region a node belongs to is decided **here**, from its key, and the text + * it shows comes from the committed frame. Presentation therefore reads the + * tree; it never tells the tree anything, which is what keeps a resize from + * touching identity. + */ + +import { component, describe as describeNode, fields } from "../../../src/repl/description.ts"; +import type { + ReplComponent, + ReplDescription, + ReplInputEvent, + ReplNode, + ReplViewData, +} from "../../../src/repl/description.ts"; +import type { + ReplRegion, + ReplSurface, + ReplSurfaceCell, + ReplSurfaceMarker, +} from "../../../src/repl/layout.ts"; +import type { ReplTree } from "../../../src/repl/reconcile.ts"; + +/** Everything this screen's controls can ask the root to do. */ +export type Surfaced = + | { readonly kind: "select-session"; readonly key: string } + | { readonly kind: "select-entry"; readonly key: string } + | { readonly kind: "select-scope"; readonly key: string } + | { readonly kind: "select-marker"; readonly key: string } + | { readonly kind: "close-drawer" } + | { readonly kind: "submit" } + /** Text the person typed or pasted, for the draft to append. */ + | { readonly kind: "insert"; readonly text: string } + | { readonly kind: "erase" }; + +/** The one string field every fixture component is given. */ +function text(input: ReplViewData): string { + const value = fields(input)?.["text"]; + if (typeof value !== "string") { + throw new Error("a fixture node is given a { text } input"); + } + return value; +} + +/** + * A selectable row. + * + * Its action comes from its key, which does not change when its text does — so + * redescribing a row for a narrower screen cannot change what activating it + * means. + */ +/** The actions a selectable row can produce. */ +type Selecting = "select-session" | "select-entry" | "select-scope" | "select-marker"; + +/** One selection action, spelled out per kind so nothing has to be asserted. */ +function selected(kind: Selecting, key: string): Surfaced { + switch (kind) { + case "select-session": + return { kind: "select-session", key }; + case "select-entry": + return { kind: "select-entry", key }; + case "select-scope": + return { kind: "select-scope", key }; + case "select-marker": + return { kind: "select-marker", key }; + } +} + +function row(kind: Selecting | "close-drawer"): ReplComponent { + return component({ + name: `row:${kind}`, + attach(node: ReplNode): void { + node.focusable(); + node.render(text(node.input)); + node.onInput((input: ReplViewData) => node.render(text(input))); + node.claim((event: ReplInputEvent): Surfaced | undefined => { + // A row is not an editor: text passes it by on the way to whatever is. + if (event.kind === "text") { + return undefined; + } + if (event.kind !== "pointer" && event.key !== "Enter") { + return undefined; + } + if (kind === "close-drawer") { + return { kind: "close-drawer" }; + } + return selected(kind, node.key); + }); + }, + }); +} + +/** A line of text nobody can select. */ +const LINE: ReplComponent = component({ + name: "line", + attach(node: ReplNode): void { + node.render(text(node.input)); + node.onInput((input: ReplViewData) => node.render(text(input))); + }, +}); + +/** A titled container. */ +const SECTION: ReplComponent = component({ + name: "section", + attach(node: ReplNode): void { + node.render(text(node.input)); + node.onInput((input: ReplViewData) => node.render(text(input))); + }, +}); + +/** A drawer: a modal branch that closes on Escape. */ +const DRAWER: ReplComponent = component({ + name: "drawer", + attach(node: ReplNode): void { + node.render(text(node.input)); + node.onInput((input: ReplViewData) => node.render(text(input))); + node.claim((event: ReplInputEvent): Surfaced | undefined => + event.kind === "key" && event.key === "Escape" ? { kind: "close-drawer" } : undefined, + ); + }, +}); + +/** + * The draft line, which is where typing goes. + * + * It claims text and Backspace as well as Enter, and answers each with its own + * semantic action. The host said what arrived; what it means to a draft — append, + * erase, submit — is this component's to decide, which is why the same Enter that + * activates a row submits here. + */ +const DRAFT: ReplComponent = component({ + name: "draft", + attach(node: ReplNode): void { + node.focusable(); + node.render(text(node.input)); + node.onInput((input: ReplViewData) => node.render(text(input))); + node.claim((event: ReplInputEvent): Surfaced | undefined => { + if (event.kind === "text") { + return { kind: "insert", text: event.text }; + } + if (event.kind === "pointer") { + return { kind: "submit" }; + } + if (event.key === "Enter") { + return { kind: "submit" }; + } + if (event.key === "Backspace") { + return { kind: "erase" }; + } + return undefined; + }); + }, +}); + +const SESSION_ROW = row("select-session"); +const ENTRY_ROW = row("select-entry"); +const SCOPE_ROW = row("select-scope"); +const MARKER_ROW = row("select-marker"); +const CLOSE_ROW = row("close-drawer"); + +/** + * Which surface a narrow screen is showing. + * + * A route, not a preference: the application decides it, and placement is told + * rather than asked. + */ +export type ReplFixtureRoute = "sessions" | "entries" | "transcript" | "inspection"; + +/** What the screen is showing. */ +export interface ReplFixtureState { + /** The surface a narrow frame routes to. */ + readonly route: ReplFixtureRoute; + readonly sessions: readonly string[]; + readonly entries: readonly string[]; + readonly scopes: readonly string[]; + readonly transcript: readonly string[]; + readonly bindings: readonly { readonly name: string; readonly value: string }[]; + readonly history: readonly ReplSurfaceMarker[]; + /** The open drawer's title, or none. */ + readonly drawer: string | undefined; + readonly draft: string; +} + +/** A state with nothing in it yet, which is a screen the REPL really shows. */ +export const EMPTY: ReplFixtureState = { + route: "sessions", + sessions: [], + entries: [], + scopes: [], + transcript: [], + bindings: [], + history: [], + drawer: undefined, + draft: "", +}; + +function leaf( + kind: ReplComponent, + key: string, + body: string, + options: { + readonly modal?: true; + readonly focus?: true; + readonly children?: readonly ReplDescription[]; + } = {}, +): ReplDescription { + return describeNode({ + key, + component: kind, + input: { text: body }, + ...(options.children === undefined ? {} : { children: options.children }), + ...(options.modal === undefined ? {} : { modal: options.modal }), + ...(options.focus === undefined ? {} : { focus: options.focus }), + }); +} + +/** Describe the whole screen. One flat set: placement is not nesting. */ +export function fixtureDescriptions(state: ReplFixtureState): readonly ReplDescription[] { + const described: ReplDescription[] = []; + + described.push( + state.sessions.length === 0 + ? // An empty list is a thing the screen says, not a thing it omits. + leaf(SECTION, "sessions:empty", "Sessions: none yet") + : leaf(SECTION, "sessions:heading", "Sessions"), + ); + for (const session of state.sessions) { + described.push(leaf(SESSION_ROW, `session:${session}`, session)); + } + described.push(leaf(SECTION, "entries:heading", "Entries")); + for (const entry of state.entries) { + described.push(leaf(ENTRY_ROW, `entry:${entry}`, entry)); + } + for (const scope of state.scopes) { + described.push(leaf(SCOPE_ROW, `scope:${scope}`, scope)); + } + for (const [index, line] of state.transcript.entries()) { + described.push(leaf(LINE, `line:${index}`, line)); + } + for (const binding of state.bindings) { + described.push(leaf(LINE, `binding:${binding.name}`, `${binding.name} = ${binding.value}`)); + } + for (const marker of state.history) { + described.push(leaf(MARKER_ROW, `marker:${marker.marker}`, marker.label)); + } + // Focus follows the innermost modal. An open drawer holds it, because the + // composition kernel refuses a focus claim its open modal does not contain — + // which is the same reason the drawer's own control is its child rather than + // its sibling. + described.push( + state.drawer === undefined + ? leaf(DRAFT, "footer:input", `> ${state.draft}`, { focus: true }) + : leaf(DRAFT, "footer:input", `> ${state.draft}`), + ); + if (state.drawer !== undefined) { + described.push( + leaf(DRAWER, "drawer:open", state.drawer, { + modal: true, + children: [leaf(CLOSE_ROW, "drawer:close", "Close", { focus: true })], + }), + ); + } + return described; +} + +/** Which region a key belongs to, and whether a pointer may activate it. */ +function regionOf( + key: string, +): { readonly region: ReplRegion; readonly targetable: boolean } | undefined { + if (key.startsWith("sessions:") || key.startsWith("entries:")) { + return { region: "sidebar", targetable: false }; + } + if (key.startsWith("session:") || key.startsWith("entry:") || key.startsWith("scope:")) { + return { region: "sidebar", targetable: true }; + } + if (key.startsWith("line:")) { + return { region: "transcript", targetable: false }; + } + if (key.startsWith("binding:")) { + return { region: "inspection", targetable: false }; + } + if (key.startsWith("marker:")) { + return { region: "footer", targetable: true }; + } + if (key.startsWith("footer:")) { + return { region: "footer", targetable: true }; + } + if (key.startsWith("drawer:")) { + return { region: "drawer", targetable: true }; + } + return undefined; +} + +/** + * The surface a committed tree presents. + * + * Read from the frame, so a node the tree has removed contributes nothing and a + * cell nobody rendered is not in it. The live node id travels with every cell, + * because that id is what a pointer resolved against the drawn frame has to name. + */ +export function fixtureSurface(tree: ReplTree, state: ReplFixtureState): ReplSurface { + const collected = new Map([ + ["sidebar", []], + ["transcript", []], + ["inspection", []], + ["drawer", []], + ["footer", []], + ]); + + for (const cell of tree.frame().cells) { + const key = tree.keyOf(cell.node); + if (key === undefined) { + continue; + } + const placed = regionOf(key); + if (placed === undefined) { + continue; + } + collected.get(placed.region)?.push({ + node: cell.node, + text: cell.cell, + ...(placed.targetable ? { targetable: true } : {}), + }); + } + + const sidebar = collected.get("sidebar") ?? []; + const sessions = sidebar.filter((cell) => { + const key = tree.keyOf(cell.node); + return key !== undefined && (key.startsWith("sessions:") || key.startsWith("session:")); + }); + const entries = sidebar.filter((cell) => !sessions.includes(cell)); + const transcript = collected.get("transcript") ?? []; + const inspection = collected.get("inspection") ?? []; + + const routed: { readonly [route in ReplFixtureRoute]: readonly ReplSurfaceCell[] } = { + sessions, + entries, + transcript, + inspection, + }; + + return { + // The route picks one. Everything else stays mounted and stays off the + // narrow frame, which is what makes it unreachable there rather than hidden. + content: routed[state.route], + sessions, + entries, + transcript, + inspection, + drawer: collected.get("drawer") ?? [], + footer: collected.get("footer") ?? [], + history: state.history, + }; +} diff --git a/packages/cli/tests/repl-composition.test.ts b/packages/cli/tests/repl-composition.test.ts index 56e7d6933..bba6ccec1 100644 --- a/packages/cli/tests/repl-composition.test.ts +++ b/packages/cli/tests/repl-composition.test.ts @@ -93,7 +93,7 @@ function panel(journal: Journal): ReplComponent { node.focusable(); node.render(`[${label(node.input)}]`); node.claim((event: ReplInputEvent): Act | undefined => - event.kind === "pointer" || event.key === "Enter" + event.kind === "pointer" || (event.kind === "key" && event.key === "Enter") ? { kind: "activate", from: node.key } : undefined, ); @@ -144,7 +144,7 @@ function group(journal: Journal): ReplComponent { journal.push(`mount:${node.key}`); node.render(`{${label(node.input)}}`); node.claim((event: ReplInputEvent): Act | undefined => - event.kind === "pointer" || event.key === "Enter" + event.kind === "pointer" || (event.kind === "key" && event.key === "Enter") ? { kind: "submit", from: node.key } : undefined, ); @@ -161,7 +161,7 @@ function drawer(journal: Journal): ReplComponent { journal.push(`mount:${node.key}`); node.render(`|${label(node.input)}|`); node.claim((event: ReplInputEvent): Act | undefined => - event.kind === "pointer" || event.key === "Escape" + event.kind === "pointer" || (event.kind === "key" && event.key === "Escape") ? { kind: "close-drawer", from: node.key } : undefined, ); diff --git a/packages/cli/tests/repl-terminal.test.ts b/packages/cli/tests/repl-terminal.test.ts new file mode 100644 index 000000000..458cf4c50 --- /dev/null +++ b/packages/cli/tests/repl-terminal.test.ts @@ -0,0 +1,1550 @@ +/** + * The terminal platform (#848 F1, H1, H2). + * + * One real Freedom tree, one real layout engine, and a terminal the test owns + * completely. What is under test is the platform Slice E sits on: that a + * described screen lays out the same way every time at four sizes, that what + * was rendered is what a pointer resolves against, that presentation time has + * one owner, and that every way out of the terminal gives it back. + * + * The terminal is injected through the contextual Api, so nothing here reads + * `Deno`, `process` or a platform name, and the same corpus runs under Deno, + * Node and Bun. The runtime adapters are constructed but never installed — that + * is Slice E's turn. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, scoped, sleep, spawn, suspend, withResolvers } from "effection"; + +import { HISTORY_ROWS, layout, NARROW, profileFor } from "../src/repl/layout.ts"; +import type { ReplBounds, ReplRegion, ReplSemanticFrame, ReplSurface } from "../src/repl/layout.ts"; +import { resolvePointer, snapshotRender, useReplRenderer } from "../src/repl/renderer.ts"; +import type { ReplRendered, ReplRenderSnapshot } from "../src/repl/renderer.ts"; +import { nearestCommonAncestor, ReplClock, useReplFrames } from "../src/repl/frame.ts"; +import type { ReplFrameSubscription } from "../src/repl/frame.ts"; +import { replModes, useReplScreen } from "../src/repl/screen.ts"; +import type { ReplScreenEvent } from "../src/repl/screen.ts"; +import { installReplTerminal } from "../src/repl/terminal-host.ts"; +import type { ReplTerminalCapabilities } from "../src/repl/terminal-host.ts"; +import type { ReplTerminalSize } from "../src/repl/terminal.ts"; +import { useReplTree } from "../src/repl/reconcile.ts"; +import type { ReplDispatched, ReplTree } from "../src/repl/reconcile.ts"; +import type { ReplInputEvent } from "../src/repl/description.ts"; +import { + EMPTY, + fixtureDescriptions, + fixtureSurface, + type ReplFixtureState, + type Surfaced, +} from "./fixtures/repl/surface.ts"; + +/** A screen with something on it, so placement has something to place. */ +const FILLED: ReplFixtureState = { + route: "entries", + sessions: ["kf39sla2"], + entries: ["entry-1"], + scopes: ["entry-1/component", "entry-1/generated"], + transcript: ["About to evaluate: the plan", "Decision: approve"], + bindings: [ + { name: "plan", value: "ship it" }, + { name: "response", value: "approve" }, + ], + history: [ + { marker: "yield:__root__:1", label: "root 1" }, + { marker: "close:__root__", label: "root close" }, + ], + drawer: undefined, + draft: "", +}; + +/** Let every task that is ready take its turn. */ +function* settled(): Operation { + yield* sleep(0); + yield* sleep(0); + yield* sleep(0); +} + +function bounds(frame: ReplSemanticFrame, region: ReplRegion): ReplBounds | undefined { + return frame.regions.find((placed) => placed.region === region)?.bounds; +} + +function regions(frame: ReplSemanticFrame): ReplRegion[] { + return frame.regions.map((placed) => placed.region); +} + +function textsIn(frame: ReplSemanticFrame, region: ReplRegion): string[] { + return frame.cells.filter((cell) => cell.region === region).map((cell) => cell.text); +} + +function committed(outcome: { ok: boolean; error?: Error }): void { + if (!outcome.ok) { + throw outcome.error; + } +} + +/** What one dispatch produced, or a failure naming what came back instead. */ +function acted(outcome: { ok: boolean; value?: unknown; error?: Error }): ReplDispatched { + if (!outcome.ok) { + throw outcome.error; + } + const value = outcome.value; + if (typeof value !== "object" || value === null || !("outcome" in value)) { + throw new Error("a dispatch answers with a closed outcome"); + } + const outcomeValue = value.outcome; + if (outcomeValue === "action" && "action" in value && "ancestry" in value) { + const action = value.action; + const ancestry = value.ancestry; + if (isSurfaced(action) && Array.isArray(ancestry)) { + return { outcome: "action", action, ancestry: ancestry.map(String) }; + } + } + if (outcomeValue === "focus" && "focused" in value) { + const focused = value.focused; + return { outcome: "focus", focused: typeof focused === "string" ? focused : undefined }; + } + if (outcomeValue === "dropped" && "reason" in value && typeof value.reason === "string") { + return { outcome: "dropped", reason: value.reason }; + } + throw new Error( + `a dispatch answered with something this test does not recognize: ${outcomeValue}`, + ); +} + +/** Whether a value is one of the fixture's actions, decided by parsing it. */ +function isSurfaced(value: unknown): value is Surfaced { + if (typeof value !== "object" || value === null || !("kind" in value)) { + return false; + } + const kind = value.kind; + return ( + kind === "select-session" || + kind === "select-entry" || + kind === "select-scope" || + kind === "select-marker" || + kind === "close-drawer" || + kind === "submit" || + kind === "insert" || + kind === "erase" + ); +} + +/** Mount one fixture state and hand back the tree and its surface. */ +function* mounted( + state: ReplFixtureState, +): Operation<{ tree: ReplTree; surface: ReplSurface }> { + const tree = yield* useReplTree(); + committed(yield* tree.apply(fixtureDescriptions(state))); + return { tree, surface: fixtureSurface(tree, state) }; +} + +/** A render input for one laid-out frame and one mounted tree. */ +function input(frame: ReplSemanticFrame, tree: ReplTree): ReplRenderSnapshot { + return snapshotRender({ + frame, + tree: tree.frame().id, + mounted: tree.mounted(), + deltaTime: 0, + pointer: undefined, + }); +} + +/** Render one frame, failing the test if the renderer refused it. */ +function* drawn( + renderer: { + render( + snapshot: ReplRenderSnapshot, + ): Operation<{ ok: boolean; value?: ReplRendered; error?: Error }>; + }, + snapshot: ReplRenderSnapshot, +): Operation { + const outcome = yield* renderer.render(snapshot); + if (!outcome.ok || outcome.value === undefined) { + throw outcome.error ?? new Error("the renderer refused a frame this test expects it to draw"); + } + return outcome.value; +} + +describe("REPL terminal: responsive semantic frames", () => { + it("F1: a wide frame carries the sidebar, transcript, inspection and fixed footer", function* () { + const { tree, surface } = yield* mounted(FILLED); + const frame = layout({ columns: 160, rows: 36 }, surface); + + expect(frame.profile).toBe("wide"); + expect(regions(frame)).toEqual(["sidebar", "transcript", "inspection", "footer"]); + expect(bounds(frame, "sidebar")).toEqual({ x: 0, y: 0, width: 32, height: 29 }); + expect(bounds(frame, "transcript")).toEqual({ x: 32, y: 0, width: 92, height: 29 }); + expect(bounds(frame, "inspection")).toEqual({ x: 124, y: 0, width: 36, height: 29 }); + // Full width and pinned to the bottom, at every size. + expect(bounds(frame, "footer")).toEqual({ x: 0, y: 29, width: 160, height: 7 }); + expect(frame.historyRows).toHaveLength(HISTORY_ROWS); + expect(frame.refusal).toBeUndefined(); + + expect(textsIn(frame, "sidebar")).toEqual([ + "Sessions", + "kf39sla2", + "Entries", + "entry-1", + "entry-1/component", + "entry-1/generated", + ]); + expect(textsIn(frame, "transcript")).toEqual([ + "About to evaluate: the plan", + "Decision: approve", + ]); + expect(textsIn(frame, "inspection")).toEqual(["plan = ship it", "response = approve"]); + expect(textsIn(frame, "footer")).toEqual(["root 1", "root close", "> "]); + expect(tree.mounted()).toHaveLength(frame.cells.length); + }); + + it("F1: a medium frame keeps every region, narrower", function* () { + const { surface } = yield* mounted(FILLED); + const frame = layout({ columns: 120, rows: 30 }, surface); + + expect(frame.profile).toBe("medium"); + expect(regions(frame)).toEqual(["sidebar", "transcript", "inspection", "footer"]); + expect(bounds(frame, "sidebar")).toEqual({ x: 0, y: 0, width: 28, height: 23 }); + expect(bounds(frame, "transcript")).toEqual({ x: 28, y: 0, width: 64, height: 23 }); + expect(bounds(frame, "inspection")).toEqual({ x: 92, y: 0, width: 28, height: 23 }); + expect(bounds(frame, "footer")).toEqual({ x: 0, y: 23, width: 120, height: 7 }); + expect(frame.historyRows).toHaveLength(HISTORY_ROWS); + }); + + it("F1: a narrow frame routes one content surface and keeps the drawer and footer", function* () { + const { surface } = yield* mounted({ ...FILLED, drawer: "Binding: plan" }); + const frame = layout(NARROW, surface); + + expect(frame.profile).toBe("narrow"); + expect(regions(frame)).toEqual(["content", "drawer", "footer"]); + expect(bounds(frame, "content")).toEqual({ x: 0, y: 0, width: 72, height: 13 }); + // The same footer contract as the wide frame: full width, five History rows. + expect(bounds(frame, "footer")).toEqual({ x: 0, y: 13, width: 72, height: 7 }); + expect(frame.historyRows).toHaveLength(HISTORY_ROWS); + expect(bounds(frame, "drawer")).toEqual({ x: 9, y: 1, width: 54, height: 11 }); + expect(textsIn(frame, "drawer")).toEqual(["Binding: plan", "Close"]); + // One surface, the routed one, and nothing from the others. + expect(textsIn(frame, "content")).toEqual([ + "Entries", + "entry-1", + "entry-1/component", + "entry-1/generated", + ]); + expect(textsIn(frame, "content")).not.toContain("kf39sla2"); + expect(textsIn(frame, "content")).not.toContain("Decision: approve"); + }); + + it("F1: narrow shows the routed surface, and no other surface is reachable", function* () { + const routes = [ + { route: "sessions", shown: ["Sessions", "kf39sla2"], hidden: "entry-1" }, + { + route: "transcript", + shown: ["About to evaluate: the plan", "Decision: approve"], + hidden: "kf39sla2", + }, + ] as const; + + for (const { route, shown, hidden } of routes) { + const state: ReplFixtureState = { ...FILLED, route }; + const { tree, surface } = yield* mounted(state); + const frame = layout(NARROW, surface); + const renderer = yield* useReplRenderer(NARROW); + const rendered = yield* drawn(renderer, input(frame, tree)); + + expect(textsIn(frame, "content")).toEqual(shown); + + // The other surfaces are still mounted — routing is presentation — and + // that is exactly why the proof has to be that they are not in the frame + // and not in its map. + const inactive = tree.mounted().filter((id) => { + const key = tree.keyOf(id); + return key !== undefined && key.endsWith(hidden); + }); + expect(inactive.length).toBeGreaterThan(0); + for (const node of inactive) { + expect(frame.cells.map((cell) => cell.node)).not.toContain(node); + expect(rendered.map.targets.map((target) => target.node)).not.toContain(node); + expect(rendered.map.nodeOf(`content:${node}`)).toBeUndefined(); + } + + // And nowhere a pointer can land reaches one of them. + for (let row = 0; row < NARROW.rows; row += 1) { + for (let column = 0; column < NARROW.columns; column += 8) { + const pointer = resolvePointer(rendered, { column, row }); + if (pointer !== undefined) { + expect(inactive).not.toContain(pointer.target); + } + } + } + } + }); + + it("F1: below the minimum the frame is an explicit refusal with nothing targetable", function* () { + const { surface } = yield* mounted(FILLED); + + for (const size of [ + { columns: 71, rows: 20 }, + { columns: 72, rows: 19 }, + { columns: 40, rows: 10 }, + ]) { + const frame = layout(size, surface); + expect(profileFor(size)).toBe("too-small"); + expect(frame.profile).toBe("too-small"); + expect(frame.refusal).toContain("at least 72x20"); + expect(frame.refusal).toContain(`${size.columns}x${size.rows}`); + expect(regions(frame)).toEqual(["refusal"]); + // A control that is not in the frame cannot be in its target map either. + expect(frame.cells).toEqual([]); + expect(frame.historyRows).toHaveLength(HISTORY_ROWS); + } + }); + + it("F1: Sessions says so when there is nothing in it", function* () { + const { surface } = yield* mounted(EMPTY); + const frame = layout({ columns: 160, rows: 36 }, surface); + + expect(textsIn(frame, "sidebar")).toEqual(["Sessions: none yet", "Entries"]); + expect(textsIn(frame, "transcript")).toEqual([]); + expect(frame.refusal).toBeUndefined(); + }); + + it("F1: compact geometry groups marker labels and keeps every marker's identity", function* () { + const history = Array.from({ length: 40 }, (_unused, index) => ({ + marker: `yield:entry-1:${index}`, + label: `entry-1 yield ${index}`, + })); + const { surface } = yield* mounted({ ...FILLED, history }); + + const wide = layout({ columns: 160, rows: 36 }, surface); + const narrow = layout(NARROW, surface); + + // Every marker survives at both sizes, once each, under its own name. + for (const frame of [wide, narrow]) { + expect(frame.markers.map((marker) => marker.marker)).toEqual( + history.map((one) => one.marker), + ); + expect(frame.historyRows).toHaveLength(HISTORY_ROWS); + } + // Wide has room for its own labels; narrow shares them. + expect(wide.markers.every((marker) => marker.grouped.length === 0)).toBe(true); + expect(narrow.markers.some((marker) => marker.grouped.length > 0)).toBe(true); + const shared = narrow.markers.filter((marker) => marker.label === narrow.markers[0].label); + expect(shared.length).toBeGreaterThan(1); + // A grouped marker names the others it shares a label with, so the identity + // of a compact position is still recoverable. + expect(narrow.markers[0].grouped).toEqual(shared.slice(1).map((marker) => marker.marker)); + expect(narrow.markers[0].label).toContain("+"); + }); + + it("F1: resizing through every profile keeps selection, nodes and action identity", function* () { + const { tree, surface } = yield* mounted(FILLED); + const before = tree.mounted(); + const focused = tree.focused(); + const frameId = tree.frame().id; + + const sizes = [ + { columns: 160, rows: 36 }, + { columns: 120, rows: 30 }, + NARROW, + { columns: 40, rows: 10 }, + ]; + const laid = sizes.map((size) => layout(size, surface)); + + expect(laid.map((frame) => frame.profile)).toEqual(["wide", "medium", "narrow", "too-small"]); + // Laying out is presentation: it reads the tree and tells it nothing. + expect(tree.mounted()).toEqual(before); + expect(tree.focused()).toBe(focused); + expect(tree.frame().id).toBe(frameId); + + // And the same control still means the same thing. + for (const _frame of laid) { + const outcome = acted(yield* tree.dispatch({ kind: "key", key: "Enter" })); + expect(outcome.outcome).toBe("action"); + if (outcome.outcome === "action") { + expect(outcome.action).toEqual({ kind: "submit" }); + } + } + }); + + it("F1: a pointer against a control the refusal hides reaches nothing", function* () { + const { tree, surface } = yield* mounted(FILLED); + const renderer = yield* useReplRenderer(NARROW); + + const visible = yield* drawn(renderer, input(layout(NARROW, surface), tree)); + const footer = visible.map.targets.find((target) => tree.keyOf(target.node) === "footer:input"); + expect(footer).toBeDefined(); + if (footer === undefined) { + throw new Error("the narrow frame draws the footer input"); + } + // The same place, now under a refusal. + const refused = yield* drawn(renderer, input(layout({ columns: 40, rows: 10 }, surface), tree)); + expect(refused.map.targets).toEqual([]); + expect( + resolvePointer(refused, { column: footer.bounds.x, row: footer.bounds.y }), + ).toBeUndefined(); + }); +}); + +/** A clock the test moves, so "no timer" is something it can observe. */ +interface TestClock { + /** How many waits were ever scheduled. */ + waits: number; + now: number; + install(): Operation; + /** Advance and release every wait outstanding right now. */ + tick(seconds: number): Operation; +} + +function controllableClock(): TestClock { + let pending: (() => void)[] = []; + const clock: TestClock = { + waits: 0, + now: 0, + install(): Operation { + return ReplClock.around( + { + // deno-lint-ignore require-yield + *now(): Operation { + return clock.now; + }, + *wait(): Operation { + clock.waits += 1; + const waiter = withResolvers(); + pending.push(() => waiter.resolve()); + yield* waiter.operation; + }, + }, + { at: "min" }, + ); + }, + *tick(seconds: number): Operation { + clock.now += seconds; + const releasing = pending; + pending = []; + for (const release of releasing) { + release(); + } + yield* settled(); + }, + }; + return clock; +} + +describe("REPL terminal: frame and renderer ownership", () => { + it("H1: a frame advances only once every subscriber has applied the last one", function* () { + const clock = controllableClock(); + yield* clock.install(); + const frames = yield* useReplFrames(); + + // One ancestor, two participants beneath it: one animation, one clock. + const first = yield* frames.subscribe({ + owner: "page", + participants: [ + ["left", "page", "root"], + ["right", "page", "root"], + ], + }); + const second = yield* frames.subscribe({ owner: "footer", participants: [["footer"]] }); + if (!first.ok) { + throw first.error; + } + if (!second.ok) { + throw second.error; + } + yield* settled(); + + expect(frames.demanded()).toBe(true); + expect(frames.published()).toBe(0); + expect(clock.waits).toBe(1); + + yield* clock.tick(1 / 60); + expect(frames.published()).toBe(1); + expect(frames.outstanding()).toBe(2); + // Nothing further is scheduled while anybody still owes this frame. + expect(clock.waits).toBe(1); + + const received = yield* first.value.next(); + expect(received.id).toBe(1); + expect(received.delta).toBe(0); + yield* settled(); + // Received, not applied: drawing with a timestamp suspends, and the stream + // has to wait for the drawing rather than for the handover. + expect(first.value.received()).toBe(1); + expect(first.value.applied()).toBe(0); + expect(frames.outstanding()).toBe(2); + expect(clock.waits).toBe(1); + + first.value.acknowledge(); + yield* settled(); + expect(first.value.applied()).toBe(1); + expect(frames.outstanding()).toBe(1); + expect(clock.waits).toBe(1); + + yield* second.value.next(); + second.value.acknowledge(); + yield* settled(); + expect(frames.outstanding()).toBe(0); + // Now, and only now. + expect(clock.waits).toBe(2); + + yield* clock.tick(1 / 30); + expect(frames.published()).toBe(2); + const later = yield* first.value.next(); + expect(later.id).toBe(2); + // Seconds, because that is what the layout engine's transitions take. + expect(later.delta).toBeCloseTo(1 / 30, 6); + }); + + it("H1: a subscriber holding a frame holds the whole stream until it applies it", function* () { + const clock = controllableClock(); + yield* clock.install(); + const frames = yield* useReplFrames(); + + const holding = yield* frames.subscribe({ owner: "page", participants: [["page"]] }); + const prompt = yield* frames.subscribe({ owner: "footer", participants: [["footer"]] }); + if (!holding.ok) { + throw holding.error; + } + if (!prompt.ok) { + throw prompt.error; + } + yield* settled(); + yield* clock.tick(1 / 60); + + // One receives its frame and holds it, as a subscriber does while it is + // laying out and drawing. + const held = yield* holding.value.next(); + expect(held.id).toBe(1); + // The other applies and says so. + yield* prompt.value.next(); + prompt.value.acknowledge(); + yield* settled(); + + // Still one frame, still one timer: the holder's work has not finished. + expect(frames.published()).toBe(1); + expect(frames.outstanding()).toBe(1); + expect(clock.waits).toBe(1); + // Releasing the clock changes nothing while the frame is unapplied. + yield* clock.tick(1 / 60); + expect(frames.published()).toBe(1); + expect(clock.waits).toBe(1); + + holding.value.acknowledge(); + yield* settled(); + expect(frames.outstanding()).toBe(0); + expect(clock.waits).toBe(2); + }); + + it("H1: cancelling a holder drops its demand without claiming it applied anything", function* () { + const clock = controllableClock(); + yield* clock.install(); + const frames = yield* useReplFrames(); + + const prompt = yield* frames.subscribe({ owner: "footer", participants: [["footer"]] }); + if (!prompt.ok) { + throw prompt.error; + } + + // The handle is read *after* the cancellation has fully unwound, not from + // inside a destructor: destructors run in reverse, so a reading registered + // by the holder would run before the subscription's own teardown and would + // miss anything that teardown did. + let handle: ReplFrameSubscription | undefined; + const holder = yield* spawn(function* (): Operation { + const held = yield* frames.subscribe({ owner: "page", participants: [["page"]] }); + if (!held.ok) { + throw held.error; + } + handle = held.value; + // Received, and then cancelled part-way through applying it. + yield* held.value.next(); + yield* suspend(); + }); + yield* settled(); + yield* clock.tick(1 / 60); + yield* settled(); + + yield* prompt.value.next(); + prompt.value.acknowledge(); + yield* settled(); + expect(frames.outstanding()).toBe(1); + expect(clock.waits).toBe(1); + + yield* holder.halt(); + yield* settled(); + + // Its demand is gone, so the stream moves; its acknowledgement never + // arrived, because it never finished the frame. + expect(handle?.received()).toBe(1); + expect(handle?.applied()).toBe(0); + expect(frames.outstanding()).toBe(0); + expect(clock.waits).toBe(2); + // The frame it was holding is not counted as drawn anywhere. + expect(frames.published()).toBe(1); + }); + + it("H1: no demand outlives a subscription, at any turn of its life", function* () { + // The same walk, for the other resource this slice publishes: whichever turn + // puts the subscriber in the demand set, the removal for it already exists. + for (let turns = 0; turns <= 6; turns += 1) { + const clock = controllableClock(); + yield* scoped(function* (): Operation { + yield* clock.install(); + const frames = yield* useReplFrames(); + + const task = yield* spawn(function* (): Operation { + const held = yield* frames.subscribe({ owner: "page", participants: [["page"]] }); + if (!held.ok) { + throw held.error; + } + yield* suspend(); + }); + for (let turn = 0; turn < turns; turn += 1) { + yield* sleep(0); + } + yield* task.halt(); + yield* settled(); + + expect(frames.demanded()).toBe(false); + expect(frames.outstanding()).toBe(0); + }); + } + }); + + it("H1: a settled tree schedules no timer at all", function* () { + const clock = controllableClock(); + yield* clock.install(); + const frames = yield* useReplFrames(); + + yield* settled(); + expect(frames.demanded()).toBe(false); + expect(clock.waits).toBe(0); + + const holder = yield* spawn(function* (): Operation { + const held = yield* frames.subscribe({ owner: "page", participants: [["page"]] }); + if (!held.ok) { + throw held.error; + } + yield* suspend(); + }); + yield* settled(); + expect(clock.waits).toBe(1); + + yield* holder.halt(); + yield* settled(); + expect(frames.demanded()).toBe(false); + yield* clock.tick(1); + yield* settled(); + // The released wait published nothing and scheduled nothing after it. + expect(frames.published()).toBe(0); + expect(clock.waits).toBe(1); + }); + + it("H1: a cross-component animation belongs to exactly its nearest common ancestor", function* () { + const clock = controllableClock(); + yield* clock.install(); + const frames = yield* useReplFrames(); + const participants = [ + ["left", "page", "root"], + ["right", "page", "root"], + ]; + + expect(nearestCommonAncestor(participants)).toBe("page"); + + const owned = yield* frames.subscribe({ owner: "page", participants }); + expect(owned.ok).toBe(true); + + // A participant cannot own it: its own lifetime is shorter than the + // animation's. + const byParticipant = yield* frames.subscribe({ owner: "left", participants }); + expect(byParticipant.ok).toBe(false); + // Nor can a grandparent: it would hold the clock for a subtree that stopped. + const byAncestor = yield* frames.subscribe({ owner: "root", participants }); + expect(byAncestor.ok).toBe(false); + if (byAncestor.ok) { + throw new Error("a distant ancestor must not own this animation"); + } + expect(byAncestor.error.name).toBe("ReplFrameOwnerError"); + expect(byAncestor.error.message).toContain("page"); + + yield* settled(); + // The two refusals registered no demand, so there is exactly one clock. + expect(clock.waits).toBe(1); + yield* clock.tick(1 / 60); + expect(frames.outstanding()).toBe(1); + }); + + it("H1: rendered bytes are copied out before the engine reuses its view", function* () { + const { tree, surface } = yield* mounted(FILLED); + const renderer = yield* useReplRenderer(NARROW); + + const narrow = yield* drawn(renderer, input(layout(NARROW, surface), tree)); + const held = narrow.output; + const witnessed = Array.from(held); + expect(witnessed.length).toBeGreaterThan(0); + + // A second render writes over the engine's output region, and a resize grows + // its memory, which detaches every view into the old buffer. + yield* drawn(renderer, input(layout({ columns: 120, rows: 30 }, surface), tree)); + renderer.resize({ columns: 160, rows: 36 }); + yield* drawn(renderer, input(layout({ columns: 160, rows: 36 }, surface), tree)); + + expect(Array.from(held)).toEqual(witnessed); + }); + + it("H1: what a caller does to bytes it holds cannot change a later render", function* () { + const { tree, surface } = yield* mounted(FILLED); + // The same three frames through two renderers. One caller scribbles over + // every result it is handed; the other leaves them alone. A renderer whose + // result aliased the engine's memory would have the scribbling corrupt the + // state the next frame is diffed against, and the two sequences would part. + const sequence = [ + layout(NARROW, surface), + layout({ columns: 120, rows: 30 }, surface), + layout(NARROW, surface), + ]; + + const tampered: number[][] = []; + const scribbling = yield* useReplRenderer(NARROW); + for (const frame of sequence) { + const rendered = yield* drawn(scribbling, input(frame, tree)); + tampered.push(Array.from(rendered.output)); + rendered.output.fill(0); + } + + const clean: number[][] = []; + const untouched = yield* useReplRenderer(NARROW); + for (const frame of sequence) { + const rendered = yield* drawn(untouched, input(frame, tree)); + clean.push(Array.from(rendered.output)); + } + + expect(tampered[0].length).toBeGreaterThan(0); + expect(tampered).toEqual(clean); + }); + + it("H1: a frame laid out before a removal draws only what is still mounted", function* () { + const { tree, surface } = yield* mounted(FILLED); + const renderer = yield* useReplRenderer({ columns: 160, rows: 36 }); + // Laid out while the row was there. + const frame = layout({ columns: 160, rows: 36 }, surface); + const going = tree.mounted().find((id) => tree.keyOf(id) === "scope:entry-1/generated"); + if (going === undefined) { + throw new Error("the generated scope row is mounted before the removal"); + } + expect(frame.cells.map((cell) => cell.node)).toContain(going); + + // And the tree moved on before anything was drawn. + committed(yield* tree.apply(fixtureDescriptions({ ...FILLED, scopes: ["entry-1/component"] }))); + expect(tree.mounted()).not.toContain(going); + + const rendered = yield* drawn(renderer, input(frame, tree)); + + // The mounted tree is the only source of renderable nodes: a cell naming a + // node that has gone is not drawn, and is therefore not a target either. + expect(rendered.map.targets.map((target) => target.node)).not.toContain(going); + expect(rendered.map.nodeOf(`sidebar:${going}`)).toBeUndefined(); + expect(rendered.map.targets.length).toBeGreaterThan(0); + }); + + it("H1: capacity replacement redraws the identical logical frame", function* () { + // Deliberately more than any of the four profiles produces: the engine's + // measurement arenas are sized from the dimensions it was built for, and + // what this proves is that a renderer built when the terminal was tiny + // recovers rather than refusing the frame the terminal now needs. + const bindings = Array.from({ length: 1000 }, (_unused, index) => ({ + name: `binding-${index}`, + value: `a fairly long recorded value for binding number ${index}`, + })); + const { tree, surface } = yield* mounted({ ...FILLED, bindings }); + const frame = layout({ columns: 160, rows: 1010 }, surface); + expect(frame.cells.length).toBeGreaterThan(900); + + const renderer = yield* useReplRenderer({ columns: 20, rows: 5 }); + const snapshot = input(frame, tree); + const rendered = yield* drawn(renderer, snapshot); + + expect(rendered.recovered).toBe(true); + // The same logical frame: the same tree, the same targets, the same + // geometry. Recovery is invisible above this line. + expect(rendered.tree).toBe(tree.frame().id); + expect(rendered.map.targets.length).toBeGreaterThan(0); + expect(renderer.last()).toEqual(snapshot); + + const repeated = yield* drawn(renderer, snapshot); + expect(repeated.recovered).toBe(false); + expect(repeated.map.targets.map((target) => target.id)).toEqual( + rendered.map.targets.map((target) => target.id), + ); + expect(tree.focused()).toBe(tree.focused()); + expect(tree.mounted()).toHaveLength(frame.cells.length); + }); +}); + +/** Everything the fake terminal was asked to do, and what it still holds. */ +interface TerminalLog { + /** Every raw-mode change, in order. */ + readonly raw: boolean[]; + /** Suspending writes that completed. */ + writes: number; + /** Unsuspending writes, which is how the final reset is written. */ + resets: number; + /** Resize listeners still attached. */ + listeners: number; + /** Byte sources still open. */ + readers: number; + /** Byte sources ever opened. */ + opened: number; + /** Whether a read is outstanding right now. */ + reading: boolean; + size: ReplTerminalSize; + /** When set, a suspending write never completes. */ + hold: boolean; + feed(bytes: Uint8Array): void; + end(): void; + resized(size: ReplTerminalSize): void; +} + +/** A terminal the test owns completely, and a host that installs it. */ +function recordingTerminal(size: ReplTerminalSize = { columns: 160, rows: 36 }): { + log: TerminalLog; + install(): Operation; +} { + const queue: Uint8Array[] = []; + const watchers = new Set<() => void>(); + let waiting: ((result: IteratorResult) => void) | undefined; + let ended = false; + + const log: TerminalLog = { + raw: [], + writes: 0, + resets: 0, + listeners: 0, + readers: 0, + opened: 0, + reading: false, + size, + hold: false, + feed(bytes: Uint8Array): void { + const resolve = waiting; + if (resolve === undefined) { + queue.push(bytes); + return; + } + waiting = undefined; + log.reading = false; + resolve({ done: false, value: bytes }); + }, + end(): void { + ended = true; + const resolve = waiting; + if (resolve !== undefined) { + waiting = undefined; + log.reading = false; + resolve({ done: true, value: undefined }); + } + }, + resized(next: ReplTerminalSize): void { + log.size = next; + for (const watcher of watchers) { + watcher(); + } + }, + }; + + const host: ReplTerminalCapabilities = { + size(): ReplTerminalSize { + return log.size; + }, + write(_bytes: Uint8Array): Promise { + if (log.hold) { + // Never completes: what a terminal that has stopped accepting bytes + // looks like from here, and where a cancellation can land. + return new Promise(() => {}); + } + log.writes += 1; + return Promise.resolve(); + }, + writeNow(_bytes: Uint8Array): void { + log.resets += 1; + }, + setRaw(raw: boolean): void { + log.raw.push(raw); + }, + bytes(): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + log.readers += 1; + log.opened += 1; + return { + next(): Promise> { + const head = queue.shift(); + if (head !== undefined) { + return Promise.resolve({ done: false, value: head }); + } + if (ended) { + return Promise.resolve({ done: true, value: undefined }); + } + log.reading = true; + return new Promise>((resolve) => { + waiting = resolve; + }); + }, + return(): Promise> { + log.readers -= 1; + log.reading = false; + // Closing a source settles the read that was outstanding on it, + // the way a real reader does. A fake that abandoned the promise + // would leave the runtime holding one forever. + const resolve = waiting; + waiting = undefined; + resolve?.({ done: true, value: undefined }); + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + }, + onResize(listener: () => void): () => void { + watchers.add(listener); + log.listeners += 1; + return () => { + watchers.delete(listener); + log.listeners -= 1; + }; + }, + }; + + return { log, install: () => installReplTerminal(host) }; +} + +/** What every exit owes the terminal. */ +function givenBack(log: TerminalLog): void { + // Exactly once, however the owner ended. + expect(log.resets).toBe(1); + expect(log.raw[log.raw.length - 1]).toBe(false); + expect(log.listeners).toBe(0); + expect(log.readers).toBe(0); + expect(log.reading).toBe(false); +} + +const BYTES = new TextEncoder(); + +describe("REPL terminal: giving the terminal back", () => { + it("H2: a run that finishes stops the reader, drops every listener and resets once", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + const finished = yield* spawn(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + const first = yield* events.next(); + expect(first.done).toBe(false); + if (first.done !== true) { + expect(first.value.kind).toBe("resize"); + } + // The reader is a task, and a task starts a turn after it is spawned. + yield* settled(); + expect(terminal.log.raw).toEqual([true]); + expect(terminal.log.opened).toBe(1); + expect(terminal.log.resets).toBe(0); + }); + + yield* finished; + yield* settled(); + givenBack(terminal.log); + expect(terminal.log.opened).toBe(1); + }); + + it("H2: a refusal restores everything a success would have", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + const refusing = yield* spawn(function* (): Operation { + yield* useReplScreen(); + yield* settled(); + // The owner decided it cannot go on. That is an outcome, not an exception, + // and it owes the terminal exactly what any other outcome does. + return new Error("this REPL needs a terminal at least 72x20"); + }); + + const refusal = yield* refusing; + expect(refusal.message).toContain("72x20"); + yield* settled(); + givenBack(terminal.log); + }); + + it("H2: a failure restores everything before it propagates", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + let raised: Error | undefined; + try { + // Its own scope, not a spawned task: a task's failure raises into its + // owner, and what this test needs is the failure delivered to the caller + // with the screen's own scope already unwound. + yield* scoped(function* (): Operation { + yield* useReplScreen(); + yield* settled(); + throw new Error("the renderer could not draw this frame"); + }); + } catch (error) { + raised = error instanceof Error ? error : new Error(String(error)); + } + expect(raised?.message).toContain("could not draw"); + yield* settled(); + givenBack(terminal.log); + }); + + it("H2: a cancellation restores everything, including a reader mid-read", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + const task = yield* spawn(function* (): Operation { + yield* useReplScreen(); + yield* suspend(); + }); + yield* settled(); + // The reader is blocked on bytes that will never come, which is the state a + // person pressing nothing leaves it in. + expect(terminal.log.reading).toBe(true); + expect(terminal.log.readers).toBe(1); + + yield* task.halt(); + yield* settled(); + givenBack(terminal.log); + }); + + it("H2: end of input is a lifecycle outcome, and shuts down like the others", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + const seen: ReplScreenEvent["kind"][] = []; + const reading = yield* spawn(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + terminal.log.feed(BYTES.encode("\r")); + terminal.log.end(); + while (true) { + const next = yield* events.next(); + if (next.done === true) { + return; + } + seen.push(next.value.kind); + if (next.value.kind === "eof") { + // The owner shuts down on end of input; it is not a key anybody claims. + return; + } + } + }); + + yield* reading; + expect(seen).toEqual(["resize", "input", "eof"]); + yield* settled(); + givenBack(terminal.log); + }); + + it("H2: a cancellation during registration leaves no half-taken terminal", function* () { + const terminal = recordingTerminal(); + terminal.log.hold = true; + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + const task = yield* spawn(function* (): Operation { + yield* useReplScreen(); + yield* suspend(); + }); + yield* settled(); + // Raw mode was taken; the mode sequence never landed. + expect(terminal.log.raw).toEqual([true]); + expect(terminal.log.writes).toBe(0); + expect(terminal.log.opened).toBe(0); + + yield* task.halt(); + yield* settled(); + // Restored anyway, because the undo was registered before the do. + givenBack(terminal.log); + }); + + it("H2: no reader and no resize listener outlives a cancellation, at any turn", function* () { + // The mode write is the one window the other cancellation case can hold, and + // it stops short of the reader and the listener. So this walks the owner's + // whole life a turn at a time and cancels at each of them: whichever turn + // opens the byte source or attaches the resize listener, the release for it + // was already registered, so nothing is left behind. + for (let turns = 0; turns <= 8; turns += 1) { + const terminal = recordingTerminal(); + yield* scoped(function* (): Operation { + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + const task = yield* spawn(function* (): Operation { + yield* useReplScreen(); + yield* suspend(); + }); + for (let turn = 0; turn < turns; turn += 1) { + yield* sleep(0); + } + yield* task.halt(); + yield* settled(); + + expect(terminal.log.readers).toBe(0); + expect(terminal.log.listeners).toBe(0); + // At most one reset, and exactly one once anything at all was taken. + expect(terminal.log.resets).toBeLessThanOrEqual(1); + if (terminal.log.raw.includes(true)) { + expect(terminal.log.resets).toBe(1); + expect(terminal.log.raw[terminal.log.raw.length - 1]).toBe(false); + } + }); + } + }); + + it("H2: nothing survives its owner, even the modes it never finished applying", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + const modes = replModes(); + expect(modes.apply.length).toBeGreaterThan(0); + expect(modes.revert.length).toBeGreaterThan(0); + + const task = yield* spawn(function* (): Operation { + yield* useReplScreen(); + yield* suspend(); + }); + yield* settled(); + yield* task.halt(); + yield* settled(); + + // Halting twice must not reset twice: the reset is the terminal's, and it + // happens once. + yield* task.halt(); + yield* settled(); + givenBack(terminal.log); + }); +}); + +/** Read the next event the screen reports, failing if the stream ended. */ +function* reported(events: { + next(): Operation>; +}): Operation { + const next = yield* events.next(); + if (next.done === true) { + throw new Error("the screen ended while this test was waiting for an event"); + } + return next.value; +} + +/** + * Everything the screen reports within a bounded number of turns. + * + * Bounded on purpose: a test that blocked on the next event could only fail by + * hanging, and a hang names nothing. Draining lets the assertion be the exact + * sequence, so a missing event is a diff rather than a timeout. + */ +function* drained(events: { + next(): Operation>; +}): Operation { + const seen: ReplScreenEvent[] = []; + const collector = yield* spawn(function* (): Operation { + while (true) { + const next = yield* events.next(); + if (next.done === true) { + return; + } + seen.push(next.value); + } + }); + for (let turn = 0; turn < 12; turn += 1) { + yield* sleep(0); + } + yield* collector.halt(); + return seen; +} + +/** The normalized events out of a drained sequence, resizes and all else aside. */ +function normalizedFrom(reported: readonly ReplScreenEvent[]): ReplInputEvent[] { + const events: ReplInputEvent[] = []; + for (const one of reported) { + if (one.kind === "input") { + events.push(one.event); + } + } + return events; +} + +describe("REPL terminal: what the host normalizes, and what it refuses to guess", () => { + it("I1: a key becomes a normalized event and a mouse press becomes a position", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + yield* scoped(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + expect((yield* reported(events)).kind).toBe("resize"); + + terminal.log.feed(BYTES.encode("\r")); + expect(yield* reported(events)).toEqual({ + kind: "input", + event: { kind: "key", key: "Enter" }, + }); + + terminal.log.feed(BYTES.encode("\t")); + expect(yield* reported(events)).toEqual({ + kind: "input", + event: { kind: "key", key: "Tab" }, + }); + + // Shift-Tab, which travels the same path rather than through a shortcut of + // the host's own. + terminal.log.feed(BYTES.encode("\x1b[Z")); + expect(yield* reported(events)).toEqual({ + kind: "input", + event: { kind: "key", key: "Backtab" }, + }); + + // A press is a column and a row. Which node is there is the frame's answer. + // The protocol counts from one and the frame counts from zero. + terminal.log.feed(BYTES.encode("\x1b[<0;5;3M")); + const pointer = yield* reported(events); + expect(pointer.kind).toBe("pointer"); + if (pointer.kind === "pointer") { + expect(pointer.at).toEqual({ column: 4, row: 2 }); + } + + // A key nothing has a meaning for is dropped rather than invented into one. + terminal.log.feed(BYTES.encode("\x1b[A")); + terminal.log.feed(BYTES.encode("\r")); + expect(yield* reported(events)).toEqual({ + kind: "input", + event: { kind: "key", key: "Enter" }, + }); + }); + }); + + it("I1: typed and pasted text arrives as text, in the order the terminal sent it", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + yield* scoped(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + + // Real bytes, through the real decoder: ASCII, a shifted capital, a space, + // an accented character and two multi-byte ones; then a pasted newline, + // which the terminal sends as LF and the decoder reports as Control-J with + // no text of its own; then editing and submission. + terminal.log.feed(BYTES.encode("aA é漢字")); + terminal.log.feed(new Uint8Array([0x0a])); + terminal.log.feed(BYTES.encode("b")); + terminal.log.feed(new Uint8Array([0x7f])); + terminal.log.feed(BYTES.encode("\r")); + + expect(normalizedFrom(yield* drained(events))).toEqual([ + { kind: "text", text: "a" }, + { kind: "text", text: "A" }, + { kind: "text", text: " " }, + { kind: "text", text: "é" }, + { kind: "text", text: "漢" }, + { kind: "text", text: "字" }, + { kind: "text", text: "\n" }, + { kind: "text", text: "b" }, + { kind: "key", key: "Backspace" }, + { kind: "key", key: "Enter" }, + ]); + }); + }); + + it("I1: a chord types nothing, whatever letter it was pressed with", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + yield* scoped(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + + // Every one of these decodes to a letter the person did not ask to type: + // Control-C is `c`, Alt-a carries the text `a`, Control-H is `h`, and F5 is + // a key with no meaning here at all. + terminal.log.feed(new Uint8Array([0x03])); + terminal.log.feed(BYTES.encode("\x1ba")); + terminal.log.feed(new Uint8Array([0x08])); + terminal.log.feed(BYTES.encode("\x1b[15~")); + // A sentinel behind them, so the assertion is the whole sequence and not + // just the absence of something. + terminal.log.feed(BYTES.encode("z")); + + expect(normalizedFrom(yield* drained(events))).toEqual([{ kind: "text", text: "z" }]); + }); + }); + + it("I1: text and Backspace reach the draft through the same ancestry as a key", function* () { + const { tree } = yield* mounted(FILLED); + + // Focus is on the draft line, which is where typing goes. + const draft = tree.mounted().find((id) => tree.keyOf(id) === "footer:input"); + expect(tree.focused()).toBe(draft); + + const typed = acted(yield* tree.dispatch({ kind: "text", text: "é" })); + expect(typed.outcome).toBe("action"); + if (typed.outcome === "action") { + expect(typed.action).toEqual({ kind: "insert", text: "é" }); + expect(typed.ancestry[0]).toBe(draft); + } + + const pasted = acted(yield* tree.dispatch({ kind: "text", text: "\n" })); + if (pasted.outcome === "action") { + expect(pasted.action).toEqual({ kind: "insert", text: "\n" }); + } + + const erased = acted(yield* tree.dispatch({ kind: "key", key: "Backspace" })); + if (erased.outcome === "action") { + expect(erased.action).toEqual({ kind: "erase" }); + } + + // And the named keys still mean what they meant. + const submitted = acted(yield* tree.dispatch({ kind: "key", key: "Enter" })); + if (submitted.outcome === "action") { + expect(submitted.action).toEqual({ kind: "submit" }); + } + const moved = acted(yield* tree.dispatch({ kind: "key", key: "Tab" })); + expect(moved.outcome).toBe("focus"); + }); + + it("I1: text a row does not claim passes it on the way up", function* () { + const { tree } = yield* mounted({ ...FILLED, drawer: "Binding: plan" }); + + // Focus is inside the drawer, on a row that has no use for text. + const closer = tree.mounted().find((id) => tree.keyOf(id) === "drawer:close"); + expect(tree.focused()).toBe(closer); + + // Nothing above it claims text either, so this is an explicit failure rather + // than a keystroke silently swallowed. + const outcome = yield* tree.dispatch({ kind: "text", text: "a" }); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error.name).toBe("ReplUnownedEventError"); + } + + // Escape still closes it. + const escaped = acted(yield* tree.dispatch({ kind: "key", key: "Escape" })); + if (escaped.outcome === "action") { + expect(escaped.action).toEqual({ kind: "close-drawer" }); + } + }); + + it("I1: a lone Escape waits for the decoder's latency and then arrives", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + yield* scoped(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + expect((yield* reported(events)).kind).toBe("resize"); + + terminal.log.feed(BYTES.encode("\x1b")); + yield* settled(); + // Undecided: it could still be the start of a sequence. + expect(clock.waits).toBe(1); + + yield* clock.tick(0.025); + expect(yield* reported(events)).toEqual({ + kind: "input", + event: { kind: "key", key: "Escape" }, + }); + }); + }); + + it("I1: bytes that follow a pending Escape are rescanned with it, not split", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + yield* scoped(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + expect((yield* reported(events)).kind).toBe("resize"); + + // An escape sequence arriving in two pieces, which is what a slow link does. + terminal.log.feed(BYTES.encode("\x1b")); + yield* settled(); + expect(clock.waits).toBe(1); + terminal.log.feed(BYTES.encode("[Z")); + + // Backtab, and no Escape before it: the held prefix was rescanned with what + // followed rather than flushed as a keypress nobody made. + expect(yield* reported(events)).toEqual({ + kind: "input", + event: { kind: "key", key: "Backtab" }, + }); + }); + }); + + it("I1: a resize is a size the host reports, not a key anybody claims", function* () { + const terminal = recordingTerminal(); + yield* terminal.install(); + const clock = controllableClock(); + yield* clock.install(); + + yield* scoped(function* (): Operation { + const screen = yield* useReplScreen(); + const events = yield* screen.events(); + expect(yield* reported(events)).toEqual({ kind: "resize", size: { columns: 160, rows: 36 } }); + + terminal.log.resized({ columns: 72, rows: 20 }); + expect(yield* reported(events)).toEqual({ kind: "resize", size: { columns: 72, rows: 20 } }); + expect(yield* screen.size()).toEqual({ columns: 72, rows: 20 }); + + // The same size again is the same frame again, and says nothing new. + terminal.log.resized({ columns: 72, rows: 20 }); + terminal.log.feed(BYTES.encode("\r")); + expect(yield* reported(events)).toEqual({ + kind: "input", + event: { kind: "key", key: "Enter" }, + }); + }); + }); + + it("I1: a key and a click on one control produce one action", function* () { + const { tree, surface } = yield* mounted(FILLED); + const renderer = yield* useReplRenderer({ columns: 160, rows: 36 }); + const rendered = yield* drawn( + renderer, + input(layout({ columns: 160, rows: 36 }, surface), tree), + ); + + const byKey = acted(yield* tree.dispatch({ kind: "key", key: "Enter" })); + expect(byKey.outcome).toBe("action"); + + const control = rendered.map.targets.find( + (target) => tree.keyOf(target.node) === "footer:input", + ); + if (control === undefined) { + throw new Error("the footer input is drawn and targetable"); + } + const pointer = resolvePointer(rendered, { column: control.bounds.x, row: control.bounds.y }); + expect(pointer).toEqual({ kind: "pointer", target: control.node, frame: rendered.tree }); + if (pointer === undefined) { + throw new Error("a pointer inside a drawn control resolves to it"); + } + const byPointer = acted(yield* tree.dispatch(pointer)); + + expect(byPointer).toEqual(byKey); + }); + + it("I1: a pointer outside the open drawer reaches nothing", function* () { + const state = { ...FILLED, drawer: "Binding: plan" }; + const { tree, surface } = yield* mounted(state); + const renderer = yield* useReplRenderer({ columns: 160, rows: 36 }); + const rendered = yield* drawn( + renderer, + input(layout({ columns: 160, rows: 36 }, surface), tree), + ); + + const behind = rendered.map.targets.find( + (target) => tree.keyOf(target.node) === "session:kf39sla2", + ); + if (behind === undefined) { + throw new Error("the sidebar's session row is drawn and targetable"); + } + const pointer = resolvePointer(rendered, { column: behind.bounds.x, row: behind.bounds.y }); + if (pointer === undefined) { + throw new Error("a pointer inside a drawn control resolves to it"); + } + + const outcome = acted(yield* tree.dispatch(pointer)); + expect(outcome.outcome).toBe("dropped"); + if (outcome.outcome === "dropped") { + expect(outcome.reason).toContain("behind the open drawer"); + } + }); + + it("I1: a pointer resolved against a frame the tree has moved past reaches nothing", function* () { + const { tree, surface } = yield* mounted(FILLED); + const renderer = yield* useReplRenderer({ columns: 160, rows: 36 }); + const rendered = yield* drawn( + renderer, + input(layout({ columns: 160, rows: 36 }, surface), tree), + ); + + const control = rendered.map.targets.find( + (target) => tree.keyOf(target.node) === "entry:entry-1", + ); + if (control === undefined) { + throw new Error("the entry row is drawn and targetable"); + } + const pointer = resolvePointer(rendered, { column: control.bounds.x, row: control.bounds.y }); + if (pointer === undefined) { + throw new Error("a pointer inside a drawn control resolves to it"); + } + + // The screen moved on. + committed(yield* tree.apply(fixtureDescriptions({ ...FILLED, draft: "yes" }))); + expect(tree.frame().id).not.toBe(pointer.frame); + + const outcome = acted(yield* tree.dispatch(pointer)); + expect(outcome.outcome).toBe("dropped"); + if (outcome.outcome === "dropped") { + expect(outcome.reason).toContain("no longer drawn"); + } + }); + + it("I1: a pointer naming a node the tree removed reaches nothing", function* () { + const { tree, surface } = yield* mounted(FILLED); + const renderer = yield* useReplRenderer({ columns: 160, rows: 36 }); + const rendered = yield* drawn( + renderer, + input(layout({ columns: 160, rows: 36 }, surface), tree), + ); + + const removed = rendered.map.targets.find( + (target) => tree.keyOf(target.node) === "scope:entry-1/generated", + ); + if (removed === undefined) { + throw new Error("the generated scope row is drawn and targetable"); + } + + committed(yield* tree.apply(fixtureDescriptions({ ...FILLED, scopes: ["entry-1/component"] }))); + expect(tree.mounted()).not.toContain(removed.node); + + // Carrying the current frame's number, so what is under test is the node and + // not the frame: a pointer cannot reach something that is not there. + const outcome = acted( + yield* tree.dispatch({ kind: "pointer", target: removed.node, frame: tree.frame().id }), + ); + expect(outcome.outcome).toBe("dropped"); + if (outcome.outcome === "dropped") { + expect(outcome.reason).toContain("no longer holds"); + } + + // And redrawing does not offer it either. + const after = yield* drawn( + renderer, + input(layout({ columns: 160, rows: 36 }, fixtureSurface(tree, FILLED)), tree), + ); + expect(after.map.targets.map((target) => target.node)).not.toContain(removed.node); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc5691b9..3f3687d27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: packages/cli: dependencies: + '@bomb.sh/tty': + specifier: 0.9.0 + version: 0.9.0 '@effectionx/stream-helpers': specifier: 0.8.3 version: 0.8.3(effection@4.1.0) @@ -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 From 1aa84f6191dff5a48f1f931159802856be045e94 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 27 Sep 2026 16:30:44 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=20Remeasure=20test=20weights=20at?= =?UTF-8?q?=2005c912f2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on run 36319908505 at the accepted Slice D implementation head, and committed unchanged. Its floors are 12 deno, 7 node and 4 bun against installed counts of 15/10/5, so nothing the measurement says requires a shard count to move. --- test-weights.json | 2157 +++++++++++++++++++++++---------------------- 1 file changed, 1080 insertions(+), 1077 deletions(-) diff --git a/test-weights.json b/test-weights.json index e77cffc5c..bb32b4d5b 100644 --- a/test-weights.json +++ b/test-weights.json @@ -1,8 +1,8 @@ { "version": 1, "source": { - "commit": "1019ea528d1de23c51c55467317ce5def25e5344", - "runUrl": "https://github.com/taras/executable.md/actions/runs/36293682586", + "commit": "05c912f26682c7ddf1818403aa332f057675e1cb", + "runUrl": "https://github.com/taras/executable.md/actions/runs/36319908505", "attempt": 1, "runner": "ubuntu-latest", "deno": "deno 2.9.5 (stable, release, x86_64-unknown-linux-gnu)", @@ -11,1085 +11,1088 @@ }, "runtimes": { "deno": { - "packages/acp/tests/acceptance.test.ts": 3169, - "packages/acp/tests/acpx-checkpoint-meta.test.ts": 6815, - "packages/acp/tests/acpx-materialization.test.ts": 7495, - "packages/acp/tests/acpx-transient-env.test.ts": 1214, - "packages/acp/tests/adapter-materialization.test.ts": 50704, - "packages/acp/tests/adapter-protocol.test.ts": 39473, - "packages/acp/tests/codex-surfaces.test.ts": 1214, - "packages/acp/tests/loaded-copy.test.ts": 3149, - "packages/acp/tests/native-launch.test.ts": 3748, - "packages/acp/tests/permission-bridge.test.ts": 2752, - "packages/acp/tests/provider.test.ts": 3685, - "packages/acp/tests/serial-queue.test.ts": 1094, - "packages/acp/tests/session-key.test.ts": 2712, - "packages/acp/tests/session-route.test.ts": 3035, - "packages/acp/tests/terminal-screen.test.ts": 1140, - "packages/cli/tests/agent-adapters.test.ts": 3834, - "packages/cli/tests/agent-cli.test.ts": 26979, - "packages/cli/tests/agent-config.test.ts": 2484, - "packages/cli/tests/agent-options-cli.test.ts": 10818, - "packages/cli/tests/agent-options.test.ts": 2340, - "packages/cli/tests/agent-session-coordinator.test.ts": 2842, - "packages/cli/tests/cli-help.test.ts": 10193, - "packages/cli/tests/command.test.ts": 7673, - "packages/cli/tests/compiled-upgrade.test.ts": 7320, - "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 5694, - "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 95451, - "packages/cli/tests/document-suites/props/props-markdown.test.ts": 5355, - "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 7616, - "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 6254, - "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 9688, - "packages/cli/tests/evaluate-component.test.ts": 7891, - "packages/cli/tests/evaluate-workflow.test.ts": 3856, - "packages/cli/tests/fetch-cli.test.ts": 2528, - "packages/cli/tests/file-stream.test.ts": 1242, - "packages/cli/tests/github-zero-effect.test.ts": 4707, - "packages/cli/tests/inline-cli.test.ts": 21873, - "packages/cli/tests/launch-timeout.test.ts": 11118, - "packages/cli/tests/packaged-document.test.ts": 3582, - "packages/cli/tests/plan-args.test.ts": 1128, - "packages/cli/tests/plan-cli.test.ts": 71295, - "packages/cli/tests/plan-command-document.test.ts": 6998, - "packages/cli/tests/plan-component.test.ts": 8773, - "packages/cli/tests/plan-host-acts.test.ts": 3814, - "packages/cli/tests/plan.test.ts": 11119, - "packages/cli/tests/plugin-cli.test.ts": 27684, - "packages/cli/tests/plugin-host.test.ts": 4210, - "packages/cli/tests/plugin-modules.test.ts": 1067, - "packages/cli/tests/plugin-selection.test.ts": 1085, - "packages/cli/tests/process-retention.test.ts": 4061, - "packages/cli/tests/props-cli.test.ts": 20490, - "packages/cli/tests/props-schema.test.ts": 2853, - "packages/cli/tests/props-sources.test.ts": 1583, - "packages/cli/tests/pull-request-read-fork.test.ts": 5117, - "packages/cli/tests/repl-composition.test.ts": 1343, - "packages/cli/tests/repl-execution.test.ts": 3803, - "packages/cli/tests/repl-model.test.ts": 3191, - "packages/cli/tests/repl-route.test.ts": 3216, - "packages/cli/tests/run-composition-deno.test.ts": 5885, - "packages/cli/tests/run-composition-nested.test.ts": 15691, - "packages/cli/tests/run-composition.test.ts": 4767, - "packages/cli/tests/run-deadline.test.ts": 6166, - "packages/cli/tests/run-profile.test.ts": 3006, - "packages/cli/tests/run-timeouts.test.ts": 10022, - "packages/cli/tests/secret-detection-cli.test.ts": 12764, - "packages/cli/tests/service-document.test.ts": 3581, - "packages/cli/tests/service-host.test.ts": 3534, - "packages/cli/tests/session-launch-cli.test.ts": 7736, - "packages/cli/tests/stdin-cli.test.ts": 24407, - "packages/cli/tests/stdout-delivery.test.ts": 1031, - "packages/cli/tests/syntax-cli.test.ts": 23624, - "packages/cli/tests/targets-cli.test.ts": 18035, - "packages/cli/tests/test-root-profile.test.ts": 5164, - "packages/cli/tests/test-target.test.ts": 23252, - "packages/cli/tests/testing-activation.test.ts": 3051, - "packages/cli/tests/testing-execution-host.test.ts": 38621, - "packages/cli/tests/upgrade-cli.test.ts": 13145, - "packages/cli/tests/upgrade-command-document.test.ts": 4957, - "packages/cli/tests/upgrade-output.test.ts": 2988, - "packages/cli/tests/value-root.test.ts": 11364, - "packages/cli/tests/verbose-component.test.ts": 3365, - "packages/cli/tests/workflow-agent-sessions.test.ts": 13330, - "packages/cli/tests/workflow-agent.test.ts": 10656, - "packages/cli/tests/workflow-cli.test.ts": 75608, - "packages/cli/tests/workflow-crash.test.ts": 73495, - "packages/cli/tests/workflow-declaration.test.ts": 2954, - "packages/cli/tests/workflow-export-publication.test.ts": 12331, - "packages/cli/tests/workflow-fetch.test.ts": 5623, - "packages/cli/tests/workflow-fork.test.ts": 54119, - "packages/cli/tests/workflow-host.test.ts": 9719, - "packages/cli/tests/workflow-inspection.test.ts": 79159, - "packages/cli/tests/workflow-installation.test.ts": 4329, - "packages/cli/tests/workflow-lifecycle-control.test.ts": 14615, - "packages/cli/tests/workflow-retention.test.ts": 16426, - "packages/cli/tests/workflow-suspension.test.ts": 5619, - "packages/code-review-agent/tests/doctor.test.ts": 1014, - "packages/code-review-agent/tests/parse-diagnostics.test.ts": 1159, - "packages/code-review-agent/tests/parse-diff.test.ts": 1077, - "packages/code-review-agent/tests/parse-doctor.test.ts": 2657, - "packages/code-review-agent/tests/parse-oxlint.test.ts": 982, - "packages/code-review-agent/tests/policy.test.ts": 1023, - "packages/core/tests/agent-api.test.ts": 1089, - "packages/core/tests/agent-components.test.ts": 3386, - "packages/core/tests/agent-function-components.test.ts": 4052, - "packages/core/tests/agent-session-launch.test.ts": 4152, - "packages/core/tests/agent-session-placement.test.ts": 1040, - "packages/core/tests/agent-session-use-loaded-copy.test.ts": 1048, - "packages/core/tests/agent-session-use.test.ts": 992, - "packages/core/tests/agent-stack.test.ts": 2690, - "packages/core/tests/answer-identity.test.ts": 2316, - "packages/core/tests/answers-component.test.ts": 3154, - "packages/core/tests/answers-expansion-recursion.test.ts": 2438, - "packages/core/tests/capture-errors.test.ts": 2617, - "packages/core/tests/capture-props.test.ts": 2517, - "packages/core/tests/cli-journal.test.ts": 7925, - "packages/core/tests/code-block-component.test.ts": 3302, - "packages/core/tests/compiler-boundary.test.ts": 2617, - "packages/core/tests/component-api.test.ts": 2275, - "packages/core/tests/component-registration.test.ts": 2896, - "packages/core/tests/component-returns.test.ts": 3236, - "packages/core/tests/component-schema-conformance.test.ts": 2608, - "packages/core/tests/config-api.test.ts": 2930, - "packages/core/tests/construct-error-observation.test.ts": 2689, - "packages/core/tests/contextual-cwd.test.ts": 3745, - "packages/core/tests/daemon-integration.test.ts": 3206, - "packages/core/tests/daemon.test.ts": 2369, - "packages/core/tests/declared-markdown-component.test.ts": 3554, - "packages/core/tests/document-output-api.test.ts": 983, - "packages/core/tests/document-target-execution.test.ts": 4285, - "packages/core/tests/document-targets.test.ts": 2510, - "packages/core/tests/document-validation.test.ts": 3395, - "packages/core/tests/documentation-index.test.ts": 2776, - "packages/core/tests/each.test.ts": 2610, - "packages/core/tests/elicit-component.test.ts": 2936, - "packages/core/tests/ephemeral-service.test.ts": 2757, - "packages/core/tests/eval-bindings.test.ts": 2395, - "packages/core/tests/eval-context.test.ts": 1356, - "packages/core/tests/eval-durable.test.ts": 2451, - "packages/core/tests/eval-error-mode.test.ts": 2768, - "packages/core/tests/eval-interpolate.test.ts": 955, - "packages/core/tests/eval-middleware.test.ts": 2559, - "packages/core/tests/eval-persist.test.ts": 2817, - "packages/core/tests/eval-return.test.ts": 2489, - "packages/core/tests/eval-scope.test.ts": 2489, - "packages/core/tests/eval-timeout.test.ts": 2477, - "packages/core/tests/eval-transform.test.ts": 2178, - "packages/core/tests/evaluate-component.test.ts": 3601, - "packages/core/tests/evaluate-loaded-copy.test.ts": 2547, - "packages/core/tests/evaluate-provider-lifetime.test.ts": 2438, - "packages/core/tests/evaluation-profile.test.ts": 2309, - "packages/core/tests/exec-timeout.test.ts": 2588, - "packages/core/tests/execute.test.ts": 2884, - "packages/core/tests/execution-protocol.test.ts": 3356, - "packages/core/tests/expand.test.ts": 2972, - "packages/core/tests/expansion-identity.test.ts": 2941, - "packages/core/tests/expansion-metadata.test.ts": 2439, - "packages/core/tests/expression-props.test.ts": 2859, - "packages/core/tests/fail-component.test.ts": 2732, - "packages/core/tests/failure-printing.test.ts": 2634, - "packages/core/tests/fatal-cause.test.ts": 2358, - "packages/core/tests/fetch-component.test.ts": 3421, - "packages/core/tests/file-component.test.ts": 4085, - "packages/core/tests/file-delete-component.test.ts": 2556, - "packages/core/tests/files-fatal.test.ts": 3148, - "packages/core/tests/foreground-exec.test.ts": 3422, - "packages/core/tests/frontmatter.test.ts": 2409, - "packages/core/tests/function-components.test.ts": 3054, - "packages/core/tests/generated-composition.test.ts": 3009, - "packages/core/tests/generated-xmd.test.ts": 4020, - "packages/core/tests/glob-api.test.ts": 1269, - "packages/core/tests/glob-component.test.ts": 3197, - "packages/core/tests/guarded-journal.test.ts": 2736, - "packages/core/tests/has-content.test.ts": 2494, - "packages/core/tests/heal.test.ts": 2519, - "packages/core/tests/if.test.ts": 2906, - "packages/core/tests/inline-root.test.ts": 2678, - "packages/core/tests/invocation-failures.test.ts": 2421, - "packages/core/tests/invocation-identity.test.ts": 2989, - "packages/core/tests/invocation-scope.test.ts": 2829, - "packages/core/tests/journal-source-position.test.ts": 2860, - "packages/core/tests/json-component.test.ts": 2884, - "packages/core/tests/json.test.ts": 2506, - "packages/core/tests/let.test.ts": 2870, - "packages/core/tests/loaded-copy-files.test.ts": 2736, - "packages/core/tests/loop.test.ts": 3443, - "packages/core/tests/named-slots.test.ts": 3244, - "packages/core/tests/output-error-mode.test.ts": 3330, - "packages/core/tests/output-normalize.test.ts": 1055, - "packages/core/tests/output-terminal.test.ts": 1115, - "packages/core/tests/parse-components.test.ts": 2779, - "packages/core/tests/plan-response.test.ts": 1113, - "packages/core/tests/plugin-api.test.ts": 2355, - "packages/core/tests/plugin.test.ts": 2544, - "packages/core/tests/props-binding.test.ts": 2752, - "packages/core/tests/protected-content.test.ts": 2634, - "packages/core/tests/registered-printed-errors.test.ts": 2648, - "packages/core/tests/replay-stream.test.ts": 1134, - "packages/core/tests/retain.test.ts": 2856, - "packages/core/tests/root-composition.test.ts": 2883, - "packages/core/tests/root-props.test.ts": 2908, - "packages/core/tests/root-provider.test.ts": 2815, - "packages/core/tests/sample-component.test.ts": 3499, - "packages/core/tests/scanner.test.ts": 2512, - "packages/core/tests/scope-local.test.ts": 1041, - "packages/core/tests/secret-detection.test.ts": 3461, - "packages/core/tests/secret-files.test.ts": 1370, - "packages/core/tests/secret-rules.test.ts": 1293, - "packages/core/tests/secret-scanner-baseline.test.ts": 1086, - "packages/core/tests/secret-scanner.test.ts": 1210, - "packages/core/tests/source-position.test.ts": 2478, - "packages/core/tests/state-ownership.test.ts": 2487, - "packages/core/tests/streaming-emission.test.ts": 2539, - "packages/core/tests/switch.test.ts": 2906, - "packages/core/tests/syntax-catalog.test.ts": 2870, - "packages/core/tests/syntax-component.test.ts": 3419, - "packages/core/tests/syntax-loaded-copy.test.ts": 2603, - "packages/core/tests/temp-dir.test.ts": 4836, - "packages/core/tests/temp-file-compiler.test.ts": 1409, - "packages/core/tests/text-interpolation.test.ts": 2412, - "packages/core/tests/try-content.test.ts": 2413, - "packages/core/tests/unused-in-diff.test.ts": 2690, - "packages/core/tests/validate.test.ts": 2628, - "packages/core/tests/validation-integration.test.ts": 2675, - "packages/core/tests/workflow-component-bundle.test.ts": 2810, - "packages/durable-streams/tests/context.test.ts": 1199, - "packages/durable-streams/tests/deterministic-id.test.ts": 1254, - "packages/durable-streams/tests/divergence-api.test.ts": 1248, - "packages/durable-streams/tests/divergence.test.ts": 1260, - "packages/durable-streams/tests/durable-each.test.ts": 1267, - "packages/durable-streams/tests/durable-position.test.ts": 1236, - "packages/durable-streams/tests/durable-run.test.ts": 1284, - "packages/durable-streams/tests/ephemeral.test.ts": 1252, - "packages/durable-streams/tests/fail-stop.test.ts": 1251, - "packages/durable-streams/tests/guard-stream.test.ts": 1283, - "packages/durable-streams/tests/live-coordinator.test.ts": 1256, - "packages/durable-streams/tests/parse.test.ts": 1263, - "packages/durable-streams/tests/replay-guard.test.ts": 1279, - "packages/durable-streams/tests/replay-index.test.ts": 1120, - "packages/durable-streams/tests/retained.test.ts": 1116, - "packages/durable-streams/tests/serialize-event.test.ts": 1214, - "packages/durable-streams/tests/smoke.test.ts": 1208, - "packages/durable-streams/tests/structured-concurrency.test.ts": 1298, - "packages/durable-streams/tests/terminal-boundary.test.ts": 1278, - "packages/durable-streams/tests/types.test.ts": 1005, - "packages/git/tests/ambient-authentication.test.ts": 21003, - "packages/git/tests/api-entrypoint.test.ts": 3322, - "packages/git/tests/credential-helper.test.ts": 4228, - "packages/git/tests/git-add-crash.test.ts": 4763, - "packages/git/tests/git-add-durability.test.ts": 20972, - "packages/git/tests/git-add.test.ts": 27364, - "packages/git/tests/git-commit-crash.test.ts": 4999, - "packages/git/tests/git-commit-durability.test.ts": 25818, - "packages/git/tests/git-commit.test.ts": 29919, - "packages/git/tests/git-host-effect.test.ts": 5375, - "packages/git/tests/git-push-crash.test.ts": 6427, - "packages/git/tests/git-push-durability.test.ts": 31602, - "packages/git/tests/git-push.test.ts": 41850, - "packages/git/tests/git-switch-crash.test.ts": 4407, - "packages/git/tests/git-switch-durability.test.ts": 14722, - "packages/git/tests/git-switch.test.ts": 15789, - "packages/git/tests/git.test.ts": 2693, - "packages/git/tests/github-activation.test.ts": 3559, - "packages/git/tests/github-issues.test.ts": 2772, - "packages/git/tests/github-pull-requests.test.ts": 2491, - "packages/git/tests/github-workflow-activation.test.ts": 3686, - "packages/git/tests/issue-markdown.test.ts": 6440, - "packages/git/tests/issue-records.test.ts": 2619, - "packages/git/tests/materialization.test.ts": 3044, - "packages/git/tests/module-partition.test.ts": 3000, - "packages/git/tests/plugin.test.ts": 2910, - "packages/git/tests/provider-neutrality.test.ts": 1977, - "packages/git/tests/public-entrypoint.test.ts": 6055, - "packages/git/tests/pull-request-crash.test.ts": 5072, - "packages/git/tests/pull-request-durability.test.ts": 29552, - "packages/git/tests/pull-request-read.test.ts": 4951, - "packages/git/tests/pull-request-records.test.ts": 2699, - "packages/git/tests/pull-request.test.ts": 54743, - "packages/git/tests/repository-components.test.ts": 7912, - "packages/git/tests/repository-control-plane.test.ts": 7089, - "packages/git/tests/repository-materialization.test.ts": 5185, - "packages/git/tests/repository-replay.test.ts": 10523, - "packages/git/tests/repository-storage.test.ts": 12922, - "packages/git/tests/run-composition-ambient.test.ts": 8589, - "packages/git/tests/run-composition-lazy.test.ts": 3480, - "packages/git/tests/run-composition-managed.test.ts": 10424, - "packages/git/tests/run-composition-remote.test.ts": 11300, - "packages/git/tests/selection-authentication.test.ts": 1134, - "packages/git/tests/worktree-replay.test.ts": 4166, - "packages/runtime/tests/agent-session-coordinator.test.ts": 1349, - "packages/runtime/tests/duration.test.ts": 927, - "packages/runtime/tests/executable-observer.test.ts": 1195, - "packages/runtime/tests/fetch.test.ts": 1634, - "packages/runtime/tests/fs.test.ts": 1112, - "packages/runtime/tests/host-files.test.ts": 1820, - "packages/runtime/tests/native-launcher.test.ts": 3293, - "packages/runtime/tests/process.test.ts": 1052, - "packages/runtime/tests/service.test.ts": 1132, - "packages/test-agent/tests/acp-server.test.ts": 1011, - "packages/test-agent/tests/behavior-engine.test.ts": 2573, - "packages/test-agent/tests/bridge.test.ts": 2359, - "packages/test-agent/tests/command-lazy.test.ts": 5319, - "packages/test-agent/tests/components.test.ts": 43184, - "packages/test-agent/tests/controller.test.ts": 1763, - "packages/test-agent/tests/cross-package-resolution.test.ts": 3539, - "packages/test-agent/tests/native-launch.test.ts": 19687, - "packages/test-agent/tests/net.test.ts": 961, - "packages/test-agent/tests/profile.test.ts": 2370, - "packages/test-agent/tests/protocol.test.ts": 1543, - "packages/test-agent/tests/provider.test.ts": 2619, - "packages/test-agent/tests/public-api.test.ts": 3036, - "packages/test-agent/tests/route-slot.test.ts": 970, - "packages/test-agent/tests/smoke.test.ts": 14866, - "packages/test-agent/tests/template.test.ts": 2333, - "packages/test-agent/tests/worker-lifecycle.test.ts": 8364, - "packages/test-support/tests/journal.test.ts": 1140, - "packages/testing/tests/agent-composition.test.ts": 2622, - "packages/testing/tests/assert-throws.test.ts": 3349, - "packages/testing/tests/assert.test.ts": 977, - "packages/testing/tests/assertions.test.ts": 3723, - "packages/testing/tests/boundary-contract.test.ts": 3225, - "packages/testing/tests/cli.test.ts": 4140, - "packages/testing/tests/execution-harness.test.ts": 5270, - "packages/testing/tests/replay.test.ts": 3360, - "packages/testing/tests/smoke.test.ts": 3651, - "packages/testing/tests/test-component.test.ts": 3895, - "packages/testing/tests/testing-activation.test.ts": 3548, - "packages/testing/tests/testing-boundary.test.ts": 2889, - "packages/testing/tests/testing-mode.test.ts": 3817, - "packages/testing/tests/use-testing.test.ts": 3327, - "packages/web/tests/assets.test.ts": 1050, - "packages/web/tests/client-logic.test.ts": 1038, - "packages/web/tests/compile.test.ts": 2939, - "packages/web/tests/component.test.ts": 3338, - "packages/web/tests/declaration.test.ts": 2803, - "packages/web/tests/document.test.ts": 2963, - "packages/web/tests/elicitation.test.ts": 2745, - "packages/web/tests/live-form.test.ts": 2812, - "packages/web/tests/markdown.test.ts": 1110, - "packages/web/tests/opener.test.ts": 2731, - "packages/web/tests/page.test.ts": 1032, - "packages/web/tests/responder.test.ts": 3080, - "packages/web/tests/server-lifecycle.test.ts": 2915, - "packages/web/tests/server.test.ts": 3021, - "packages/workflow/tests/generated-agent-component.test.ts": 4494, - "packages/workflow/tests/generated-observations.test.ts": 2732, - "packages/workflow/tests/public-entrypoint.test.ts": 3872, - "packages/workflow/tests/retained-run.test.ts": 3071, - "packages/workflow/tests/service-denial.test.ts": 1189, - "packages/workflow/tests/workflow-agent-checkpoints.test.ts": 3946, - "packages/workflow/tests/workflow-agent-sessions.test.ts": 3244, - "packages/workflow/tests/workflow-bundle.test.ts": 2808, - "packages/workflow/tests/workflow-checkpoint.test.ts": 4029, - "packages/workflow/tests/workflow-definition.test.ts": 2614, - "packages/workflow/tests/workflow-export.test.ts": 5038, - "packages/workflow/tests/workflow-fork-source.test.ts": 3176, - "packages/workflow/tests/workflow-fork.test.ts": 2627, - "packages/workflow/tests/workflow-lifecycle-authority.test.ts": 3999, - "packages/workflow/tests/workflow-lifecycle-control.test.ts": 4113, - "packages/workflow/tests/workflow-lifecycle-inspection.test.ts": 8212, - "packages/workflow/tests/workflow-run-journal.test.ts": 4574, - "packages/workflow/tests/workflow-run-storage.test.ts": 5897, - "packages/workflow/tests/workflow-run.test.ts": 3322, - "packages/workflow/tests/workflow-suspension-answer.test.ts": 3816, - "packages/workflow/tests/workflow-suspension.test.ts": 3394, - "packages/workflow/tests/workspace-crash-recovery.test.ts": 4966, - "packages/workflow/tests/workspace-effect-loaded-copy.test.ts": 3201, - "packages/workflow/tests/workspace-effect-transaction.test.ts": 3862, - "packages/workflow/tests/workspace-effect.test.ts": 3487, - "packages/workflow/tests/workspace-files.test.ts": 8146, - "packages/workflow/tests/workspace-root-restoration.test.ts": 4396, - "packages/workflow/tests/workspace-root.test.ts": 3237, - "packages/workflow/tests/workspace-transaction.test.ts": 3206, - "packages/workflow/tests/xmd-artifact.test.ts": 4445, - "scripts/tests/acpx-vendor.test.ts": 1396, - "scripts/tests/adapter-distribution.test.ts": 2977, - "scripts/tests/adapter-npm-package.test.ts": 22618, - "scripts/tests/adapter-vendor.test.ts": 2615, - "scripts/tests/bootstrap-npm-package.test.ts": 8604, - "scripts/tests/build-npm.test.ts": 25291, - "scripts/tests/build-web-client.test.ts": 20579, - "scripts/tests/bump-version.test.ts": 1055, - "scripts/tests/changed-paths.test.ts": 992, - "scripts/tests/ci-workflow.test.ts": 3227, - "scripts/tests/cli-npm-bin.test.ts": 155690, - "scripts/tests/cloudflare-dofs-vendor.test.ts": 2738, - "scripts/tests/consumer-cycle.test.ts": 1012, - "scripts/tests/documentation-validation.test.ts": 3898, - "scripts/tests/filesystem-contract-workflow.test.ts": 977, - "scripts/tests/frozen-entry.test.ts": 6493, - "scripts/tests/jsr-consumer-documentation.test.ts": 11143, - "scripts/tests/main-green.test.ts": 1254, - "scripts/tests/main-health.test.ts": 1053, - "scripts/tests/measure-test-weights.test.ts": 1244, - "scripts/tests/no-module-scoped-registry.test.ts": 1493, - "scripts/tests/no-redundant-test-scope.test.ts": 4111, - "scripts/tests/no-sync-filesystem.test.ts": 3917, - "scripts/tests/no-yield-in-finally.test.ts": 1492, - "scripts/tests/oxlint-policy.test.ts": 7492, - "scripts/tests/packaged-document.test.ts": 989, - "scripts/tests/prefer-effection-operation.test.ts": 4659, - "scripts/tests/prefer-effection-result.test.ts": 3518, - "scripts/tests/prepared-state.test.ts": 1764, - "scripts/tests/publish-workflow-generator.test.ts": 3644, - "scripts/tests/publish-workflow-membership.test.ts": 1037, - "scripts/tests/publishable-membership-agreement.test.ts": 2425, - "scripts/tests/readme-targets.test.ts": 11201, - "scripts/tests/release-targets.test.ts": 974, - "scripts/tests/review-infrastructure.test.ts": 3707, - "scripts/tests/runtime-exclusions.test.ts": 1209, - "scripts/tests/runtime-tests.test.ts": 1033, - "scripts/tests/scope-bound-event-registration.test.ts": 3730, - "scripts/tests/shard-execution.test.ts": 8404, - "scripts/tests/staged-write.test.ts": 953, - "scripts/tests/test-file-discovery.test.ts": 1094, - "scripts/tests/test-shards.test.ts": 932, - "scripts/tests/test-weights.test.ts": 1177, - "scripts/tests/tracked.test.ts": 968, - "scripts/tests/verify-adapter.test.ts": 6181, - "scripts/tests/verify-clean.test.ts": 1111, - "scripts/tests/verify-coordinator.test.ts": 1269, - "scripts/tests/version-lockstep.test.ts": 1055, - "scripts/tests/web-client-module.test.ts": 967, - "scripts/tests/workspace.test.ts": 942 + "packages/acp/tests/acceptance.test.ts": 4424, + "packages/acp/tests/acpx-checkpoint-meta.test.ts": 7578, + "packages/acp/tests/acpx-materialization.test.ts": 7886, + "packages/acp/tests/acpx-transient-env.test.ts": 1594, + "packages/acp/tests/adapter-materialization.test.ts": 71437, + "packages/acp/tests/adapter-protocol.test.ts": 54597, + "packages/acp/tests/codex-surfaces.test.ts": 1607, + "packages/acp/tests/loaded-copy.test.ts": 3996, + "packages/acp/tests/native-launch.test.ts": 4703, + "packages/acp/tests/permission-bridge.test.ts": 3504, + "packages/acp/tests/provider.test.ts": 4604, + "packages/acp/tests/serial-queue.test.ts": 1383, + "packages/acp/tests/session-key.test.ts": 3417, + "packages/acp/tests/session-route.test.ts": 3642, + "packages/acp/tests/terminal-screen.test.ts": 1467, + "packages/cli/tests/agent-adapters.test.ts": 4822, + "packages/cli/tests/agent-cli.test.ts": 34852, + "packages/cli/tests/agent-config.test.ts": 3147, + "packages/cli/tests/agent-options-cli.test.ts": 14309, + "packages/cli/tests/agent-options.test.ts": 3162, + "packages/cli/tests/agent-session-coordinator.test.ts": 3791, + "packages/cli/tests/cli-help.test.ts": 12834, + "packages/cli/tests/command.test.ts": 9005, + "packages/cli/tests/compiled-upgrade.test.ts": 9187, + "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 7688, + "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 106746, + "packages/cli/tests/document-suites/props/props-markdown.test.ts": 7176, + "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 10090, + "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 8942, + "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 12701, + "packages/cli/tests/evaluate-component.test.ts": 9823, + "packages/cli/tests/evaluate-workflow.test.ts": 4862, + "packages/cli/tests/fetch-cli.test.ts": 3226, + "packages/cli/tests/file-stream.test.ts": 1584, + "packages/cli/tests/github-zero-effect.test.ts": 6274, + "packages/cli/tests/inline-cli.test.ts": 26698, + "packages/cli/tests/launch-timeout.test.ts": 11426, + "packages/cli/tests/packaged-document.test.ts": 4260, + "packages/cli/tests/plan-args.test.ts": 1382, + "packages/cli/tests/plan-cli.test.ts": 88923, + "packages/cli/tests/plan-command-document.test.ts": 8750, + "packages/cli/tests/plan-component.test.ts": 10892, + "packages/cli/tests/plan-host-acts.test.ts": 4787, + "packages/cli/tests/plan.test.ts": 14517, + "packages/cli/tests/plugin-cli.test.ts": 32667, + "packages/cli/tests/plugin-host.test.ts": 5344, + "packages/cli/tests/plugin-modules.test.ts": 1381, + "packages/cli/tests/plugin-selection.test.ts": 1373, + "packages/cli/tests/process-retention.test.ts": 5082, + "packages/cli/tests/props-cli.test.ts": 24282, + "packages/cli/tests/props-schema.test.ts": 3610, + "packages/cli/tests/props-sources.test.ts": 2011, + "packages/cli/tests/pull-request-read-fork.test.ts": 6259, + "packages/cli/tests/repl-composition.test.ts": 1823, + "packages/cli/tests/repl-execution.test.ts": 4755, + "packages/cli/tests/repl-model.test.ts": 3988, + "packages/cli/tests/repl-route.test.ts": 3896, + "packages/cli/tests/repl-terminal.test.ts": 2683, + "packages/cli/tests/run-composition-deno.test.ts": 6681, + "packages/cli/tests/run-composition-nested.test.ts": 18901, + "packages/cli/tests/run-composition.test.ts": 6238, + "packages/cli/tests/run-deadline.test.ts": 7605, + "packages/cli/tests/run-profile.test.ts": 3765, + "packages/cli/tests/run-timeouts.test.ts": 11458, + "packages/cli/tests/secret-detection-cli.test.ts": 15679, + "packages/cli/tests/service-document.test.ts": 4634, + "packages/cli/tests/service-host.test.ts": 4545, + "packages/cli/tests/session-launch-cli.test.ts": 10090, + "packages/cli/tests/stdin-cli.test.ts": 30552, + "packages/cli/tests/stdout-delivery.test.ts": 1377, + "packages/cli/tests/syntax-cli.test.ts": 30181, + "packages/cli/tests/targets-cli.test.ts": 23314, + "packages/cli/tests/test-root-profile.test.ts": 6783, + "packages/cli/tests/test-target.test.ts": 30382, + "packages/cli/tests/testing-activation.test.ts": 3995, + "packages/cli/tests/testing-execution-host.test.ts": 47616, + "packages/cli/tests/upgrade-cli.test.ts": 16284, + "packages/cli/tests/upgrade-command-document.test.ts": 6450, + "packages/cli/tests/upgrade-output.test.ts": 3951, + "packages/cli/tests/value-root.test.ts": 14567, + "packages/cli/tests/verbose-component.test.ts": 4400, + "packages/cli/tests/workflow-agent-sessions.test.ts": 18435, + "packages/cli/tests/workflow-agent.test.ts": 14865, + "packages/cli/tests/workflow-cli.test.ts": 95078, + "packages/cli/tests/workflow-crash.test.ts": 78332, + "packages/cli/tests/workflow-declaration.test.ts": 3899, + "packages/cli/tests/workflow-export-publication.test.ts": 16177, + "packages/cli/tests/workflow-fetch.test.ts": 7205, + "packages/cli/tests/workflow-fork.test.ts": 71027, + "packages/cli/tests/workflow-host.test.ts": 13119, + "packages/cli/tests/workflow-inspection.test.ts": 96455, + "packages/cli/tests/workflow-installation.test.ts": 5249, + "packages/cli/tests/workflow-lifecycle-control.test.ts": 17955, + "packages/cli/tests/workflow-retention.test.ts": 20614, + "packages/cli/tests/workflow-suspension.test.ts": 6970, + "packages/code-review-agent/tests/doctor.test.ts": 1350, + "packages/code-review-agent/tests/parse-diagnostics.test.ts": 1390, + "packages/code-review-agent/tests/parse-diff.test.ts": 1358, + "packages/code-review-agent/tests/parse-doctor.test.ts": 3524, + "packages/code-review-agent/tests/parse-oxlint.test.ts": 1375, + "packages/code-review-agent/tests/policy.test.ts": 1347, + "packages/core/tests/agent-api.test.ts": 1408, + "packages/core/tests/agent-components.test.ts": 4063, + "packages/core/tests/agent-function-components.test.ts": 5307, + "packages/core/tests/agent-session-launch.test.ts": 5840, + "packages/core/tests/agent-session-placement.test.ts": 1433, + "packages/core/tests/agent-session-use-loaded-copy.test.ts": 1455, + "packages/core/tests/agent-session-use.test.ts": 1413, + "packages/core/tests/agent-stack.test.ts": 3680, + "packages/core/tests/answer-identity.test.ts": 3169, + "packages/core/tests/answers-component.test.ts": 4212, + "packages/core/tests/answers-expansion-recursion.test.ts": 3393, + "packages/core/tests/capture-errors.test.ts": 3569, + "packages/core/tests/capture-props.test.ts": 3371, + "packages/core/tests/cli-journal.test.ts": 9912, + "packages/core/tests/code-block-component.test.ts": 4810, + "packages/core/tests/compiler-boundary.test.ts": 3363, + "packages/core/tests/component-api.test.ts": 3113, + "packages/core/tests/component-registration.test.ts": 4113, + "packages/core/tests/component-returns.test.ts": 4502, + "packages/core/tests/component-schema-conformance.test.ts": 3500, + "packages/core/tests/config-api.test.ts": 3526, + "packages/core/tests/construct-error-observation.test.ts": 3615, + "packages/core/tests/contextual-cwd.test.ts": 4650, + "packages/core/tests/daemon-integration.test.ts": 4166, + "packages/core/tests/daemon.test.ts": 3179, + "packages/core/tests/declared-markdown-component.test.ts": 4814, + "packages/core/tests/document-output-api.test.ts": 1362, + "packages/core/tests/document-target-execution.test.ts": 6032, + "packages/core/tests/document-targets.test.ts": 3566, + "packages/core/tests/document-validation.test.ts": 4876, + "packages/core/tests/documentation-index.test.ts": 3296, + "packages/core/tests/each.test.ts": 3510, + "packages/core/tests/elicit-component.test.ts": 4087, + "packages/core/tests/ephemeral-service.test.ts": 3859, + "packages/core/tests/eval-bindings.test.ts": 3481, + "packages/core/tests/eval-context.test.ts": 1930, + "packages/core/tests/eval-durable.test.ts": 3556, + "packages/core/tests/eval-error-mode.test.ts": 3742, + "packages/core/tests/eval-interpolate.test.ts": 1360, + "packages/core/tests/eval-middleware.test.ts": 3130, + "packages/core/tests/eval-persist.test.ts": 3608, + "packages/core/tests/eval-return.test.ts": 3484, + "packages/core/tests/eval-scope.test.ts": 3447, + "packages/core/tests/eval-timeout.test.ts": 3504, + "packages/core/tests/eval-transform.test.ts": 3218, + "packages/core/tests/evaluate-component.test.ts": 5249, + "packages/core/tests/evaluate-loaded-copy.test.ts": 3566, + "packages/core/tests/evaluate-provider-lifetime.test.ts": 3581, + "packages/core/tests/evaluation-profile.test.ts": 3307, + "packages/core/tests/exec-timeout.test.ts": 3535, + "packages/core/tests/execute.test.ts": 4007, + "packages/core/tests/execution-protocol.test.ts": 4649, + "packages/core/tests/expand.test.ts": 4108, + "packages/core/tests/expansion-identity.test.ts": 4041, + "packages/core/tests/expansion-metadata.test.ts": 3413, + "packages/core/tests/expression-props.test.ts": 3980, + "packages/core/tests/fail-component.test.ts": 3720, + "packages/core/tests/failure-printing.test.ts": 3659, + "packages/core/tests/fatal-cause.test.ts": 3284, + "packages/core/tests/fetch-component.test.ts": 4673, + "packages/core/tests/file-component.test.ts": 5289, + "packages/core/tests/file-delete-component.test.ts": 3630, + "packages/core/tests/files-fatal.test.ts": 4348, + "packages/core/tests/foreground-exec.test.ts": 4296, + "packages/core/tests/frontmatter.test.ts": 3181, + "packages/core/tests/function-components.test.ts": 3910, + "packages/core/tests/generated-composition.test.ts": 3965, + "packages/core/tests/generated-xmd.test.ts": 5249, + "packages/core/tests/glob-api.test.ts": 1703, + "packages/core/tests/glob-component.test.ts": 4091, + "packages/core/tests/guarded-journal.test.ts": 3461, + "packages/core/tests/has-content.test.ts": 3307, + "packages/core/tests/heal.test.ts": 3335, + "packages/core/tests/if.test.ts": 3870, + "packages/core/tests/inline-root.test.ts": 3506, + "packages/core/tests/invocation-failures.test.ts": 3231, + "packages/core/tests/invocation-identity.test.ts": 3841, + "packages/core/tests/invocation-scope.test.ts": 3560, + "packages/core/tests/journal-source-position.test.ts": 3626, + "packages/core/tests/json-component.test.ts": 3650, + "packages/core/tests/json.test.ts": 3147, + "packages/core/tests/let.test.ts": 3507, + "packages/core/tests/loaded-copy-files.test.ts": 3398, + "packages/core/tests/loop.test.ts": 4242, + "packages/core/tests/named-slots.test.ts": 4184, + "packages/core/tests/output-error-mode.test.ts": 4420, + "packages/core/tests/output-normalize.test.ts": 1366, + "packages/core/tests/output-terminal.test.ts": 1461, + "packages/core/tests/parse-components.test.ts": 3611, + "packages/core/tests/plan-response.test.ts": 1407, + "packages/core/tests/plugin-api.test.ts": 3190, + "packages/core/tests/plugin.test.ts": 3272, + "packages/core/tests/props-binding.test.ts": 3585, + "packages/core/tests/protected-content.test.ts": 3135, + "packages/core/tests/registered-printed-errors.test.ts": 3300, + "packages/core/tests/replay-stream.test.ts": 1345, + "packages/core/tests/retain.test.ts": 3494, + "packages/core/tests/root-composition.test.ts": 3529, + "packages/core/tests/root-props.test.ts": 3588, + "packages/core/tests/root-provider.test.ts": 3522, + "packages/core/tests/sample-component.test.ts": 4207, + "packages/core/tests/scanner.test.ts": 3193, + "packages/core/tests/scope-local.test.ts": 1345, + "packages/core/tests/secret-detection.test.ts": 4135, + "packages/core/tests/secret-files.test.ts": 1807, + "packages/core/tests/secret-rules.test.ts": 1826, + "packages/core/tests/secret-scanner-baseline.test.ts": 1583, + "packages/core/tests/secret-scanner.test.ts": 1698, + "packages/core/tests/source-position.test.ts": 3353, + "packages/core/tests/state-ownership.test.ts": 3390, + "packages/core/tests/streaming-emission.test.ts": 3389, + "packages/core/tests/switch.test.ts": 3887, + "packages/core/tests/syntax-catalog.test.ts": 3913, + "packages/core/tests/syntax-component.test.ts": 4609, + "packages/core/tests/syntax-loaded-copy.test.ts": 3505, + "packages/core/tests/temp-dir.test.ts": 5795, + "packages/core/tests/temp-file-compiler.test.ts": 1861, + "packages/core/tests/text-interpolation.test.ts": 3310, + "packages/core/tests/try-content.test.ts": 3296, + "packages/core/tests/unused-in-diff.test.ts": 3528, + "packages/core/tests/validate.test.ts": 3352, + "packages/core/tests/validation-integration.test.ts": 3414, + "packages/core/tests/workflow-component-bundle.test.ts": 3634, + "packages/durable-streams/tests/context.test.ts": 1576, + "packages/durable-streams/tests/deterministic-id.test.ts": 1600, + "packages/durable-streams/tests/divergence-api.test.ts": 1606, + "packages/durable-streams/tests/divergence.test.ts": 1638, + "packages/durable-streams/tests/durable-each.test.ts": 1650, + "packages/durable-streams/tests/durable-position.test.ts": 1600, + "packages/durable-streams/tests/durable-run.test.ts": 1625, + "packages/durable-streams/tests/ephemeral.test.ts": 1620, + "packages/durable-streams/tests/fail-stop.test.ts": 1644, + "packages/durable-streams/tests/guard-stream.test.ts": 1678, + "packages/durable-streams/tests/live-coordinator.test.ts": 1627, + "packages/durable-streams/tests/parse.test.ts": 1611, + "packages/durable-streams/tests/replay-guard.test.ts": 1678, + "packages/durable-streams/tests/replay-index.test.ts": 1424, + "packages/durable-streams/tests/retained.test.ts": 1456, + "packages/durable-streams/tests/serialize-event.test.ts": 1595, + "packages/durable-streams/tests/smoke.test.ts": 1580, + "packages/durable-streams/tests/structured-concurrency.test.ts": 1706, + "packages/durable-streams/tests/terminal-boundary.test.ts": 1663, + "packages/durable-streams/tests/types.test.ts": 1304, + "packages/git/tests/ambient-authentication.test.ts": 22496, + "packages/git/tests/api-entrypoint.test.ts": 4301, + "packages/git/tests/credential-helper.test.ts": 5315, + "packages/git/tests/git-add-crash.test.ts": 6108, + "packages/git/tests/git-add-durability.test.ts": 23187, + "packages/git/tests/git-add.test.ts": 30526, + "packages/git/tests/git-commit-crash.test.ts": 6315, + "packages/git/tests/git-commit-durability.test.ts": 29041, + "packages/git/tests/git-commit.test.ts": 34181, + "packages/git/tests/git-host-effect.test.ts": 8105, + "packages/git/tests/git-push-crash.test.ts": 8524, + "packages/git/tests/git-push-durability.test.ts": 35860, + "packages/git/tests/git-push.test.ts": 41808, + "packages/git/tests/git-switch-crash.test.ts": 6124, + "packages/git/tests/git-switch-durability.test.ts": 18852, + "packages/git/tests/git-switch.test.ts": 19532, + "packages/git/tests/git.test.ts": 3879, + "packages/git/tests/github-activation.test.ts": 4893, + "packages/git/tests/github-issues.test.ts": 3848, + "packages/git/tests/github-pull-requests.test.ts": 3543, + "packages/git/tests/github-workflow-activation.test.ts": 5247, + "packages/git/tests/issue-markdown.test.ts": 10253, + "packages/git/tests/issue-records.test.ts": 3746, + "packages/git/tests/materialization.test.ts": 4410, + "packages/git/tests/module-partition.test.ts": 4585, + "packages/git/tests/plugin.test.ts": 4163, + "packages/git/tests/provider-neutrality.test.ts": 2991, + "packages/git/tests/public-entrypoint.test.ts": 7837, + "packages/git/tests/pull-request-crash.test.ts": 7172, + "packages/git/tests/pull-request-durability.test.ts": 34453, + "packages/git/tests/pull-request-read.test.ts": 7415, + "packages/git/tests/pull-request-records.test.ts": 3776, + "packages/git/tests/pull-request.test.ts": 61232, + "packages/git/tests/repository-components.test.ts": 11337, + "packages/git/tests/repository-control-plane.test.ts": 10024, + "packages/git/tests/repository-materialization.test.ts": 7531, + "packages/git/tests/repository-replay.test.ts": 14210, + "packages/git/tests/repository-storage.test.ts": 17123, + "packages/git/tests/run-composition-ambient.test.ts": 11137, + "packages/git/tests/run-composition-lazy.test.ts": 5053, + "packages/git/tests/run-composition-managed.test.ts": 12698, + "packages/git/tests/run-composition-remote.test.ts": 12758, + "packages/git/tests/selection-authentication.test.ts": 1635, + "packages/git/tests/worktree-replay.test.ts": 6088, + "packages/runtime/tests/agent-session-coordinator.test.ts": 1930, + "packages/runtime/tests/duration.test.ts": 1367, + "packages/runtime/tests/executable-observer.test.ts": 1655, + "packages/runtime/tests/fetch.test.ts": 2173, + "packages/runtime/tests/fs.test.ts": 1553, + "packages/runtime/tests/host-files.test.ts": 2332, + "packages/runtime/tests/native-launcher.test.ts": 3774, + "packages/runtime/tests/process.test.ts": 1538, + "packages/runtime/tests/service.test.ts": 1662, + "packages/test-agent/tests/acp-server.test.ts": 1495, + "packages/test-agent/tests/behavior-engine.test.ts": 3766, + "packages/test-agent/tests/bridge.test.ts": 3279, + "packages/test-agent/tests/command-lazy.test.ts": 7029, + "packages/test-agent/tests/components.test.ts": 52910, + "packages/test-agent/tests/controller.test.ts": 2587, + "packages/test-agent/tests/cross-package-resolution.test.ts": 5474, + "packages/test-agent/tests/native-launch.test.ts": 25742, + "packages/test-agent/tests/net.test.ts": 1467, + "packages/test-agent/tests/profile.test.ts": 3508, + "packages/test-agent/tests/protocol.test.ts": 2226, + "packages/test-agent/tests/provider.test.ts": 3817, + "packages/test-agent/tests/public-api.test.ts": 4500, + "packages/test-agent/tests/route-slot.test.ts": 1372, + "packages/test-agent/tests/smoke.test.ts": 18550, + "packages/test-agent/tests/template.test.ts": 3469, + "packages/test-agent/tests/worker-lifecycle.test.ts": 11058, + "packages/test-support/tests/journal.test.ts": 1615, + "packages/testing/tests/agent-composition.test.ts": 3843, + "packages/testing/tests/assert-throws.test.ts": 5007, + "packages/testing/tests/assert.test.ts": 1407, + "packages/testing/tests/assertions.test.ts": 5541, + "packages/testing/tests/boundary-contract.test.ts": 4575, + "packages/testing/tests/cli.test.ts": 5696, + "packages/testing/tests/execution-harness.test.ts": 8139, + "packages/testing/tests/replay.test.ts": 4940, + "packages/testing/tests/smoke.test.ts": 5117, + "packages/testing/tests/test-component.test.ts": 5579, + "packages/testing/tests/testing-activation.test.ts": 5204, + "packages/testing/tests/testing-boundary.test.ts": 4126, + "packages/testing/tests/testing-mode.test.ts": 5629, + "packages/testing/tests/use-testing.test.ts": 4696, + "packages/web/tests/assets.test.ts": 1438, + "packages/web/tests/client-logic.test.ts": 1487, + "packages/web/tests/compile.test.ts": 4113, + "packages/web/tests/component.test.ts": 4592, + "packages/web/tests/declaration.test.ts": 3930, + "packages/web/tests/document.test.ts": 4128, + "packages/web/tests/elicitation.test.ts": 3903, + "packages/web/tests/live-form.test.ts": 3831, + "packages/web/tests/markdown.test.ts": 1561, + "packages/web/tests/opener.test.ts": 3788, + "packages/web/tests/page.test.ts": 1420, + "packages/web/tests/responder.test.ts": 4048, + "packages/web/tests/server-lifecycle.test.ts": 3937, + "packages/web/tests/server.test.ts": 4322, + "packages/workflow/tests/generated-agent-component.test.ts": 6153, + "packages/workflow/tests/generated-observations.test.ts": 3674, + "packages/workflow/tests/public-entrypoint.test.ts": 5334, + "packages/workflow/tests/retained-run.test.ts": 4451, + "packages/workflow/tests/service-denial.test.ts": 1668, + "packages/workflow/tests/workflow-agent-checkpoints.test.ts": 5434, + "packages/workflow/tests/workflow-agent-sessions.test.ts": 4432, + "packages/workflow/tests/workflow-bundle.test.ts": 3963, + "packages/workflow/tests/workflow-checkpoint.test.ts": 5425, + "packages/workflow/tests/workflow-definition.test.ts": 3662, + "packages/workflow/tests/workflow-export.test.ts": 6869, + "packages/workflow/tests/workflow-fork-source.test.ts": 4445, + "packages/workflow/tests/workflow-fork.test.ts": 3552, + "packages/workflow/tests/workflow-lifecycle-authority.test.ts": 5209, + "packages/workflow/tests/workflow-lifecycle-control.test.ts": 5737, + "packages/workflow/tests/workflow-lifecycle-inspection.test.ts": 10270, + "packages/workflow/tests/workflow-run-journal.test.ts": 6295, + "packages/workflow/tests/workflow-run-storage.test.ts": 7706, + "packages/workflow/tests/workflow-run.test.ts": 4308, + "packages/workflow/tests/workflow-suspension-answer.test.ts": 5313, + "packages/workflow/tests/workflow-suspension.test.ts": 4992, + "packages/workflow/tests/workspace-crash-recovery.test.ts": 6630, + "packages/workflow/tests/workspace-effect-loaded-copy.test.ts": 4477, + "packages/workflow/tests/workspace-effect-transaction.test.ts": 5230, + "packages/workflow/tests/workspace-effect.test.ts": 5127, + "packages/workflow/tests/workspace-files.test.ts": 11932, + "packages/workflow/tests/workspace-root-restoration.test.ts": 6018, + "packages/workflow/tests/workspace-root.test.ts": 4403, + "packages/workflow/tests/workspace-transaction.test.ts": 4469, + "packages/workflow/tests/xmd-artifact.test.ts": 6309, + "scripts/tests/acpx-vendor.test.ts": 1854, + "scripts/tests/adapter-distribution.test.ts": 3955, + "scripts/tests/adapter-npm-package.test.ts": 28449, + "scripts/tests/adapter-vendor.test.ts": 3547, + "scripts/tests/bootstrap-npm-package.test.ts": 9311, + "scripts/tests/build-npm.test.ts": 34883, + "scripts/tests/build-web-client.test.ts": 25828, + "scripts/tests/bump-version.test.ts": 1462, + "scripts/tests/changed-paths.test.ts": 1348, + "scripts/tests/ci-workflow.test.ts": 4145, + "scripts/tests/cli-npm-bin.test.ts": 202292, + "scripts/tests/cloudflare-dofs-vendor.test.ts": 3760, + "scripts/tests/consumer-cycle.test.ts": 1461, + "scripts/tests/documentation-validation.test.ts": 5282, + "scripts/tests/filesystem-contract-workflow.test.ts": 1398, + "scripts/tests/frozen-entry.test.ts": 7108, + "scripts/tests/jsr-consumer-documentation.test.ts": 16048, + "scripts/tests/main-green.test.ts": 1711, + "scripts/tests/main-health.test.ts": 1558, + "scripts/tests/measure-test-weights.test.ts": 1729, + "scripts/tests/no-module-scoped-registry.test.ts": 2111, + "scripts/tests/no-redundant-test-scope.test.ts": 5639, + "scripts/tests/no-sync-filesystem.test.ts": 5497, + "scripts/tests/no-yield-in-finally.test.ts": 2072, + "scripts/tests/oxlint-policy.test.ts": 10227, + "scripts/tests/packaged-document.test.ts": 1419, + "scripts/tests/prefer-effection-operation.test.ts": 6711, + "scripts/tests/prefer-effection-result.test.ts": 4848, + "scripts/tests/prepared-state.test.ts": 2214, + "scripts/tests/publish-workflow-generator.test.ts": 5123, + "scripts/tests/publish-workflow-membership.test.ts": 1527, + "scripts/tests/publishable-membership-agreement.test.ts": 3436, + "scripts/tests/readme-targets.test.ts": 15416, + "scripts/tests/release-targets.test.ts": 1465, + "scripts/tests/review-infrastructure.test.ts": 5411, + "scripts/tests/runtime-exclusions.test.ts": 1827, + "scripts/tests/runtime-tests.test.ts": 1526, + "scripts/tests/scope-bound-event-registration.test.ts": 5395, + "scripts/tests/shard-execution.test.ts": 10601, + "scripts/tests/staged-write.test.ts": 1446, + "scripts/tests/test-file-discovery.test.ts": 1579, + "scripts/tests/test-shards.test.ts": 1406, + "scripts/tests/test-weights.test.ts": 1665, + "scripts/tests/tracked.test.ts": 1390, + "scripts/tests/verify-adapter.test.ts": 6517, + "scripts/tests/verify-clean.test.ts": 1574, + "scripts/tests/verify-coordinator.test.ts": 1748, + "scripts/tests/version-lockstep.test.ts": 1532, + "scripts/tests/web-client-module.test.ts": 1425, + "scripts/tests/workspace.test.ts": 1352 }, "node": { - "packages/acp/tests/acceptance.test.ts": 1304, - "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4546, - "packages/acp/tests/acpx-materialization.test.ts": 6716, - "packages/acp/tests/acpx-transient-env.test.ts": 495, - "packages/acp/tests/adapter-materialization.test.ts": 48474, - "packages/acp/tests/adapter-protocol.test.ts": 33278, - "packages/acp/tests/codex-surfaces.test.ts": 388, - "packages/acp/tests/native-launch.test.ts": 1361, - "packages/acp/tests/permission-bridge.test.ts": 696, - "packages/acp/tests/provider.test.ts": 1407, - "packages/acp/tests/serial-queue.test.ts": 320, - "packages/acp/tests/session-key.test.ts": 341, - "packages/acp/tests/terminal-screen.test.ts": 377, - "packages/cli/tests/agent-adapters.test.ts": 1483, - "packages/cli/tests/agent-cli.test.ts": 23455, - "packages/cli/tests/agent-config.test.ts": 295, - "packages/cli/tests/agent-options-cli.test.ts": 9063, - "packages/cli/tests/agent-options.test.ts": 300, - "packages/cli/tests/agent-session-coordinator.test.ts": 818, - "packages/cli/tests/cli-help.test.ts": 16739, - "packages/cli/tests/command.test.ts": 9476, - "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 2429, - "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 120309, - "packages/cli/tests/document-suites/props/props-markdown.test.ts": 2141, - "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 5882, - "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 3614, - "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 10297, - "packages/cli/tests/evaluate-component.test.ts": 8585, - "packages/cli/tests/fetch-cli.test.ts": 2900, - "packages/cli/tests/file-stream.test.ts": 335, - "packages/cli/tests/inline-cli.test.ts": 38091, - "packages/cli/tests/launch-timeout.test.ts": 20363, - "packages/cli/tests/packaged-document.test.ts": 932, - "packages/cli/tests/plan-args.test.ts": 333, - "packages/cli/tests/plan-cli.test.ts": 67983, - "packages/cli/tests/plan-command-document.test.ts": 5120, - "packages/cli/tests/plan-component.test.ts": 7232, - "packages/cli/tests/plan-host-acts.test.ts": 1508, - "packages/cli/tests/plan.test.ts": 10598, - "packages/cli/tests/plugin-cli.test.ts": 49674, - "packages/cli/tests/plugin-host.test.ts": 1937, - "packages/cli/tests/plugin-modules.test.ts": 351, - "packages/cli/tests/plugin-selection.test.ts": 348, - "packages/cli/tests/process-retention.test.ts": 2984, - "packages/cli/tests/props-cli.test.ts": 38353, - "packages/cli/tests/props-schema.test.ts": 892, - "packages/cli/tests/props-sources.test.ts": 447, - "packages/cli/tests/repl-composition.test.ts": 489, - "packages/cli/tests/repl-execution.test.ts": 1968, - "packages/cli/tests/repl-model.test.ts": 1198, - "packages/cli/tests/repl-route.test.ts": 1077, - "packages/cli/tests/run-composition.test.ts": 2446, - "packages/cli/tests/run-deadline.test.ts": 2785, - "packages/cli/tests/run-profile.test.ts": 772, - "packages/cli/tests/run-timeouts.test.ts": 13623, - "packages/cli/tests/secret-detection-cli.test.ts": 22616, - "packages/cli/tests/service-document.test.ts": 1590, - "packages/cli/tests/service-host.test.ts": 1455, - "packages/cli/tests/session-launch-cli.test.ts": 6415, - "packages/cli/tests/stdin-cli.test.ts": 37484, - "packages/cli/tests/stdout-delivery.test.ts": 355, - "packages/cli/tests/syntax-cli.test.ts": 35401, - "packages/cli/tests/targets-cli.test.ts": 28067, - "packages/cli/tests/test-root-profile.test.ts": 4004, - "packages/cli/tests/test-target.test.ts": 41290, - "packages/cli/tests/testing-activation.test.ts": 845, - "packages/cli/tests/testing-execution-host.test.ts": 53218, - "packages/cli/tests/upgrade-cli.test.ts": 25171, - "packages/cli/tests/upgrade-command-document.test.ts": 3217, - "packages/cli/tests/upgrade-output.test.ts": 1048, - "packages/cli/tests/value-root.test.ts": 16692, - "packages/cli/tests/verbose-component.test.ts": 1059, - "packages/cli/tests/workflow-declaration.test.ts": 823, - "packages/cli/tests/workflow-host.test.ts": 5691, - "packages/code-review-agent/tests/doctor.test.ts": 294, - "packages/code-review-agent/tests/parse-diagnostics.test.ts": 311, - "packages/code-review-agent/tests/parse-diff.test.ts": 302, - "packages/code-review-agent/tests/parse-doctor.test.ts": 661, - "packages/code-review-agent/tests/parse-oxlint.test.ts": 289, - "packages/code-review-agent/tests/policy.test.ts": 298, - "packages/core/tests/agent-api.test.ts": 304, - "packages/core/tests/agent-components.test.ts": 1138, - "packages/core/tests/agent-function-components.test.ts": 2069, - "packages/core/tests/agent-session-launch.test.ts": 2607, - "packages/core/tests/agent-session-placement.test.ts": 309, - "packages/core/tests/agent-session-use.test.ts": 297, - "packages/core/tests/agent-stack.test.ts": 852, - "packages/core/tests/answer-identity.test.ts": 313, - "packages/core/tests/answers-component.test.ts": 1370, - "packages/core/tests/answers-expansion-recursion.test.ts": 580, - "packages/core/tests/capture-errors.test.ts": 710, - "packages/core/tests/capture-props.test.ts": 555, - "packages/core/tests/cli-journal.test.ts": 11814, - "packages/core/tests/code-block-component.test.ts": 1712, - "packages/core/tests/compiler-boundary.test.ts": 648, - "packages/core/tests/component-api.test.ts": 332, - "packages/core/tests/component-registration.test.ts": 1156, - "packages/core/tests/component-returns.test.ts": 1606, - "packages/core/tests/component-schema-conformance.test.ts": 661, - "packages/core/tests/config-api.test.ts": 726, - "packages/core/tests/construct-error-observation.test.ts": 750, - "packages/core/tests/contextual-cwd.test.ts": 1808, - "packages/core/tests/daemon-integration.test.ts": 1319, - "packages/core/tests/daemon.test.ts": 335, - "packages/core/tests/declared-markdown-component.test.ts": 1651, - "packages/core/tests/document-output-api.test.ts": 298, - "packages/core/tests/document-target-execution.test.ts": 2539, - "packages/core/tests/document-targets.test.ts": 671, - "packages/core/tests/document-validation.test.ts": 1711, - "packages/core/tests/documentation-index.test.ts": 535, - "packages/core/tests/each.test.ts": 730, - "packages/core/tests/elicit-component.test.ts": 1222, - "packages/core/tests/ephemeral-service.test.ts": 1037, - "packages/core/tests/eval-bindings.test.ts": 719, - "packages/core/tests/eval-context.test.ts": 364, - "packages/core/tests/eval-durable.test.ts": 746, - "packages/core/tests/eval-error-mode.test.ts": 975, - "packages/core/tests/eval-interpolate.test.ts": 303, - "packages/core/tests/eval-middleware.test.ts": 355, - "packages/core/tests/eval-persist.test.ts": 804, - "packages/core/tests/eval-return.test.ts": 779, - "packages/core/tests/eval-scope.test.ts": 666, - "packages/core/tests/eval-timeout.test.ts": 718, - "packages/core/tests/eval-transform.test.ts": 331, - "packages/core/tests/evaluate-component.test.ts": 2027, - "packages/core/tests/evaluate-loaded-copy.test.ts": 765, - "packages/core/tests/evaluate-provider-lifetime.test.ts": 815, - "packages/core/tests/evaluation-profile.test.ts": 488, - "packages/core/tests/exec-timeout.test.ts": 762, - "packages/core/tests/execute.test.ts": 1100, - "packages/core/tests/execution-protocol.test.ts": 1541, - "packages/core/tests/expand.test.ts": 1106, - "packages/core/tests/expansion-identity.test.ts": 1223, - "packages/core/tests/expansion-metadata.test.ts": 569, - "packages/core/tests/expression-props.test.ts": 1063, - "packages/core/tests/fail-component.test.ts": 990, - "packages/core/tests/failure-printing.test.ts": 810, - "packages/core/tests/fatal-cause.test.ts": 508, - "packages/core/tests/fetch-component.test.ts": 1696, - "packages/core/tests/file-component.test.ts": 2324, - "packages/core/tests/file-delete-component.test.ts": 870, - "packages/core/tests/files-fatal.test.ts": 1359, - "packages/core/tests/foreground-exec.test.ts": 1347, - "packages/core/tests/frontmatter.test.ts": 332, - "packages/core/tests/function-components.test.ts": 1158, - "packages/core/tests/generated-composition.test.ts": 1158, - "packages/core/tests/generated-xmd.test.ts": 2048, - "packages/core/tests/glob-api.test.ts": 357, - "packages/core/tests/glob-component.test.ts": 1277, - "packages/core/tests/guarded-journal.test.ts": 775, - "packages/core/tests/has-content.test.ts": 557, - "packages/core/tests/heal.test.ts": 548, - "packages/core/tests/if.test.ts": 1069, - "packages/core/tests/inline-root.test.ts": 771, - "packages/core/tests/invocation-failures.test.ts": 363, - "packages/core/tests/invocation-identity.test.ts": 991, - "packages/core/tests/invocation-scope.test.ts": 706, - "packages/core/tests/journal-source-position.test.ts": 845, - "packages/core/tests/json-component.test.ts": 873, - "packages/core/tests/json.test.ts": 303, - "packages/core/tests/let.test.ts": 737, - "packages/core/tests/loop.test.ts": 1369, - "packages/core/tests/named-slots.test.ts": 1343, - "packages/core/tests/output-error-mode.test.ts": 1455, - "packages/core/tests/output-normalize.test.ts": 316, - "packages/core/tests/output-terminal.test.ts": 396, - "packages/core/tests/parse-components.test.ts": 808, - "packages/core/tests/plan-response.test.ts": 347, - "packages/core/tests/plugin-api.test.ts": 325, - "packages/core/tests/plugin.test.ts": 565, - "packages/core/tests/props-binding.test.ts": 810, - "packages/core/tests/protected-content.test.ts": 332, - "packages/core/tests/registered-printed-errors.test.ts": 494, - "packages/core/tests/replay-stream.test.ts": 292, - "packages/core/tests/retain.test.ts": 670, - "packages/core/tests/root-composition.test.ts": 792, - "packages/core/tests/root-props.test.ts": 829, - "packages/core/tests/root-provider.test.ts": 791, - "packages/core/tests/sample-component.test.ts": 1342, - "packages/core/tests/scanner.test.ts": 347, - "packages/core/tests/scope-local.test.ts": 292, - "packages/core/tests/secret-detection.test.ts": 1295, - "packages/core/tests/secret-files.test.ts": 444, - "packages/core/tests/secret-rules.test.ts": 489, - "packages/core/tests/secret-scanner-baseline.test.ts": 442, - "packages/core/tests/secret-scanner.test.ts": 409, - "packages/core/tests/source-position.test.ts": 647, - "packages/core/tests/state-ownership.test.ts": 680, - "packages/core/tests/streaming-emission.test.ts": 658, - "packages/core/tests/switch.test.ts": 1053, - "packages/core/tests/syntax-catalog.test.ts": 1021, - "packages/core/tests/syntax-component.test.ts": 1636, - "packages/core/tests/temp-dir.test.ts": 2979, - "packages/core/tests/temp-file-compiler.test.ts": 328, - "packages/core/tests/text-interpolation.test.ts": 518, - "packages/core/tests/try-content.test.ts": 532, - "packages/core/tests/unused-in-diff.test.ts": 788, - "packages/core/tests/validate.test.ts": 474, - "packages/core/tests/validation-integration.test.ts": 746, - "packages/core/tests/workflow-component-bundle.test.ts": 922, - "packages/durable-streams/tests/context.test.ts": 317, - "packages/durable-streams/tests/deterministic-id.test.ts": 333, - "packages/durable-streams/tests/divergence-api.test.ts": 329, - "packages/durable-streams/tests/divergence.test.ts": 337, - "packages/durable-streams/tests/durable-each.test.ts": 344, - "packages/durable-streams/tests/durable-position.test.ts": 342, - "packages/durable-streams/tests/durable-run.test.ts": 353, - "packages/durable-streams/tests/ephemeral.test.ts": 384, - "packages/durable-streams/tests/fail-stop.test.ts": 354, - "packages/durable-streams/tests/guard-stream.test.ts": 381, - "packages/durable-streams/tests/live-coordinator.test.ts": 342, - "packages/durable-streams/tests/parse.test.ts": 351, - "packages/durable-streams/tests/replay-guard.test.ts": 356, - "packages/durable-streams/tests/replay-index.test.ts": 334, - "packages/durable-streams/tests/retained.test.ts": 326, - "packages/durable-streams/tests/serialize-event.test.ts": 342, - "packages/durable-streams/tests/smoke.test.ts": 325, - "packages/durable-streams/tests/structured-concurrency.test.ts": 361, - "packages/durable-streams/tests/terminal-boundary.test.ts": 349, - "packages/durable-streams/tests/types.test.ts": 303, - "packages/git/tests/api-entrypoint.test.ts": 769, - "packages/git/tests/git-host-effect.test.ts": 3784, - "packages/git/tests/git.test.ts": 798, - "packages/git/tests/github-activation.test.ts": 961, - "packages/git/tests/github-issues.test.ts": 707, - "packages/git/tests/github-pull-requests.test.ts": 366, - "packages/git/tests/issue-markdown.test.ts": 5401, - "packages/git/tests/issue-records.test.ts": 687, - "packages/git/tests/module-partition.test.ts": 1744, - "packages/git/tests/plugin.test.ts": 966, - "packages/git/tests/provider-neutrality.test.ts": 1305, - "packages/git/tests/pull-request-records.test.ts": 715, - "packages/git/tests/run-composition-lazy.test.ts": 1116, - "packages/git/tests/selection-authentication.test.ts": 301, - "packages/runtime/tests/duration.test.ts": 291, - "packages/runtime/tests/fetch.test.ts": 901, - "packages/runtime/tests/fs.test.ts": 339, - "packages/runtime/tests/host-files.test.ts": 1003, - "packages/runtime/tests/native-launcher.test.ts": 2661, - "packages/runtime/tests/process.test.ts": 351, - "packages/runtime/tests/service.test.ts": 342, - "packages/test-agent/tests/acp-server.test.ts": 408, - "packages/test-agent/tests/behavior-engine.test.ts": 936, - "packages/test-agent/tests/bridge.test.ts": 338, - "packages/test-agent/tests/command-lazy.test.ts": 4134, - "packages/test-agent/tests/components.test.ts": 65382, - "packages/test-agent/tests/controller.test.ts": 468, - "packages/test-agent/tests/cross-package-resolution.test.ts": 1671, - "packages/test-agent/tests/native-launch.test.ts": 30997, - "packages/test-agent/tests/net.test.ts": 363, - "packages/test-agent/tests/profile.test.ts": 672, - "packages/test-agent/tests/protocol.test.ts": 342, - "packages/test-agent/tests/provider.test.ts": 761, - "packages/test-agent/tests/public-api.test.ts": 877, - "packages/test-agent/tests/route-slot.test.ts": 307, - "packages/test-agent/tests/smoke.test.ts": 18154, - "packages/test-agent/tests/template.test.ts": 673, - "packages/test-agent/tests/worker-lifecycle.test.ts": 12945, - "packages/test-support/tests/journal.test.ts": 316, - "packages/testing/tests/agent-composition.test.ts": 852, - "packages/testing/tests/assert-throws.test.ts": 1760, - "packages/testing/tests/assert.test.ts": 310, - "packages/testing/tests/assertions.test.ts": 2104, - "packages/testing/tests/boundary-contract.test.ts": 1409, - "packages/testing/tests/cli.test.ts": 5888, - "packages/testing/tests/execution-harness.test.ts": 4042, - "packages/testing/tests/replay.test.ts": 1748, - "packages/testing/tests/smoke.test.ts": 1874, - "packages/testing/tests/test-component.test.ts": 2444, - "packages/testing/tests/testing-activation.test.ts": 2000, - "packages/testing/tests/testing-boundary.test.ts": 1160, - "packages/testing/tests/testing-mode.test.ts": 2336, - "packages/testing/tests/use-testing.test.ts": 1506, - "packages/web/tests/assets.test.ts": 318, - "packages/web/tests/client-logic.test.ts": 363, - "packages/web/tests/compile.test.ts": 1124, - "packages/web/tests/component.test.ts": 1474, - "packages/web/tests/declaration.test.ts": 940, - "packages/web/tests/document.test.ts": 1030, - "packages/web/tests/elicitation.test.ts": 892, - "packages/web/tests/live-form.test.ts": 831, - "packages/web/tests/markdown.test.ts": 396, - "packages/web/tests/opener.test.ts": 827, - "packages/web/tests/page.test.ts": 295, - "packages/web/tests/responder.test.ts": 1040, - "packages/web/tests/server-lifecycle.test.ts": 1014, - "packages/web/tests/server.test.ts": 1192, - "packages/workflow/tests/generated-observations.test.ts": 830, - "packages/workflow/tests/retained-run.test.ts": 1263, - "packages/workflow/tests/service-denial.test.ts": 319, - "packages/workflow/tests/workflow-bundle.test.ts": 945, - "packages/workflow/tests/workflow-definition.test.ts": 739, - "packages/workflow/tests/workflow-fork.test.ts": 677, - "packages/workflow/tests/workflow-run.test.ts": 1245, - "packages/workflow/tests/workspace-effect.test.ts": 1700, - "packages/workflow/tests/xmd-artifact.test.ts": 2045, - "scripts/tests/adapter-vendor.test.ts": 435, - "scripts/tests/bootstrap-npm-package.test.ts": 5289, - "scripts/tests/bump-version.test.ts": 440, - "scripts/tests/changed-paths.test.ts": 316, - "scripts/tests/ci-workflow.test.ts": 2291, - "scripts/tests/consumer-cycle.test.ts": 445, - "scripts/tests/filesystem-contract-workflow.test.ts": 382, - "scripts/tests/main-green.test.ts": 546, - "scripts/tests/main-health.test.ts": 367, - "scripts/tests/no-module-scoped-registry.test.ts": 807, - "scripts/tests/no-redundant-test-scope.test.ts": 3255, - "scripts/tests/no-sync-filesystem.test.ts": 3364, - "scripts/tests/no-yield-in-finally.test.ts": 797, - "scripts/tests/oxlint-policy.test.ts": 6473, - "scripts/tests/packaged-document.test.ts": 354, - "scripts/tests/prefer-effection-operation.test.ts": 3848, - "scripts/tests/prefer-effection-result.test.ts": 2779, - "scripts/tests/publish-workflow-membership.test.ts": 392, - "scripts/tests/review-infrastructure.test.ts": 1838, - "scripts/tests/runtime-exclusions.test.ts": 573, - "scripts/tests/runtime-tests.test.ts": 333, - "scripts/tests/scope-bound-event-registration.test.ts": 3197, - "scripts/tests/staged-write.test.ts": 334, - "scripts/tests/test-file-discovery.test.ts": 480, - "scripts/tests/test-shards.test.ts": 314, - "scripts/tests/test-weights.test.ts": 467, - "scripts/tests/tracked.test.ts": 320, - "scripts/tests/verify-coordinator.test.ts": 488, - "scripts/tests/version-lockstep.test.ts": 413, - "scripts/tests/web-client-module.test.ts": 327, - "scripts/tests/workspace.test.ts": 311 + "packages/acp/tests/acceptance.test.ts": 1938, + "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4778, + "packages/acp/tests/acpx-materialization.test.ts": 7002, + "packages/acp/tests/acpx-transient-env.test.ts": 729, + "packages/acp/tests/adapter-materialization.test.ts": 72138, + "packages/acp/tests/adapter-protocol.test.ts": 50348, + "packages/acp/tests/codex-surfaces.test.ts": 594, + "packages/acp/tests/native-launch.test.ts": 1960, + "packages/acp/tests/permission-bridge.test.ts": 1103, + "packages/acp/tests/provider.test.ts": 1931, + "packages/acp/tests/serial-queue.test.ts": 448, + "packages/acp/tests/session-key.test.ts": 477, + "packages/acp/tests/terminal-screen.test.ts": 550, + "packages/cli/tests/agent-adapters.test.ts": 2170, + "packages/cli/tests/agent-cli.test.ts": 35674, + "packages/cli/tests/agent-config.test.ts": 445, + "packages/cli/tests/agent-options-cli.test.ts": 13495, + "packages/cli/tests/agent-options.test.ts": 445, + "packages/cli/tests/agent-session-coordinator.test.ts": 1178, + "packages/cli/tests/cli-help.test.ts": 25826, + "packages/cli/tests/command.test.ts": 13058, + "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 3789, + "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 152031, + "packages/cli/tests/document-suites/props/props-markdown.test.ts": 3190, + "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 8329, + "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 5469, + "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 15256, + "packages/cli/tests/evaluate-component.test.ts": 13048, + "packages/cli/tests/fetch-cli.test.ts": 4161, + "packages/cli/tests/file-stream.test.ts": 489, + "packages/cli/tests/inline-cli.test.ts": 56000, + "packages/cli/tests/launch-timeout.test.ts": 20499, + "packages/cli/tests/packaged-document.test.ts": 1361, + "packages/cli/tests/plan-args.test.ts": 464, + "packages/cli/tests/plan-cli.test.ts": 98200, + "packages/cli/tests/plan-command-document.test.ts": 7235, + "packages/cli/tests/plan-component.test.ts": 9842, + "packages/cli/tests/plan-host-acts.test.ts": 2046, + "packages/cli/tests/plan.test.ts": 14362, + "packages/cli/tests/plugin-cli.test.ts": 68128, + "packages/cli/tests/plugin-host.test.ts": 2999, + "packages/cli/tests/plugin-modules.test.ts": 438, + "packages/cli/tests/plugin-selection.test.ts": 463, + "packages/cli/tests/process-retention.test.ts": 4143, + "packages/cli/tests/props-cli.test.ts": 52873, + "packages/cli/tests/props-schema.test.ts": 1178, + "packages/cli/tests/props-sources.test.ts": 629, + "packages/cli/tests/repl-composition.test.ts": 672, + "packages/cli/tests/repl-execution.test.ts": 2560, + "packages/cli/tests/repl-model.test.ts": 1692, + "packages/cli/tests/repl-route.test.ts": 1522, + "packages/cli/tests/repl-terminal.test.ts": 1196, + "packages/cli/tests/run-composition.test.ts": 3677, + "packages/cli/tests/run-deadline.test.ts": 3354, + "packages/cli/tests/run-profile.test.ts": 1115, + "packages/cli/tests/run-timeouts.test.ts": 17659, + "packages/cli/tests/secret-detection-cli.test.ts": 31779, + "packages/cli/tests/service-document.test.ts": 2391, + "packages/cli/tests/service-host.test.ts": 2125, + "packages/cli/tests/session-launch-cli.test.ts": 9257, + "packages/cli/tests/stdin-cli.test.ts": 53126, + "packages/cli/tests/stdout-delivery.test.ts": 458, + "packages/cli/tests/syntax-cli.test.ts": 52374, + "packages/cli/tests/targets-cli.test.ts": 42219, + "packages/cli/tests/test-root-profile.test.ts": 5964, + "packages/cli/tests/test-target.test.ts": 60898, + "packages/cli/tests/testing-activation.test.ts": 1273, + "packages/cli/tests/testing-execution-host.test.ts": 77038, + "packages/cli/tests/upgrade-cli.test.ts": 37529, + "packages/cli/tests/upgrade-command-document.test.ts": 4894, + "packages/cli/tests/upgrade-output.test.ts": 1615, + "packages/cli/tests/value-root.test.ts": 26199, + "packages/cli/tests/verbose-component.test.ts": 1659, + "packages/cli/tests/workflow-declaration.test.ts": 1279, + "packages/cli/tests/workflow-host.test.ts": 8807, + "packages/code-review-agent/tests/doctor.test.ts": 427, + "packages/code-review-agent/tests/parse-diagnostics.test.ts": 479, + "packages/code-review-agent/tests/parse-diff.test.ts": 455, + "packages/code-review-agent/tests/parse-doctor.test.ts": 1053, + "packages/code-review-agent/tests/parse-oxlint.test.ts": 448, + "packages/code-review-agent/tests/policy.test.ts": 448, + "packages/core/tests/agent-api.test.ts": 450, + "packages/core/tests/agent-components.test.ts": 1797, + "packages/core/tests/agent-function-components.test.ts": 3393, + "packages/core/tests/agent-session-launch.test.ts": 4061, + "packages/core/tests/agent-session-placement.test.ts": 462, + "packages/core/tests/agent-session-use.test.ts": 441, + "packages/core/tests/agent-stack.test.ts": 1342, + "packages/core/tests/answer-identity.test.ts": 471, + "packages/core/tests/answers-component.test.ts": 2116, + "packages/core/tests/answers-expansion-recursion.test.ts": 838, + "packages/core/tests/capture-errors.test.ts": 1092, + "packages/core/tests/capture-props.test.ts": 866, + "packages/core/tests/cli-journal.test.ts": 18551, + "packages/core/tests/code-block-component.test.ts": 2737, + "packages/core/tests/compiler-boundary.test.ts": 1017, + "packages/core/tests/component-api.test.ts": 490, + "packages/core/tests/component-registration.test.ts": 1860, + "packages/core/tests/component-returns.test.ts": 2465, + "packages/core/tests/component-schema-conformance.test.ts": 1012, + "packages/core/tests/config-api.test.ts": 1107, + "packages/core/tests/construct-error-observation.test.ts": 1179, + "packages/core/tests/contextual-cwd.test.ts": 2284, + "packages/core/tests/daemon-integration.test.ts": 1708, + "packages/core/tests/daemon.test.ts": 505, + "packages/core/tests/declared-markdown-component.test.ts": 2808, + "packages/core/tests/document-output-api.test.ts": 458, + "packages/core/tests/document-target-execution.test.ts": 4148, + "packages/core/tests/document-targets.test.ts": 1089, + "packages/core/tests/document-validation.test.ts": 2803, + "packages/core/tests/documentation-index.test.ts": 737, + "packages/core/tests/each.test.ts": 1142, + "packages/core/tests/elicit-component.test.ts": 1760, + "packages/core/tests/ephemeral-service.test.ts": 1587, + "packages/core/tests/eval-bindings.test.ts": 1132, + "packages/core/tests/eval-context.test.ts": 526, + "packages/core/tests/eval-durable.test.ts": 1158, + "packages/core/tests/eval-error-mode.test.ts": 1448, + "packages/core/tests/eval-interpolate.test.ts": 448, + "packages/core/tests/eval-middleware.test.ts": 539, + "packages/core/tests/eval-persist.test.ts": 1228, + "packages/core/tests/eval-return.test.ts": 1119, + "packages/core/tests/eval-scope.test.ts": 999, + "packages/core/tests/eval-timeout.test.ts": 1108, + "packages/core/tests/eval-transform.test.ts": 535, + "packages/core/tests/evaluate-component.test.ts": 3200, + "packages/core/tests/evaluate-loaded-copy.test.ts": 1184, + "packages/core/tests/evaluate-provider-lifetime.test.ts": 1256, + "packages/core/tests/evaluation-profile.test.ts": 774, + "packages/core/tests/exec-timeout.test.ts": 1151, + "packages/core/tests/execute.test.ts": 1740, + "packages/core/tests/execution-protocol.test.ts": 2560, + "packages/core/tests/expand.test.ts": 1768, + "packages/core/tests/expansion-identity.test.ts": 1780, + "packages/core/tests/expansion-metadata.test.ts": 904, + "packages/core/tests/expression-props.test.ts": 1695, + "packages/core/tests/fail-component.test.ts": 1453, + "packages/core/tests/failure-printing.test.ts": 1311, + "packages/core/tests/fatal-cause.test.ts": 782, + "packages/core/tests/fetch-component.test.ts": 2676, + "packages/core/tests/file-component.test.ts": 3120, + "packages/core/tests/file-delete-component.test.ts": 1306, + "packages/core/tests/files-fatal.test.ts": 2177, + "packages/core/tests/foreground-exec.test.ts": 2091, + "packages/core/tests/frontmatter.test.ts": 498, + "packages/core/tests/function-components.test.ts": 1795, + "packages/core/tests/generated-composition.test.ts": 1816, + "packages/core/tests/generated-xmd.test.ts": 3229, + "packages/core/tests/glob-api.test.ts": 523, + "packages/core/tests/glob-component.test.ts": 1907, + "packages/core/tests/guarded-journal.test.ts": 1182, + "packages/core/tests/has-content.test.ts": 807, + "packages/core/tests/heal.test.ts": 843, + "packages/core/tests/if.test.ts": 1619, + "packages/core/tests/inline-root.test.ts": 1202, + "packages/core/tests/invocation-failures.test.ts": 544, + "packages/core/tests/invocation-identity.test.ts": 1548, + "packages/core/tests/invocation-scope.test.ts": 1068, + "packages/core/tests/journal-source-position.test.ts": 1292, + "packages/core/tests/json-component.test.ts": 1360, + "packages/core/tests/json.test.ts": 462, + "packages/core/tests/let.test.ts": 1152, + "packages/core/tests/loop.test.ts": 2036, + "packages/core/tests/named-slots.test.ts": 2073, + "packages/core/tests/output-error-mode.test.ts": 2340, + "packages/core/tests/output-normalize.test.ts": 464, + "packages/core/tests/output-terminal.test.ts": 626, + "packages/core/tests/parse-components.test.ts": 1304, + "packages/core/tests/plan-response.test.ts": 538, + "packages/core/tests/plugin-api.test.ts": 486, + "packages/core/tests/plugin.test.ts": 884, + "packages/core/tests/props-binding.test.ts": 1295, + "packages/core/tests/protected-content.test.ts": 497, + "packages/core/tests/registered-printed-errors.test.ts": 767, + "packages/core/tests/replay-stream.test.ts": 451, + "packages/core/tests/retain.test.ts": 1037, + "packages/core/tests/root-composition.test.ts": 1279, + "packages/core/tests/root-props.test.ts": 1298, + "packages/core/tests/root-provider.test.ts": 1195, + "packages/core/tests/sample-component.test.ts": 2088, + "packages/core/tests/scanner.test.ts": 521, + "packages/core/tests/scope-local.test.ts": 456, + "packages/core/tests/secret-detection.test.ts": 1897, + "packages/core/tests/secret-files.test.ts": 687, + "packages/core/tests/secret-rules.test.ts": 794, + "packages/core/tests/secret-scanner-baseline.test.ts": 704, + "packages/core/tests/secret-scanner.test.ts": 641, + "packages/core/tests/source-position.test.ts": 1014, + "packages/core/tests/state-ownership.test.ts": 1068, + "packages/core/tests/streaming-emission.test.ts": 1022, + "packages/core/tests/switch.test.ts": 1684, + "packages/core/tests/syntax-catalog.test.ts": 1636, + "packages/core/tests/syntax-component.test.ts": 2426, + "packages/core/tests/temp-dir.test.ts": 3533, + "packages/core/tests/temp-file-compiler.test.ts": 508, + "packages/core/tests/text-interpolation.test.ts": 794, + "packages/core/tests/try-content.test.ts": 818, + "packages/core/tests/unused-in-diff.test.ts": 1205, + "packages/core/tests/validate.test.ts": 735, + "packages/core/tests/validation-integration.test.ts": 1052, + "packages/core/tests/workflow-component-bundle.test.ts": 1387, + "packages/durable-streams/tests/context.test.ts": 479, + "packages/durable-streams/tests/deterministic-id.test.ts": 510, + "packages/durable-streams/tests/divergence-api.test.ts": 495, + "packages/durable-streams/tests/divergence.test.ts": 521, + "packages/durable-streams/tests/durable-each.test.ts": 522, + "packages/durable-streams/tests/durable-position.test.ts": 504, + "packages/durable-streams/tests/durable-run.test.ts": 507, + "packages/durable-streams/tests/ephemeral.test.ts": 506, + "packages/durable-streams/tests/fail-stop.test.ts": 562, + "packages/durable-streams/tests/guard-stream.test.ts": 563, + "packages/durable-streams/tests/live-coordinator.test.ts": 500, + "packages/durable-streams/tests/parse.test.ts": 512, + "packages/durable-streams/tests/replay-guard.test.ts": 522, + "packages/durable-streams/tests/replay-index.test.ts": 483, + "packages/durable-streams/tests/retained.test.ts": 471, + "packages/durable-streams/tests/serialize-event.test.ts": 492, + "packages/durable-streams/tests/smoke.test.ts": 508, + "packages/durable-streams/tests/structured-concurrency.test.ts": 551, + "packages/durable-streams/tests/terminal-boundary.test.ts": 517, + "packages/durable-streams/tests/types.test.ts": 450, + "packages/git/tests/api-entrypoint.test.ts": 1119, + "packages/git/tests/git-host-effect.test.ts": 6430, + "packages/git/tests/git.test.ts": 1131, + "packages/git/tests/github-activation.test.ts": 1434, + "packages/git/tests/github-issues.test.ts": 1073, + "packages/git/tests/github-pull-requests.test.ts": 542, + "packages/git/tests/issue-markdown.test.ts": 9143, + "packages/git/tests/issue-records.test.ts": 1047, + "packages/git/tests/module-partition.test.ts": 2661, + "packages/git/tests/plugin.test.ts": 1514, + "packages/git/tests/provider-neutrality.test.ts": 2039, + "packages/git/tests/pull-request-records.test.ts": 1105, + "packages/git/tests/run-composition-lazy.test.ts": 1805, + "packages/git/tests/selection-authentication.test.ts": 461, + "packages/runtime/tests/duration.test.ts": 446, + "packages/runtime/tests/fetch.test.ts": 1049, + "packages/runtime/tests/fs.test.ts": 499, + "packages/runtime/tests/host-files.test.ts": 1219, + "packages/runtime/tests/native-launcher.test.ts": 2801, + "packages/runtime/tests/process.test.ts": 493, + "packages/runtime/tests/service.test.ts": 478, + "packages/test-agent/tests/acp-server.test.ts": 607, + "packages/test-agent/tests/behavior-engine.test.ts": 1403, + "packages/test-agent/tests/bridge.test.ts": 475, + "packages/test-agent/tests/command-lazy.test.ts": 5674, + "packages/test-agent/tests/components.test.ts": 91334, + "packages/test-agent/tests/controller.test.ts": 715, + "packages/test-agent/tests/cross-package-resolution.test.ts": 2578, + "packages/test-agent/tests/native-launch.test.ts": 45647, + "packages/test-agent/tests/net.test.ts": 538, + "packages/test-agent/tests/profile.test.ts": 1044, + "packages/test-agent/tests/protocol.test.ts": 518, + "packages/test-agent/tests/provider.test.ts": 1162, + "packages/test-agent/tests/public-api.test.ts": 1360, + "packages/test-agent/tests/route-slot.test.ts": 460, + "packages/test-agent/tests/smoke.test.ts": 24811, + "packages/test-agent/tests/template.test.ts": 1030, + "packages/test-agent/tests/worker-lifecycle.test.ts": 19686, + "packages/test-support/tests/journal.test.ts": 449, + "packages/testing/tests/agent-composition.test.ts": 1315, + "packages/testing/tests/assert-throws.test.ts": 2789, + "packages/testing/tests/assert.test.ts": 474, + "packages/testing/tests/assertions.test.ts": 3439, + "packages/testing/tests/boundary-contract.test.ts": 2288, + "packages/testing/tests/cli.test.ts": 9209, + "packages/testing/tests/execution-harness.test.ts": 6818, + "packages/testing/tests/replay.test.ts": 2793, + "packages/testing/tests/smoke.test.ts": 2855, + "packages/testing/tests/test-component.test.ts": 3610, + "packages/testing/tests/testing-activation.test.ts": 3089, + "packages/testing/tests/testing-boundary.test.ts": 1788, + "packages/testing/tests/testing-mode.test.ts": 3703, + "packages/testing/tests/use-testing.test.ts": 2450, + "packages/web/tests/assets.test.ts": 455, + "packages/web/tests/client-logic.test.ts": 480, + "packages/web/tests/compile.test.ts": 1703, + "packages/web/tests/component.test.ts": 2198, + "packages/web/tests/declaration.test.ts": 1459, + "packages/web/tests/document.test.ts": 1514, + "packages/web/tests/elicitation.test.ts": 1370, + "packages/web/tests/live-form.test.ts": 1291, + "packages/web/tests/markdown.test.ts": 597, + "packages/web/tests/opener.test.ts": 1279, + "packages/web/tests/page.test.ts": 453, + "packages/web/tests/responder.test.ts": 1571, + "packages/web/tests/server-lifecycle.test.ts": 1398, + "packages/web/tests/server.test.ts": 1813, + "packages/workflow/tests/generated-observations.test.ts": 1253, + "packages/workflow/tests/retained-run.test.ts": 1926, + "packages/workflow/tests/service-denial.test.ts": 461, + "packages/workflow/tests/workflow-bundle.test.ts": 1460, + "packages/workflow/tests/workflow-definition.test.ts": 1172, + "packages/workflow/tests/workflow-fork.test.ts": 1038, + "packages/workflow/tests/workflow-run.test.ts": 1839, + "packages/workflow/tests/workspace-effect.test.ts": 2652, + "packages/workflow/tests/xmd-artifact.test.ts": 3522, + "scripts/tests/adapter-vendor.test.ts": 657, + "scripts/tests/bootstrap-npm-package.test.ts": 7338, + "scripts/tests/bump-version.test.ts": 563, + "scripts/tests/changed-paths.test.ts": 477, + "scripts/tests/ci-workflow.test.ts": 3191, + "scripts/tests/consumer-cycle.test.ts": 615, + "scripts/tests/filesystem-contract-workflow.test.ts": 523, + "scripts/tests/main-green.test.ts": 722, + "scripts/tests/main-health.test.ts": 558, + "scripts/tests/no-module-scoped-registry.test.ts": 1169, + "scripts/tests/no-redundant-test-scope.test.ts": 4617, + "scripts/tests/no-sync-filesystem.test.ts": 4929, + "scripts/tests/no-yield-in-finally.test.ts": 1166, + "scripts/tests/oxlint-policy.test.ts": 9186, + "scripts/tests/packaged-document.test.ts": 542, + "scripts/tests/prefer-effection-operation.test.ts": 5702, + "scripts/tests/prefer-effection-result.test.ts": 4004, + "scripts/tests/publish-workflow-membership.test.ts": 597, + "scripts/tests/review-infrastructure.test.ts": 2845, + "scripts/tests/runtime-exclusions.test.ts": 904, + "scripts/tests/runtime-tests.test.ts": 508, + "scripts/tests/scope-bound-event-registration.test.ts": 4708, + "scripts/tests/staged-write.test.ts": 492, + "scripts/tests/test-file-discovery.test.ts": 732, + "scripts/tests/test-shards.test.ts": 497, + "scripts/tests/test-weights.test.ts": 660, + "scripts/tests/tracked.test.ts": 479, + "scripts/tests/verify-coordinator.test.ts": 670, + "scripts/tests/version-lockstep.test.ts": 613, + "scripts/tests/web-client-module.test.ts": 464, + "scripts/tests/workspace.test.ts": 453 }, "bun": { - "packages/acp/tests/acceptance.test.ts": 476, - "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4212, - "packages/acp/tests/acpx-materialization.test.ts": 6394, - "packages/acp/tests/acpx-transient-env.test.ts": 161, - "packages/acp/tests/adapter-materialization.test.ts": 43821, - "packages/acp/tests/adapter-protocol.test.ts": 32539, - "packages/acp/tests/codex-surfaces.test.ts": 73, - "packages/acp/tests/native-launch.test.ts": 734, - "packages/acp/tests/permission-bridge.test.ts": 234, - "packages/acp/tests/provider.test.ts": 843, - "packages/acp/tests/serial-queue.test.ts": 53, - "packages/acp/tests/session-key.test.ts": 52, - "packages/acp/tests/terminal-screen.test.ts": 56, - "packages/cli/tests/agent-adapters.test.ts": 695, - "packages/cli/tests/agent-cli.test.ts": 12465, - "packages/cli/tests/agent-config.test.ts": 20, - "packages/cli/tests/agent-options-cli.test.ts": 4835, - "packages/cli/tests/agent-options.test.ts": 21, - "packages/cli/tests/agent-session-coordinator.test.ts": 238, - "packages/cli/tests/cli-help.test.ts": 6709, - "packages/cli/tests/command.test.ts": 5933, - "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 1671, - "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 84321, - "packages/cli/tests/document-suites/props/props-markdown.test.ts": 1438, - "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 2935, - "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 2792, - "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 5180, - "packages/cli/tests/evaluate-component.test.ts": 4505, - "packages/cli/tests/fetch-cli.test.ts": 1349, - "packages/cli/tests/file-stream.test.ts": 41, - "packages/cli/tests/inline-cli.test.ts": 18600, - "packages/cli/tests/launch-timeout.test.ts": 10064, - "packages/cli/tests/packaged-document.test.ts": 288, - "packages/cli/tests/plan-args.test.ts": 23, - "packages/cli/tests/plan-cli.test.ts": 36529, - "packages/cli/tests/plan-command-document.test.ts": 3735, - "packages/cli/tests/plan-component.test.ts": 5651, - "packages/cli/tests/plan-host-acts.test.ts": 702, - "packages/cli/tests/plan.test.ts": 8228, - "packages/cli/tests/plugin-cli.test.ts": 23767, - "packages/cli/tests/plugin-host.test.ts": 1239, - "packages/cli/tests/plugin-modules.test.ts": 25, - "packages/cli/tests/plugin-selection.test.ts": 25, - "packages/cli/tests/process-retention.test.ts": 1589, - "packages/cli/tests/props-cli.test.ts": 16657, - "packages/cli/tests/props-schema.test.ts": 239, - "packages/cli/tests/props-sources.test.ts": 88, - "packages/cli/tests/repl-composition.test.ts": 92, - "packages/cli/tests/repl-execution.test.ts": 1275, - "packages/cli/tests/repl-model.test.ts": 606, - "packages/cli/tests/repl-route.test.ts": 492, - "packages/cli/tests/run-composition.test.ts": 1941, - "packages/cli/tests/run-deadline.test.ts": 2030, - "packages/cli/tests/run-profile.test.ts": 198, - "packages/cli/tests/run-timeouts.test.ts": 8337, - "packages/cli/tests/secret-detection-cli.test.ts": 10975, - "packages/cli/tests/service-document.test.ts": 1070, - "packages/cli/tests/service-host.test.ts": 957, - "packages/cli/tests/session-launch-cli.test.ts": 3591, - "packages/cli/tests/stdin-cli.test.ts": 19893, - "packages/cli/tests/stdout-delivery.test.ts": 35, - "packages/cli/tests/syntax-cli.test.ts": 19614, - "packages/cli/tests/targets-cli.test.ts": 12552, - "packages/cli/tests/test-root-profile.test.ts": 2298, - "packages/cli/tests/test-target.test.ts": 21000, - "packages/cli/tests/testing-activation.test.ts": 361, - "packages/cli/tests/testing-execution-host.test.ts": 33370, - "packages/cli/tests/upgrade-cli.test.ts": 11035, - "packages/cli/tests/upgrade-command-document.test.ts": 2550, - "packages/cli/tests/upgrade-output.test.ts": 552, - "packages/cli/tests/value-root.test.ts": 9996, - "packages/cli/tests/verbose-component.test.ts": 472, - "packages/cli/tests/workflow-declaration.test.ts": 323, - "packages/cli/tests/workflow-host.test.ts": 2653, - "packages/code-review-agent/tests/doctor.test.ts": 21, - "packages/code-review-agent/tests/parse-diagnostics.test.ts": 26, - "packages/code-review-agent/tests/parse-diff.test.ts": 24, - "packages/code-review-agent/tests/parse-doctor.test.ts": 194, - "packages/code-review-agent/tests/parse-oxlint.test.ts": 21, - "packages/code-review-agent/tests/policy.test.ts": 22, - "packages/core/tests/agent-api.test.ts": 26, - "packages/core/tests/agent-components.test.ts": 769, - "packages/core/tests/agent-function-components.test.ts": 1895, - "packages/core/tests/agent-session-launch.test.ts": 2171, - "packages/core/tests/agent-session-placement.test.ts": 25, - "packages/core/tests/agent-session-use.test.ts": 23, - "packages/core/tests/agent-stack.test.ts": 437, - "packages/core/tests/answer-identity.test.ts": 44, - "packages/core/tests/answers-component.test.ts": 1019, - "packages/core/tests/answers-expansion-recursion.test.ts": 203, - "packages/core/tests/capture-errors.test.ts": 374, - "packages/core/tests/capture-props.test.ts": 232, - "packages/core/tests/cli-journal.test.ts": 6124, - "packages/core/tests/code-block-component.test.ts": 1576, - "packages/core/tests/compiler-boundary.test.ts": 208, - "packages/core/tests/component-api.test.ts": 43, - "packages/core/tests/component-registration.test.ts": 672, - "packages/core/tests/component-returns.test.ts": 1007, - "packages/core/tests/component-schema-conformance.test.ts": 330, - "packages/core/tests/config-api.test.ts": 213, - "packages/core/tests/construct-error-observation.test.ts": 455, - "packages/core/tests/contextual-cwd.test.ts": 1344, - "packages/core/tests/daemon-integration.test.ts": 825, - "packages/core/tests/daemon.test.ts": 46, - "packages/core/tests/declared-markdown-component.test.ts": 1257, - "packages/core/tests/document-output-api.test.ts": 25, - "packages/core/tests/document-target-execution.test.ts": 2275, - "packages/core/tests/document-targets.test.ts": 255, - "packages/core/tests/document-validation.test.ts": 1233, - "packages/core/tests/documentation-index.test.ts": 110, - "packages/core/tests/each.test.ts": 248, - "packages/core/tests/elicit-component.test.ts": 710, - "packages/core/tests/ephemeral-service.test.ts": 514, - "packages/core/tests/eval-bindings.test.ts": 245, - "packages/core/tests/eval-context.test.ts": 53, - "packages/core/tests/eval-durable.test.ts": 254, - "packages/core/tests/eval-error-mode.test.ts": 427, - "packages/core/tests/eval-interpolate.test.ts": 55, - "packages/core/tests/eval-middleware.test.ts": 65, - "packages/core/tests/eval-persist.test.ts": 317, - "packages/core/tests/eval-return.test.ts": 275, - "packages/core/tests/eval-scope.test.ts": 209, - "packages/core/tests/eval-timeout.test.ts": 283, - "packages/core/tests/eval-transform.test.ts": 62, - "packages/core/tests/evaluate-component.test.ts": 1588, - "packages/core/tests/evaluate-loaded-copy.test.ts": 318, - "packages/core/tests/evaluate-provider-lifetime.test.ts": 407, - "packages/core/tests/evaluation-profile.test.ts": 135, - "packages/core/tests/exec-timeout.test.ts": 303, - "packages/core/tests/execute.test.ts": 647, - "packages/core/tests/execution-protocol.test.ts": 1044, - "packages/core/tests/expand.test.ts": 795, - "packages/core/tests/expansion-identity.test.ts": 766, - "packages/core/tests/expansion-metadata.test.ts": 206, - "packages/core/tests/expression-props.test.ts": 562, - "packages/core/tests/fail-component.test.ts": 493, - "packages/core/tests/failure-printing.test.ts": 460, - "packages/core/tests/fatal-cause.test.ts": 113, - "packages/core/tests/fetch-component.test.ts": 1172, - "packages/core/tests/file-component.test.ts": 1914, - "packages/core/tests/file-delete-component.test.ts": 457, - "packages/core/tests/files-fatal.test.ts": 946, - "packages/core/tests/foreground-exec.test.ts": 812, - "packages/core/tests/frontmatter.test.ts": 30, - "packages/core/tests/function-components.test.ts": 576, - "packages/core/tests/generated-composition.test.ts": 833, - "packages/core/tests/generated-xmd.test.ts": 1598, - "packages/core/tests/glob-api.test.ts": 66, - "packages/core/tests/glob-component.test.ts": 871, - "packages/core/tests/guarded-journal.test.ts": 284, - "packages/core/tests/has-content.test.ts": 167, - "packages/core/tests/heal.test.ts": 163, - "packages/core/tests/if.test.ts": 491, - "packages/core/tests/inline-root.test.ts": 298, - "packages/core/tests/invocation-failures.test.ts": 78, - "packages/core/tests/invocation-identity.test.ts": 506, - "packages/core/tests/invocation-scope.test.ts": 365, - "packages/core/tests/journal-source-position.test.ts": 346, - "packages/core/tests/json-component.test.ts": 1494, - "packages/core/tests/json.test.ts": 22, - "packages/core/tests/let.test.ts": 270, - "packages/core/tests/loop.test.ts": 768, - "packages/core/tests/named-slots.test.ts": 818, - "packages/core/tests/output-error-mode.test.ts": 1085, - "packages/core/tests/output-normalize.test.ts": 29, - "packages/core/tests/output-terminal.test.ts": 99, - "packages/core/tests/parse-components.test.ts": 396, - "packages/core/tests/plan-response.test.ts": 67, - "packages/core/tests/plugin-api.test.ts": 30, - "packages/core/tests/plugin.test.ts": 114, - "packages/core/tests/props-binding.test.ts": 351, - "packages/core/tests/protected-content.test.ts": 31, - "packages/core/tests/registered-printed-errors.test.ts": 131, - "packages/core/tests/replay-stream.test.ts": 25, - "packages/core/tests/retain.test.ts": 324, - "packages/core/tests/root-composition.test.ts": 365, - "packages/core/tests/root-props.test.ts": 369, - "packages/core/tests/root-provider.test.ts": 364, - "packages/core/tests/sample-component.test.ts": 760, - "packages/core/tests/scanner.test.ts": 48, - "packages/core/tests/scope-local.test.ts": 24, - "packages/core/tests/secret-detection.test.ts": 794, - "packages/core/tests/secret-files.test.ts": 82, - "packages/core/tests/secret-rules.test.ts": 90, - "packages/core/tests/secret-scanner-baseline.test.ts": 86, - "packages/core/tests/secret-scanner.test.ts": 78, - "packages/core/tests/source-position.test.ts": 210, - "packages/core/tests/state-ownership.test.ts": 247, - "packages/core/tests/streaming-emission.test.ts": 196, - "packages/core/tests/switch.test.ts": 549, - "packages/core/tests/syntax-catalog.test.ts": 579, - "packages/core/tests/syntax-component.test.ts": 1224, - "packages/core/tests/temp-dir.test.ts": 2545, - "packages/core/tests/temp-file-compiler.test.ts": 50, - "packages/core/tests/text-interpolation.test.ts": 143, - "packages/core/tests/try-content.test.ts": 166, - "packages/core/tests/unused-in-diff.test.ts": 319, - "packages/core/tests/validate.test.ts": 200, - "packages/core/tests/validation-integration.test.ts": 235, - "packages/core/tests/workflow-component-bundle.test.ts": 463, - "packages/durable-streams/tests/context.test.ts": 58, - "packages/durable-streams/tests/deterministic-id.test.ts": 61, - "packages/durable-streams/tests/divergence-api.test.ts": 32, - "packages/durable-streams/tests/divergence.test.ts": 38, - "packages/durable-streams/tests/durable-each.test.ts": 42, - "packages/durable-streams/tests/durable-position.test.ts": 34, - "packages/durable-streams/tests/durable-run.test.ts": 41, - "packages/durable-streams/tests/ephemeral.test.ts": 38, - "packages/durable-streams/tests/fail-stop.test.ts": 46, - "packages/durable-streams/tests/guard-stream.test.ts": 82, - "packages/durable-streams/tests/live-coordinator.test.ts": 37, - "packages/durable-streams/tests/parse.test.ts": 32, - "packages/durable-streams/tests/replay-guard.test.ts": 45, - "packages/durable-streams/tests/replay-index.test.ts": 30, - "packages/durable-streams/tests/retained.test.ts": 29, - "packages/durable-streams/tests/serialize-event.test.ts": 31, - "packages/durable-streams/tests/smoke.test.ts": 24, - "packages/durable-streams/tests/structured-concurrency.test.ts": 56, - "packages/durable-streams/tests/terminal-boundary.test.ts": 49, - "packages/durable-streams/tests/types.test.ts": 21, - "packages/git/tests/api-entrypoint.test.ts": 212, - "packages/git/tests/git-host-effect.test.ts": 4120, - "packages/git/tests/git.test.ts": 238, - "packages/git/tests/github-activation.test.ts": 414, - "packages/git/tests/github-issues.test.ts": 190, - "packages/git/tests/github-pull-requests.test.ts": 51, - "packages/git/tests/issue-markdown.test.ts": 5438, - "packages/git/tests/issue-records.test.ts": 198, - "packages/git/tests/module-partition.test.ts": 1119, - "packages/git/tests/plugin.test.ts": 533, - "packages/git/tests/provider-neutrality.test.ts": 688, - "packages/git/tests/pull-request-records.test.ts": 201, - "packages/git/tests/run-composition-lazy.test.ts": 496, - "packages/git/tests/selection-authentication.test.ts": 27, - "packages/runtime/tests/duration.test.ts": 22, - "packages/runtime/tests/fetch.test.ts": 584, - "packages/runtime/tests/fs.test.ts": 52, - "packages/runtime/tests/host-files.test.ts": 661, - "packages/runtime/tests/native-launcher.test.ts": 2354, - "packages/runtime/tests/process.test.ts": 59, - "packages/runtime/tests/service.test.ts": 42, - "packages/test-agent/tests/acp-server.test.ts": 66, - "packages/test-agent/tests/behavior-engine.test.ts": 394, - "packages/test-agent/tests/bridge.test.ts": 55, - "packages/test-agent/tests/command-lazy.test.ts": 2395, - "packages/test-agent/tests/components.test.ts": 38421, - "packages/test-agent/tests/controller.test.ts": 129, - "packages/test-agent/tests/cross-package-resolution.test.ts": 1112, - "packages/test-agent/tests/native-launch.test.ts": 15994, - "packages/test-agent/tests/net.test.ts": 76, - "packages/test-agent/tests/profile.test.ts": 182, - "packages/test-agent/tests/protocol.test.ts": 35, - "packages/test-agent/tests/provider.test.ts": 248, - "packages/test-agent/tests/public-api.test.ts": 283, - "packages/test-agent/tests/route-slot.test.ts": 46, - "packages/test-agent/tests/smoke.test.ts": 11864, - "packages/test-agent/tests/template.test.ts": 181, - "packages/test-agent/tests/worker-lifecycle.test.ts": 5563, - "packages/test-support/tests/journal.test.ts": 27, - "packages/testing/tests/agent-composition.test.ts": 382, - "packages/testing/tests/assert-throws.test.ts": 1421, - "packages/testing/tests/assert.test.ts": 35, - "packages/testing/tests/assertions.test.ts": 1950, - "packages/testing/tests/boundary-contract.test.ts": 1032, - "packages/testing/tests/cli.test.ts": 3048, - "packages/testing/tests/execution-harness.test.ts": 4299, - "packages/testing/tests/replay.test.ts": 1395, - "packages/testing/tests/smoke.test.ts": 1300, - "packages/testing/tests/test-component.test.ts": 2035, - "packages/testing/tests/testing-activation.test.ts": 1696, - "packages/testing/tests/testing-boundary.test.ts": 730, - "packages/testing/tests/testing-mode.test.ts": 2024, - "packages/testing/tests/use-testing.test.ts": 1221, - "packages/web/tests/assets.test.ts": 21, - "packages/web/tests/client-logic.test.ts": 25, - "packages/web/tests/compile.test.ts": 602, - "packages/web/tests/component.test.ts": 1023, - "packages/web/tests/declaration.test.ts": 411, - "packages/web/tests/document.test.ts": 488, - "packages/web/tests/elicitation.test.ts": 360, - "packages/web/tests/live-form.test.ts": 372, - "packages/web/tests/markdown.test.ts": 103, - "packages/web/tests/opener.test.ts": 310, - "packages/web/tests/page.test.ts": 22, - "packages/web/tests/responder.test.ts": 518, - "packages/web/tests/server-lifecycle.test.ts": 375, - "packages/web/tests/server.test.ts": 682, - "packages/workflow/tests/generated-observations.test.ts": 346, - "packages/workflow/tests/retained-run.test.ts": 816, - "packages/workflow/tests/service-denial.test.ts": 47, - "packages/workflow/tests/workflow-bundle.test.ts": 477, - "packages/workflow/tests/workflow-definition.test.ts": 217, - "packages/workflow/tests/workflow-fork.test.ts": 194, - "packages/workflow/tests/workflow-run.test.ts": 662, - "packages/workflow/tests/workspace-effect.test.ts": 902, - "scripts/tests/adapter-vendor.test.ts": 106, - "scripts/tests/bootstrap-npm-package.test.ts": 4054, - "scripts/tests/bump-version.test.ts": 57, - "scripts/tests/changed-paths.test.ts": 25, - "scripts/tests/ci-workflow.test.ts": 1249, - "scripts/tests/consumer-cycle.test.ts": 78, - "scripts/tests/filesystem-contract-workflow.test.ts": 57, - "scripts/tests/main-green.test.ts": 264, - "scripts/tests/main-health.test.ts": 70, - "scripts/tests/no-module-scoped-registry.test.ts": 574, - "scripts/tests/no-redundant-test-scope.test.ts": 3101, - "scripts/tests/no-sync-filesystem.test.ts": 2925, - "scripts/tests/no-yield-in-finally.test.ts": 599, - "scripts/tests/oxlint-policy.test.ts": 6486, - "scripts/tests/packaged-document.test.ts": 49, - "scripts/tests/prefer-effection-operation.test.ts": 3631, - "scripts/tests/prefer-effection-result.test.ts": 2532, - "scripts/tests/publish-workflow-membership.test.ts": 68, - "scripts/tests/review-infrastructure.test.ts": 1132, - "scripts/tests/runtime-exclusions.test.ts": 185, - "scripts/tests/runtime-tests.test.ts": 68, - "scripts/tests/scope-bound-event-registration.test.ts": 2706, - "scripts/tests/staged-write.test.ts": 47, - "scripts/tests/test-file-discovery.test.ts": 154, - "scripts/tests/test-shards.test.ts": 29, - "scripts/tests/test-weights.test.ts": 171, - "scripts/tests/tracked.test.ts": 25, - "scripts/tests/verify-coordinator.test.ts": 206, - "scripts/tests/version-lockstep.test.ts": 78, - "scripts/tests/web-client-module.test.ts": 39, - "scripts/tests/workspace.test.ts": 31 + "packages/acp/tests/acceptance.test.ts": 744, + "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4286, + "packages/acp/tests/acpx-materialization.test.ts": 6484, + "packages/acp/tests/acpx-transient-env.test.ts": 248, + "packages/acp/tests/adapter-materialization.test.ts": 66479, + "packages/acp/tests/adapter-protocol.test.ts": 49439, + "packages/acp/tests/codex-surfaces.test.ts": 118, + "packages/acp/tests/native-launch.test.ts": 951, + "packages/acp/tests/permission-bridge.test.ts": 339, + "packages/acp/tests/provider.test.ts": 1055, + "packages/acp/tests/serial-queue.test.ts": 63, + "packages/acp/tests/session-key.test.ts": 67, + "packages/acp/tests/terminal-screen.test.ts": 80, + "packages/cli/tests/agent-adapters.test.ts": 1099, + "packages/cli/tests/agent-cli.test.ts": 19452, + "packages/cli/tests/agent-config.test.ts": 26, + "packages/cli/tests/agent-options-cli.test.ts": 7289, + "packages/cli/tests/agent-options.test.ts": 29, + "packages/cli/tests/agent-session-coordinator.test.ts": 367, + "packages/cli/tests/cli-help.test.ts": 10618, + "packages/cli/tests/command.test.ts": 7507, + "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 2750, + "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 100519, + "packages/cli/tests/document-suites/props/props-markdown.test.ts": 2160, + "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 4408, + "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 4564, + "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 8036, + "packages/cli/tests/evaluate-component.test.ts": 7095, + "packages/cli/tests/fetch-cli.test.ts": 2106, + "packages/cli/tests/file-stream.test.ts": 53, + "packages/cli/tests/inline-cli.test.ts": 28810, + "packages/cli/tests/launch-timeout.test.ts": 10079, + "packages/cli/tests/packaged-document.test.ts": 428, + "packages/cli/tests/plan-args.test.ts": 30, + "packages/cli/tests/plan-cli.test.ts": 54947, + "packages/cli/tests/plan-command-document.test.ts": 5393, + "packages/cli/tests/plan-component.test.ts": 8517, + "packages/cli/tests/plan-host-acts.test.ts": 1118, + "packages/cli/tests/plan.test.ts": 12187, + "packages/cli/tests/plugin-cli.test.ts": 35808, + "packages/cli/tests/plugin-host.test.ts": 1844, + "packages/cli/tests/plugin-modules.test.ts": 33, + "packages/cli/tests/plugin-selection.test.ts": 31, + "packages/cli/tests/process-retention.test.ts": 2291, + "packages/cli/tests/props-cli.test.ts": 25899, + "packages/cli/tests/props-schema.test.ts": 383, + "packages/cli/tests/props-sources.test.ts": 117, + "packages/cli/tests/repl-composition.test.ts": 132, + "packages/cli/tests/repl-execution.test.ts": 1763, + "packages/cli/tests/repl-model.test.ts": 890, + "packages/cli/tests/repl-route.test.ts": 724, + "packages/cli/tests/repl-terminal.test.ts": 799, + "packages/cli/tests/run-composition.test.ts": 2884, + "packages/cli/tests/run-deadline.test.ts": 2305, + "packages/cli/tests/run-profile.test.ts": 285, + "packages/cli/tests/run-timeouts.test.ts": 10667, + "packages/cli/tests/secret-detection-cli.test.ts": 16731, + "packages/cli/tests/service-document.test.ts": 1429, + "packages/cli/tests/service-host.test.ts": 1328, + "packages/cli/tests/session-launch-cli.test.ts": 5280, + "packages/cli/tests/stdin-cli.test.ts": 28646, + "packages/cli/tests/stdout-delivery.test.ts": 46, + "packages/cli/tests/syntax-cli.test.ts": 30236, + "packages/cli/tests/targets-cli.test.ts": 19960, + "packages/cli/tests/test-root-profile.test.ts": 3486, + "packages/cli/tests/test-target.test.ts": 32810, + "packages/cli/tests/testing-activation.test.ts": 531, + "packages/cli/tests/testing-execution-host.test.ts": 45619, + "packages/cli/tests/upgrade-cli.test.ts": 16213, + "packages/cli/tests/upgrade-command-document.test.ts": 3396, + "packages/cli/tests/upgrade-output.test.ts": 781, + "packages/cli/tests/value-root.test.ts": 14408, + "packages/cli/tests/verbose-component.test.ts": 708, + "packages/cli/tests/workflow-declaration.test.ts": 470, + "packages/cli/tests/workflow-host.test.ts": 3873, + "packages/code-review-agent/tests/doctor.test.ts": 27, + "packages/code-review-agent/tests/parse-diagnostics.test.ts": 34, + "packages/code-review-agent/tests/parse-diff.test.ts": 32, + "packages/code-review-agent/tests/parse-doctor.test.ts": 272, + "packages/code-review-agent/tests/parse-oxlint.test.ts": 27, + "packages/code-review-agent/tests/policy.test.ts": 28, + "packages/core/tests/agent-api.test.ts": 30, + "packages/core/tests/agent-components.test.ts": 1143, + "packages/core/tests/agent-function-components.test.ts": 2785, + "packages/core/tests/agent-session-launch.test.ts": 3217, + "packages/core/tests/agent-session-placement.test.ts": 32, + "packages/core/tests/agent-session-use.test.ts": 27, + "packages/core/tests/agent-stack.test.ts": 655, + "packages/core/tests/answer-identity.test.ts": 36, + "packages/core/tests/answers-component.test.ts": 1309, + "packages/core/tests/answers-expansion-recursion.test.ts": 281, + "packages/core/tests/capture-errors.test.ts": 525, + "packages/core/tests/capture-props.test.ts": 290, + "packages/core/tests/cli-journal.test.ts": 9215, + "packages/core/tests/code-block-component.test.ts": 2245, + "packages/core/tests/compiler-boundary.test.ts": 316, + "packages/core/tests/component-api.test.ts": 49, + "packages/core/tests/component-registration.test.ts": 978, + "packages/core/tests/component-returns.test.ts": 1439, + "packages/core/tests/component-schema-conformance.test.ts": 525, + "packages/core/tests/config-api.test.ts": 315, + "packages/core/tests/construct-error-observation.test.ts": 633, + "packages/core/tests/contextual-cwd.test.ts": 1490, + "packages/core/tests/daemon-integration.test.ts": 998, + "packages/core/tests/daemon.test.ts": 63, + "packages/core/tests/declared-markdown-component.test.ts": 1873, + "packages/core/tests/document-output-api.test.ts": 33, + "packages/core/tests/document-target-execution.test.ts": 3168, + "packages/core/tests/document-targets.test.ts": 381, + "packages/core/tests/document-validation.test.ts": 1906, + "packages/core/tests/documentation-index.test.ts": 149, + "packages/core/tests/each.test.ts": 384, + "packages/core/tests/elicit-component.test.ts": 1014, + "packages/core/tests/ephemeral-service.test.ts": 761, + "packages/core/tests/eval-bindings.test.ts": 355, + "packages/core/tests/eval-context.test.ts": 73, + "packages/core/tests/eval-durable.test.ts": 388, + "packages/core/tests/eval-error-mode.test.ts": 620, + "packages/core/tests/eval-interpolate.test.ts": 33, + "packages/core/tests/eval-middleware.test.ts": 67, + "packages/core/tests/eval-persist.test.ts": 449, + "packages/core/tests/eval-return.test.ts": 397, + "packages/core/tests/eval-scope.test.ts": 306, + "packages/core/tests/eval-timeout.test.ts": 386, + "packages/core/tests/eval-transform.test.ts": 57, + "packages/core/tests/evaluate-component.test.ts": 2345, + "packages/core/tests/evaluate-loaded-copy.test.ts": 472, + "packages/core/tests/evaluate-provider-lifetime.test.ts": 542, + "packages/core/tests/evaluation-profile.test.ts": 169, + "packages/core/tests/exec-timeout.test.ts": 447, + "packages/core/tests/execute.test.ts": 970, + "packages/core/tests/execution-protocol.test.ts": 1559, + "packages/core/tests/expand.test.ts": 1116, + "packages/core/tests/expansion-identity.test.ts": 1121, + "packages/core/tests/expansion-metadata.test.ts": 304, + "packages/core/tests/expression-props.test.ts": 800, + "packages/core/tests/fail-component.test.ts": 687, + "packages/core/tests/failure-printing.test.ts": 627, + "packages/core/tests/fatal-cause.test.ts": 174, + "packages/core/tests/fetch-component.test.ts": 1566, + "packages/core/tests/file-component.test.ts": 2428, + "packages/core/tests/file-delete-component.test.ts": 581, + "packages/core/tests/files-fatal.test.ts": 1402, + "packages/core/tests/foreground-exec.test.ts": 1136, + "packages/core/tests/frontmatter.test.ts": 44, + "packages/core/tests/function-components.test.ts": 858, + "packages/core/tests/generated-composition.test.ts": 1049, + "packages/core/tests/generated-xmd.test.ts": 2201, + "packages/core/tests/glob-api.test.ts": 94, + "packages/core/tests/glob-component.test.ts": 1141, + "packages/core/tests/guarded-journal.test.ts": 411, + "packages/core/tests/has-content.test.ts": 241, + "packages/core/tests/heal.test.ts": 225, + "packages/core/tests/if.test.ts": 730, + "packages/core/tests/inline-root.test.ts": 453, + "packages/core/tests/invocation-failures.test.ts": 97, + "packages/core/tests/invocation-identity.test.ts": 722, + "packages/core/tests/invocation-scope.test.ts": 491, + "packages/core/tests/journal-source-position.test.ts": 537, + "packages/core/tests/json-component.test.ts": 2648, + "packages/core/tests/json.test.ts": 29, + "packages/core/tests/let.test.ts": 436, + "packages/core/tests/loop.test.ts": 1126, + "packages/core/tests/named-slots.test.ts": 1181, + "packages/core/tests/output-error-mode.test.ts": 1468, + "packages/core/tests/output-normalize.test.ts": 36, + "packages/core/tests/output-terminal.test.ts": 143, + "packages/core/tests/parse-components.test.ts": 523, + "packages/core/tests/plan-response.test.ts": 76, + "packages/core/tests/plugin-api.test.ts": 40, + "packages/core/tests/plugin.test.ts": 170, + "packages/core/tests/props-binding.test.ts": 505, + "packages/core/tests/protected-content.test.ts": 44, + "packages/core/tests/registered-printed-errors.test.ts": 191, + "packages/core/tests/replay-stream.test.ts": 31, + "packages/core/tests/retain.test.ts": 428, + "packages/core/tests/root-composition.test.ts": 509, + "packages/core/tests/root-props.test.ts": 527, + "packages/core/tests/root-provider.test.ts": 524, + "packages/core/tests/sample-component.test.ts": 1067, + "packages/core/tests/scanner.test.ts": 69, + "packages/core/tests/scope-local.test.ts": 31, + "packages/core/tests/secret-detection.test.ts": 1139, + "packages/core/tests/secret-files.test.ts": 109, + "packages/core/tests/secret-rules.test.ts": 149, + "packages/core/tests/secret-scanner-baseline.test.ts": 139, + "packages/core/tests/secret-scanner.test.ts": 99, + "packages/core/tests/source-position.test.ts": 307, + "packages/core/tests/state-ownership.test.ts": 361, + "packages/core/tests/streaming-emission.test.ts": 284, + "packages/core/tests/switch.test.ts": 765, + "packages/core/tests/syntax-catalog.test.ts": 830, + "packages/core/tests/syntax-component.test.ts": 1646, + "packages/core/tests/temp-dir.test.ts": 2764, + "packages/core/tests/temp-file-compiler.test.ts": 67, + "packages/core/tests/text-interpolation.test.ts": 216, + "packages/core/tests/try-content.test.ts": 250, + "packages/core/tests/unused-in-diff.test.ts": 458, + "packages/core/tests/validate.test.ts": 296, + "packages/core/tests/validation-integration.test.ts": 360, + "packages/core/tests/workflow-component-bundle.test.ts": 690, + "packages/durable-streams/tests/context.test.ts": 32, + "packages/durable-streams/tests/deterministic-id.test.ts": 64, + "packages/durable-streams/tests/divergence-api.test.ts": 40, + "packages/durable-streams/tests/divergence.test.ts": 51, + "packages/durable-streams/tests/durable-each.test.ts": 57, + "packages/durable-streams/tests/durable-position.test.ts": 46, + "packages/durable-streams/tests/durable-run.test.ts": 60, + "packages/durable-streams/tests/ephemeral.test.ts": 53, + "packages/durable-streams/tests/fail-stop.test.ts": 65, + "packages/durable-streams/tests/guard-stream.test.ts": 103, + "packages/durable-streams/tests/live-coordinator.test.ts": 54, + "packages/durable-streams/tests/parse.test.ts": 43, + "packages/durable-streams/tests/replay-guard.test.ts": 65, + "packages/durable-streams/tests/replay-index.test.ts": 39, + "packages/durable-streams/tests/retained.test.ts": 43, + "packages/durable-streams/tests/serialize-event.test.ts": 35, + "packages/durable-streams/tests/smoke.test.ts": 30, + "packages/durable-streams/tests/structured-concurrency.test.ts": 79, + "packages/durable-streams/tests/terminal-boundary.test.ts": 71, + "packages/durable-streams/tests/types.test.ts": 24, + "packages/git/tests/api-entrypoint.test.ts": 280, + "packages/git/tests/git-host-effect.test.ts": 6090, + "packages/git/tests/git.test.ts": 347, + "packages/git/tests/github-activation.test.ts": 646, + "packages/git/tests/github-issues.test.ts": 283, + "packages/git/tests/github-pull-requests.test.ts": 76, + "packages/git/tests/issue-markdown.test.ts": 8192, + "packages/git/tests/issue-records.test.ts": 267, + "packages/git/tests/module-partition.test.ts": 1580, + "packages/git/tests/plugin.test.ts": 746, + "packages/git/tests/provider-neutrality.test.ts": 984, + "packages/git/tests/pull-request-records.test.ts": 297, + "packages/git/tests/run-composition-lazy.test.ts": 732, + "packages/git/tests/selection-authentication.test.ts": 75, + "packages/runtime/tests/duration.test.ts": 54, + "packages/runtime/tests/fetch.test.ts": 617, + "packages/runtime/tests/fs.test.ts": 65, + "packages/runtime/tests/host-files.test.ts": 725, + "packages/runtime/tests/native-launcher.test.ts": 2395, + "packages/runtime/tests/process.test.ts": 81, + "packages/runtime/tests/service.test.ts": 57, + "packages/test-agent/tests/acp-server.test.ts": 95, + "packages/test-agent/tests/behavior-engine.test.ts": 627, + "packages/test-agent/tests/bridge.test.ts": 63, + "packages/test-agent/tests/command-lazy.test.ts": 3054, + "packages/test-agent/tests/components.test.ts": 49393, + "packages/test-agent/tests/controller.test.ts": 192, + "packages/test-agent/tests/cross-package-resolution.test.ts": 1739, + "packages/test-agent/tests/native-launch.test.ts": 22544, + "packages/test-agent/tests/net.test.ts": 89, + "packages/test-agent/tests/profile.test.ts": 288, + "packages/test-agent/tests/protocol.test.ts": 52, + "packages/test-agent/tests/provider.test.ts": 381, + "packages/test-agent/tests/public-api.test.ts": 446, + "packages/test-agent/tests/route-slot.test.ts": 54, + "packages/test-agent/tests/smoke.test.ts": 14887, + "packages/test-agent/tests/template.test.ts": 297, + "packages/test-agent/tests/worker-lifecycle.test.ts": 8501, + "packages/test-support/tests/journal.test.ts": 38, + "packages/testing/tests/agent-composition.test.ts": 574, + "packages/testing/tests/assert-throws.test.ts": 2186, + "packages/testing/tests/assert.test.ts": 51, + "packages/testing/tests/assertions.test.ts": 2973, + "packages/testing/tests/boundary-contract.test.ts": 1553, + "packages/testing/tests/cli.test.ts": 4523, + "packages/testing/tests/execution-harness.test.ts": 6276, + "packages/testing/tests/replay.test.ts": 2131, + "packages/testing/tests/smoke.test.ts": 1803, + "packages/testing/tests/test-component.test.ts": 2875, + "packages/testing/tests/testing-activation.test.ts": 2504, + "packages/testing/tests/testing-boundary.test.ts": 1056, + "packages/testing/tests/testing-mode.test.ts": 3089, + "packages/testing/tests/use-testing.test.ts": 1818, + "packages/web/tests/assets.test.ts": 27, + "packages/web/tests/client-logic.test.ts": 32, + "packages/web/tests/compile.test.ts": 866, + "packages/web/tests/component.test.ts": 1393, + "packages/web/tests/declaration.test.ts": 624, + "packages/web/tests/document.test.ts": 724, + "packages/web/tests/elicitation.test.ts": 502, + "packages/web/tests/live-form.test.ts": 457, + "packages/web/tests/markdown.test.ts": 135, + "packages/web/tests/opener.test.ts": 475, + "packages/web/tests/page.test.ts": 30, + "packages/web/tests/responder.test.ts": 785, + "packages/web/tests/server-lifecycle.test.ts": 607, + "packages/web/tests/server.test.ts": 990, + "packages/workflow/tests/generated-observations.test.ts": 519, + "packages/workflow/tests/retained-run.test.ts": 1181, + "packages/workflow/tests/service-denial.test.ts": 53, + "packages/workflow/tests/workflow-bundle.test.ts": 740, + "packages/workflow/tests/workflow-definition.test.ts": 321, + "packages/workflow/tests/workflow-fork.test.ts": 283, + "packages/workflow/tests/workflow-run.test.ts": 1000, + "packages/workflow/tests/workspace-effect.test.ts": 1310, + "scripts/tests/adapter-vendor.test.ts": 146, + "scripts/tests/bootstrap-npm-package.test.ts": 4873, + "scripts/tests/bump-version.test.ts": 73, + "scripts/tests/changed-paths.test.ts": 32, + "scripts/tests/ci-workflow.test.ts": 1616, + "scripts/tests/consumer-cycle.test.ts": 91, + "scripts/tests/filesystem-contract-workflow.test.ts": 67, + "scripts/tests/main-green.test.ts": 279, + "scripts/tests/main-health.test.ts": 101, + "scripts/tests/no-module-scoped-registry.test.ts": 753, + "scripts/tests/no-redundant-test-scope.test.ts": 4157, + "scripts/tests/no-sync-filesystem.test.ts": 3835, + "scripts/tests/no-yield-in-finally.test.ts": 733, + "scripts/tests/oxlint-policy.test.ts": 8746, + "scripts/tests/packaged-document.test.ts": 64, + "scripts/tests/prefer-effection-operation.test.ts": 5312, + "scripts/tests/prefer-effection-result.test.ts": 3540, + "scripts/tests/publish-workflow-membership.test.ts": 84, + "scripts/tests/review-infrastructure.test.ts": 1660, + "scripts/tests/runtime-exclusions.test.ts": 277, + "scripts/tests/runtime-tests.test.ts": 74, + "scripts/tests/scope-bound-event-registration.test.ts": 3871, + "scripts/tests/staged-write.test.ts": 68, + "scripts/tests/test-file-discovery.test.ts": 211, + "scripts/tests/test-shards.test.ts": 38, + "scripts/tests/test-weights.test.ts": 205, + "scripts/tests/tracked.test.ts": 34, + "scripts/tests/verify-coordinator.test.ts": 216, + "scripts/tests/version-lockstep.test.ts": 111, + "scripts/tests/web-client-module.test.ts": 53, + "scripts/tests/workspace.test.ts": 42 } } } From ce0a88570230157c6130b4de5ac4131c4557674c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 27 Sep 2026 16:44:03 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Reach=20Deno's=20terminal=20?= =?UTF-8?q?through=20the=20host=20rather=20than=20naming=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every package here is typechecked under Node as well as Deno, and that check is a step of every `test-node` shard. A module that names the `Deno` global does not compile there even when nothing would ever load it, so the two Deno-named terminal adapters failed all ten shards with `Cannot find name 'Deno'`. The capabilities are now read off `globalThis` and parsed, which is how the other runtime-named adapters in this repository stay compilable everywhere and constructible only where they work. A host missing one of them has no terminal, and says so, rather than being discovered halfway through taking one. --- packages/cli/src/compiled-repl-terminal.ts | 62 +++++--- packages/cli/src/deno-repl-terminal.ts | 62 +++++--- packages/cli/src/deno-terminal-surface.ts | 159 +++++++++++++++++++++ 3 files changed, 247 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/deno-terminal-surface.ts diff --git a/packages/cli/src/compiled-repl-terminal.ts b/packages/cli/src/compiled-repl-terminal.ts index f029642c9..48baf6ee5 100644 --- a/packages/cli/src/compiled-repl-terminal.ts +++ b/packages/cli/src/compiled-repl-terminal.ts @@ -12,24 +12,32 @@ * so an ordinary `xmd run` never touches raw mode or the terminal's modes at * all. * + * The capabilities are read off the host rather than named, because this package + * is typechecked under Node as well and a module that names `Deno` does not + * compile there even though nothing would ever load it. + * * Standard input is a single source, so one subscriber consumes it; a second * would be a second decoder racing the first for the same bytes, which is not * something this REPL does. */ import type { Operation } from "effection"; -import { installReplTerminal, writeAllTo } from "./repl/terminal-host.ts"; +import { denoTerminalSurface } from "./deno-terminal-surface.ts"; +import { installReplTerminal } from "./repl/terminal-host.ts"; import type { ReplTerminalSize } from "./repl/terminal.ts"; /** Install the compiled binary's terminal for the calling scope. */ -export function useCompiledReplTerminal(): Operation { - return installReplTerminal({ +export function* useCompiledReplTerminal(): Operation { + const host = denoTerminalSurface(); + if (host === undefined) { + throw new Error("this host is not Deno, so it has no Deno terminal to install"); + } + yield* installReplTerminal({ size(): ReplTerminalSize { - const { columns, rows } = Deno.consoleSize(); - return { columns, rows }; + return host.consoleSize(); }, write(bytes: Uint8Array): Promise { - return writeAllTo((chunk) => Deno.stdout.write(chunk), bytes); + return writeAll(host, bytes); }, writeNow(bytes: Uint8Array): void { let written = 0; @@ -37,8 +45,7 @@ export function useCompiledReplTerminal(): Operation { // Synchronous on purpose: the final reset must land as one uninterrupted // act, and a suspension here would let another teardown step write over // a half-restored terminal. - // oxlint-disable-next-line local/no-sync-filesystem - const count = Deno.stdout.writeSync(bytes.subarray(written)); + const count = host.writeSync(bytes.subarray(written)); if (count <= 0) { return; } @@ -46,16 +53,35 @@ export function useCompiledReplTerminal(): Operation { } }, setRaw(raw: boolean): void { - Deno.stdin.setRaw(raw); - }, - bytes(): AsyncIterable { - return Deno.stdin.readable; - }, - onResize(listener: () => void): () => void { - Deno.addSignalListener("SIGWINCH", listener); - return () => { - Deno.removeSignalListener("SIGWINCH", listener); - }; + host.setRaw(raw); }, + bytes: () => host.bytes(), + onResize: (listener: () => void) => host.onResize(listener), }); } + +/** + * Write every byte, however few one call takes. + * + * A partial write nobody continued leaves a frame half drawn, which on a + * terminal means escape sequences cut in the middle. + */ +function writeAll( + host: { write(bytes: Uint8Array): Promise }, + bytes: Uint8Array, +): Promise { + let written = 0; + const step = (): Promise => { + if (written >= bytes.length) { + return Promise.resolve(); + } + return host.write(bytes.subarray(written)).then((count) => { + if (count <= 0) { + return Promise.reject(new Error("the terminal accepted none of the bytes it was given")); + } + written += count; + return step(); + }); + }; + return step(); +} diff --git a/packages/cli/src/deno-repl-terminal.ts b/packages/cli/src/deno-repl-terminal.ts index 4927cad8a..d5024aead 100644 --- a/packages/cli/src/deno-repl-terminal.ts +++ b/packages/cli/src/deno-repl-terminal.ts @@ -7,24 +7,32 @@ * so an ordinary `xmd run` never touches raw mode or the terminal's modes at * all. * + * The capabilities are read off the host rather than named, because this package + * is typechecked under Node as well and a module that names `Deno` does not + * compile there even though nothing would ever load it. + * * Standard input is a single source, so one subscriber consumes it; a second * would be a second decoder racing the first for the same bytes, which is not * something this REPL does. */ import type { Operation } from "effection"; -import { installReplTerminal, writeAllTo } from "./repl/terminal-host.ts"; +import { denoTerminalSurface } from "./deno-terminal-surface.ts"; +import { installReplTerminal } from "./repl/terminal-host.ts"; import type { ReplTerminalSize } from "./repl/terminal.ts"; /** Install the Deno-backed terminal for the calling scope. */ -export function useDenoReplTerminal(): Operation { - return installReplTerminal({ +export function* useDenoReplTerminal(): Operation { + const host = denoTerminalSurface(); + if (host === undefined) { + throw new Error("this host is not Deno, so it has no Deno terminal to install"); + } + yield* installReplTerminal({ size(): ReplTerminalSize { - const { columns, rows } = Deno.consoleSize(); - return { columns, rows }; + return host.consoleSize(); }, write(bytes: Uint8Array): Promise { - return writeAllTo((chunk) => Deno.stdout.write(chunk), bytes); + return writeAll(host, bytes); }, writeNow(bytes: Uint8Array): void { let written = 0; @@ -32,8 +40,7 @@ export function useDenoReplTerminal(): Operation { // Synchronous on purpose: the final reset must land as one uninterrupted // act, and a suspension here would let another teardown step write over // a half-restored terminal. - // oxlint-disable-next-line local/no-sync-filesystem - const count = Deno.stdout.writeSync(bytes.subarray(written)); + const count = host.writeSync(bytes.subarray(written)); if (count <= 0) { return; } @@ -41,16 +48,35 @@ export function useDenoReplTerminal(): Operation { } }, setRaw(raw: boolean): void { - Deno.stdin.setRaw(raw); - }, - bytes(): AsyncIterable { - return Deno.stdin.readable; - }, - onResize(listener: () => void): () => void { - Deno.addSignalListener("SIGWINCH", listener); - return () => { - Deno.removeSignalListener("SIGWINCH", listener); - }; + host.setRaw(raw); }, + bytes: () => host.bytes(), + onResize: (listener: () => void) => host.onResize(listener), }); } + +/** + * Write every byte, however few one call takes. + * + * A partial write nobody continued leaves a frame half drawn, which on a + * terminal means escape sequences cut in the middle. + */ +function writeAll( + host: { write(bytes: Uint8Array): Promise }, + bytes: Uint8Array, +): Promise { + let written = 0; + const step = (): Promise => { + if (written >= bytes.length) { + return Promise.resolve(); + } + return host.write(bytes.subarray(written)).then((count) => { + if (count <= 0) { + return Promise.reject(new Error("the terminal accepted none of the bytes it was given")); + } + written += count; + return step(); + }); + }; + return step(); +} diff --git a/packages/cli/src/deno-terminal-surface.ts b/packages/cli/src/deno-terminal-surface.ts new file mode 100644 index 000000000..18332fe0c --- /dev/null +++ b/packages/cli/src/deno-terminal-surface.ts @@ -0,0 +1,159 @@ +/** + * The pieces of Deno's terminal this REPL uses, read off the host. + * + * Read rather than imported, and named nowhere in this module's types, because + * the repository typechecks every package under Node as well — where the `Deno` + * global does not exist and a module that names it fails to compile even though + * nothing there would ever load it. Reaching for the host through `globalThis` + * and parsing what comes back is how the other runtime-named adapters in this + * repository stay compilable everywhere and constructible only where they work. + * + * Every member is validated before it is offered. A host that is missing one is + * a host with no terminal, which the caller reports rather than discovering + * halfway through taking one. + */ + +/** What a Deno host offers a terminal. */ +export interface DenoTerminalSurface { + consoleSize(): { readonly columns: number; readonly rows: number }; + /** Hand bytes over, resolving with how many it took. */ + write(bytes: Uint8Array): Promise; + /** Hand bytes over with no suspension point inside the call. */ + writeSync(bytes: Uint8Array): number; + setRaw(raw: boolean): void; + /** The byte source. One source, so one subscriber consumes it. */ + bytes(): AsyncIterable; + /** Watch for size changes; the returned function stops watching. */ + onResize(listener: () => void): () => void; +} + +/** One member of the host, when it is the function this needs. */ +function callable(host: object, name: string): ((...args: unknown[]) => unknown) | undefined { + const member: unknown = Reflect.get(host, name); + if (typeof member !== "function") { + return undefined; + } + return (...args: unknown[]) => Reflect.apply(member, host, args); +} + +/** A number the host reported, or none when it reported something else. */ +function numeric(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * This host's terminal, or none when it is not a Deno host. + * + * `undefined` rather than a throw: whether this runtime has one is a question + * with an answer, and the caller is the one that knows whether an absent + * terminal is a problem. + */ +export function denoTerminalSurface(): DenoTerminalSurface | undefined { + const host: unknown = Reflect.get(globalThis, "Deno"); + if (typeof host !== "object" || host === null) { + return undefined; + } + const consoleSize = callable(host, "consoleSize"); + const addSignalListener = callable(host, "addSignalListener"); + const removeSignalListener = callable(host, "removeSignalListener"); + const stdout: unknown = Reflect.get(host, "stdout"); + const stdin: unknown = Reflect.get(host, "stdin"); + if ( + consoleSize === undefined || + addSignalListener === undefined || + removeSignalListener === undefined || + typeof stdout !== "object" || + stdout === null || + typeof stdin !== "object" || + stdin === null + ) { + return undefined; + } + + const write = callable(stdout, "write"); + const writeSync = callable(stdout, "writeSync"); + const setRaw = callable(stdin, "setRaw"); + const readable: unknown = Reflect.get(stdin, "readable"); + if ( + write === undefined || + writeSync === undefined || + setRaw === undefined || + typeof readable !== "object" || + readable === null || + !(Symbol.asyncIterator in readable) + ) { + return undefined; + } + const source = readable; + + return { + consoleSize(): { readonly columns: number; readonly rows: number } { + const reported: unknown = consoleSize(); + if (typeof reported !== "object" || reported === null) { + throw new Error("this host did not report a console size"); + } + const columns = numeric(Reflect.get(reported, "columns")); + const rows = numeric(Reflect.get(reported, "rows")); + if (columns === undefined || rows === undefined) { + throw new Error("this host reported a console size with no columns or rows"); + } + return { columns, rows }; + }, + write(bytes: Uint8Array): Promise { + return Promise.resolve(write(bytes)).then((taken) => numeric(taken) ?? 0); + }, + writeSync(bytes: Uint8Array): number { + return numeric(writeSync(bytes)) ?? 0; + }, + setRaw(raw: boolean): void { + setRaw(raw); + }, + bytes(): AsyncIterable { + // Checked above: this object declares the async-iterable protocol, which + // is the whole of what the shared reader asks of it. + return { + [Symbol.asyncIterator](): AsyncIterator { + const method: unknown = Reflect.get(source, Symbol.asyncIterator); + if (typeof method !== "function") { + throw new Error("this host's standard input is not iterable after all"); + } + const iterator: unknown = Reflect.apply(method, source, []); + if (typeof iterator !== "object" || iterator === null) { + throw new Error("this host's standard input produced no iterator"); + } + const next = callable(iterator, "next"); + const back = callable(iterator, "return"); + if (next === undefined) { + throw new Error("this host's standard input produced no reader"); + } + return { + next(): Promise> { + return Promise.resolve(next()).then((result) => { + if (typeof result !== "object" || result === null) { + return { done: true, value: undefined }; + } + const value: unknown = Reflect.get(result, "value"); + return Reflect.get(result, "done") === true || !(value instanceof Uint8Array) + ? { done: true, value: undefined } + : { done: false, value }; + }); + }, + ...(back === undefined + ? {} + : { + return(): Promise> { + return Promise.resolve(back()).then(() => ({ done: true, value: undefined })); + }, + }), + }; + }, + }; + }, + onResize(listener: () => void): () => void { + addSignalListener("SIGWINCH", listener); + return () => { + removeSignalListener("SIGWINCH", listener); + }; + }, + }; +}