Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .agents/architecture-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions deno.lock

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

1 change: 1 addition & 0 deletions packages/cli/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/bun-repl-terminal.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return installReplTerminal({
size(): ReplTerminalSize {
return { columns: process.stdout.columns, rows: process.stdout.rows };
},
write(bytes: Uint8Array): Promise<void> {
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<Uint8Array> {
process.stdin.resume();
return decodedChunks(process.stdin);
},
onResize(listener: () => void): () => void {
process.on("SIGWINCH", listener);
return () => {
process.off("SIGWINCH", listener);
};
},
});
}
87 changes: 87 additions & 0 deletions packages/cli/src/compiled-repl-terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* 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.
*
* 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 { 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<void> {
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 {
return host.consoleSize();
},
write(bytes: Uint8Array): Promise<void> {
return writeAll(host, 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.
const count = host.writeSync(bytes.subarray(written));
if (count <= 0) {
return;
}
written += count;
}
},
setRaw(raw: boolean): void {
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<number> },
bytes: Uint8Array,
): Promise<void> {
let written = 0;
const step = (): Promise<void> => {
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();
}
82 changes: 82 additions & 0 deletions packages/cli/src/deno-repl-terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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.
*
* 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 { 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<void> {
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 {
return host.consoleSize();
},
write(bytes: Uint8Array): Promise<void> {
return writeAll(host, 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.
const count = host.writeSync(bytes.subarray(written));
if (count <= 0) {
return;
}
written += count;
}
},
setRaw(raw: boolean): void {
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<number> },
bytes: Uint8Array,
): Promise<void> {
let written = 0;
const step = (): Promise<void> => {
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();
}
Loading
Loading