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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/quiet-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset should start with an approved active present-tense verb. Changeset package scope does not match the affected packages confidently. Changeset bump does not match the consumer-visible impact.

View the proposed replacement
---
'@bomb.sh/tty': minor
---

TODO: Write changeset message (inference failed: HTTP 401: {"error":{"message":"User not found.","code":401}})

"@bomb.sh/tty": minor
---

Make `term.update()` return `void`. Call `term.render()` to reflect dimension and
capability changes on screen; multiple updates contribute to the next render.
Disabling pointer shape support preserves the last emitted shape without emitting
a reset. To reset the pointer, render without a pointer while support is enabled.
4 changes: 2 additions & 2 deletions docs/review-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ before opening a PR. PR numbers point at the original discussion.
overloads that only tests use (#133).
- Names carry constraints: `id` when it must be unique, not `name` (#5).
- Static facts are fixed at `createTerm()`; dynamic changes arrive as events via
`term.update()`, which returns the bytes to write. Don't defer work to the
next `render()` (#106).
`term.update()`. Apply those state changes synchronously; renderer output is
produced by `render()`.
- Front-load configuration at creation so each paint stays one
`write(result.output)` (#101).

Expand Down
8 changes: 2 additions & 6 deletions examples/2048/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,7 @@ await main(function* () {
if (next.width !== width || next.height !== height) {
width = next.width;
height = next.height;
Deno.stdout.writeSync(
term.update([{ type: "resize", width, height }]),
);
term.update([{ type: "resize", width, height }]);
Deno.stdout.writeSync(CLEAR_SCREEN);
draw();
}
Expand All @@ -405,9 +403,7 @@ await main(function* () {
let e = ev as InputEvent | PointerEvent;

if (e.type === "capability") {
Deno.stdout.writeSync(term.update([e]));
yield* each.next();
continue;
term.update([e]);
}

if (e.type === "keydown") {
Expand Down
2 changes: 1 addition & 1 deletion examples/cursor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ await main(function* () {
break;
}
if (event.type === "capability") {
writeStdout(term.update([event]));
term.update([event]);
}
if (event.type === "pointerenter") {
ctx.entered.add(event.id);
Expand Down
2 changes: 1 addition & 1 deletion examples/keyboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ await main(function* () {
break;
}
if (event.type === "capability") {
writeStdout(term.update([event]));
term.update([event]);
}
if (event.type === "pointerenter") {
context.entered.add(event.id);
Expand Down
2 changes: 1 addition & 1 deletion examples/text-input/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ await main(function* () {
caret++;
}
} else if (event.type === "capability") {
writeStdout(term.update([event]));
term.update([event]);
} else if ("x" in event) {
pointer = {
x: event.x,
Expand Down
4 changes: 0 additions & 4 deletions pointer-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,3 @@ const INDEX: ReadonlyMap<unknown, number> = new Map(
export function pointerShapeIndex(shape: unknown): number | undefined {
return INDEX.get(shape);
}

export const POINTER_SHAPE_RESET: Uint8Array = new TextEncoder().encode(
"\x1b]22;default\x1b\\",
);
58 changes: 30 additions & 28 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,11 +426,12 @@ values (see [Input Specification](input-spec.md) §5), discriminated by `type`:
the transaction MUST throw otherwise.
- A `CapabilityEvent` (see [Terminfo Specification](terminfo-spec.md) §6.3) — a
capability value delivered by the input parser from a probe response.
- Any other `InputEvent` — a no-op step. It changes no state and contributes no
bytes.
- Any other `InputEvent` — a no-op step. It changes no state.

The Term folds each event in array order. The returned bytes are the
concatenation of each fold's output. An empty array is a no-op.
The Term folds each event in array order. An empty array is a no-op.

Multiple `update()` calls before the next `render()` accumulate state changes in
call order. That render MUST use the state resulting from all those calls.

A resize event whose target dimensions equal the Term's current dimensions MUST
be a no-op for that step.
Expand All @@ -448,15 +449,13 @@ be a no-op for that step.
emitted.

**Capability semantics.** A `CapabilityEvent` step folds the event's `key` and
`value` into the Term's private `RuntimeCapabilities`. The foundation update
transaction emits bytes or alters renderer output as a consequence of a
capability event only where a capability section of this specification says so
(§7.10).
`value` into the Term's private `RuntimeCapabilities`. The updated capabilities
are observable through `term.capabilities` when the call returns. Their effects
on subsequent render transactions are defined by the capability sections of this
specification (§7.8–§7.10).

**Return value.** `update()` returns a `Uint8Array` of bytes to write to the
terminal immediately. An empty array is valid when the update changes no
rendered state (TINV-5). The caller writes the bytes to its output stream before
the next render.
**Return value.** `update()` returns `void`. The caller must invoke `render()`
to see the effects (TINV-5).

**Memory.** WASM linear memory can only grow. An update to smaller dimensions
retains the high-water-mark allocation; memory is not reclaimed until the Term
Expand Down Expand Up @@ -558,17 +557,17 @@ When the resolved shape differs, the render MUST append `ESC ] 22 ; <shape> ST`
to `output` after the frame's cell bytes and record the new shape. Otherwise it
MUST NOT emit OSC 22.

**Restore.** OSC 22 set cannot pop, so the Term restores by setting `default`:

- A render that resolves `default` emits it per **Output** above.
- An update step that changes `pointerShape` from `true` to `false` while the
emitted shape is not `default` MUST return `ESC ] 22 ; default ST` and record
`default`.
- A resize step leaves the emitted shape unchanged.
**Updates.** Capability changes and resize MUST leave the recorded last emitted
shape unchanged. While `pointerShape` is `false`, renders MUST NOT emit a shape
change, including a reset to `default`. If the capability becomes `true` again,
the next render compares its resolved shape with the recorded last emitted shape
as defined by **Output** above.

To restore before exit, the caller renders a frame without `pointer`, or passes
`{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and
writes the result.
**Reset to default.** OSC 22 set cannot pop, so the Term resets the pointer by
setting `default`. While the capability is `true`, a render that resolves
`default` emits it per **Output** above. To reset before exit, the caller
renders a frame without `pointer` while the capability is `true` and writes the
result's `output`.

---

Expand Down Expand Up @@ -762,7 +761,7 @@ wherever the directive model expects a color.
### 8.6 Term update

```
term.update(events: readonly InputEvent[]): Uint8Array
term.update(events: readonly InputEvent[]): void
```

Performs an update transaction as defined in §7.7. `update()` is the universal
Expand All @@ -775,23 +774,26 @@ Every other `InputEvent` is accepted and ignored, so the full `events` array
from `input.scan()` can be passed without filtering. Events are folded in array
order.

**Return value.** `update()` always returns a `Uint8Array`. Write it to the
terminal immediately when non-empty. Do not wait for the next `render()`. An
empty array means the update changed no rendered state.
**Return value.** `update()` returns `void`. The caller must invoke `render()`
to see the effects.

**Host usage.** Events from `input.scan()` pass through directly. Resizes
observed outside the input stream (e.g. `SIGWINCH`) are passed as a constructed
`ResizeEvent`:

```
const { events } = input.scan(bytes);
const out = term.update(events);
if (out.length) stdout.write(out);
term.update(events);
requestRender();

// on SIGWINCH
term.update([{ type: "resize", width: cols(), height: rows() }]);
requestRender();
```

Here `requestRender()` is the application's scheduling hook. Its render pass
supplies directives to `term.render()` and writes the result's `output`.

A resize to the Term's current dimensions is a no-op for that step.

---
Expand Down
58 changes: 36 additions & 22 deletions specs/terminfo-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ renderer and input parser each receive a copy at construction.
terminal's responses arrive on the input stream, where the input parser
recognizes them and emits `CapabilityEvent` values alongside keys and mouse
events. The host loop routes these events to `term.update()`, which folds them
into the renderer's `RuntimeCapabilities` and returns any bytes to write
immediately.
into the renderer's `RuntimeCapabilities` synchronously. Changes are reflected
in the next `render()` call.

### 4.2 Data flow

Expand All @@ -102,16 +102,19 @@ terminfo file bytes ──▶ terminfo_parse() ─▶ Capabilities (frozen)
createInput() createTerm()
│ │
probe bytes ──▶ terminal ──▶ stdin │
scan() ─▶ CapabilityEvent ─▶ update() ─▶ Uint8Array (write now)
scan() ─▶ CapabilityEvent ─▶ update(): void
│
RuntimeCapabilities
│
render(ops).output ─▶ Uint8Array (host writes)
```

- `detectTerminal()` parses raw bytes into `Capabilities`. It performs no IO.
- The input parser is the only source of `CapabilityEvent` values. It writes
nothing; it emits.
- The renderer holds its own private capability state. `update()` is the only
path that changes it.
- Changes made by `update()` are reflected in the next `render()` call.
- The renderer and the input parser share no memory and hold no reference to
each other. The host loop is the only place they meet.

Expand Down Expand Up @@ -154,9 +157,8 @@ missing, oversized, or malformed terminfo file, or abort. Environmental
conditions degrade to the §7.1 baseline. The only rejection is caller error
(§10.1).

**TINV-5. Immediate update output.** `term.update()` returns a `Uint8Array` of
bytes to write now. It MUST NOT defer output to the next `render()` call. An
empty array is a valid return when the update changes no rendered state.
**TINV-5. Update effects.** `term.update()` returns `void`. The caller must
invoke `render()` to see the effects.

**TINV-6. Event-only probe surface.** The input parser MUST NOT write to any
shared state when it recognizes a probe response. It surfaces the result as a
Expand Down Expand Up @@ -324,10 +326,11 @@ not merge; each response yields its own event.
**OSC 22.** An OSC 22 reply sets `key: "pointer-shape", value: true`. The shape
name in the reply is discarded in v1; only protocol support is recorded. The
renderer emits pointer shapes only while `pointerShape` is `true` (Renderer
Specification §7.10). Terminals that implement OSC 22 set but not its query
never reply; `detectTerminal()` supplies that evidence itself from the
known-support table (§6.1), and a caller may still override it with
`term.update([{ type: "capability", key: "pointer-shape", value: true }])`.
Specification §7.10). Setting `pointerShape` to `false` suppresses pointer-shape
output; it does not reset the pointer to `default`. Terminals that implement OSC
22 set but not its query never reply; `detectTerminal()` supplies that evidence
itself from the known-support table (§6.1), and a caller may still override it
with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`.

**DA1.** The DA1 reply is recognized internally as the probe fence and MUST NOT
surface as a `CapabilityEvent`.
Expand Down Expand Up @@ -476,9 +479,10 @@ and mouse events in arrival order. The host loop routes them to `term.update()`.
### 9.3 Capability change over time

A capability event can arrive after the renderer has already emitted frames.
`term.update()` folds it into the runtime snapshot. Any renderer-side output
invalidation or immediate bytes are defined by the renderer section that
consumes that capability (Renderer Specification §7.8).
`term.update()` folds it into the runtime snapshot synchronously. Subsequent
renders consume that snapshot. Any renderer-side output invalidation is defined
by the renderer section that consumes that capability (Renderer Specification
§7.8).

---

Expand Down Expand Up @@ -566,18 +570,19 @@ When omitted, the parser uses built-in xterm key sequences.
```ts
interface Term {
render(ops: Op[], options?: RenderOptions): RenderResult;
update(events: readonly InputEvent[]): Uint8Array;
update(events: readonly InputEvent[]): void;
readonly capabilities: RuntimeCapabilities;
}
```

`update()` accepts an array of `InputEvent` values. Events other than
`ResizeEvent` and `CapabilityEvent` are no-op steps, so the full `events` array
from `scan()` can be passed without filtering. Events are folded in order: each
event produces the next `RuntimeCapabilities` and any bytes defined by the
consuming feature, and the returned bytes are concatenated. The foundation
defines no such bytes. The return value is always a `Uint8Array`; callers write
it to their output stream when non-empty (TINV-5).
resize updates the Term's dimensions and each capability event updates its
`RuntimeCapabilities`. These changes take effect before `update()` returns. The
method returns `void`; the caller must invoke `render()` to see the effects
(TINV-5). Multiple calls contribute to the next render as defined in Renderer
Specification §7.7.

`term.capabilities` is a frozen snapshot of the current `RuntimeCapabilities`.

Expand All @@ -594,19 +599,28 @@ const input = await createInput({ terminfo });

process.stdout.write(terminfo.probe);

function draw() {
const { output } = term.render(view());
process.stdout.write(output);
}

process.stdin.on("data", (bytes: Uint8Array) => {
const { events } = input.scan(bytes);
const out = term.update(events);
if (out.length) process.stdout.write(out);
term.update(events);
for (const event of events) dispatch(event);
requestRender(draw);
});

process.on("SIGWINCH", () => {
const out = term.update([{ type: "resize", width: cols(), height: rows() }]);
if (out.length) process.stdout.write(out);
term.update([{ type: "resize", width: cols(), height: rows() }]);
requestRender(draw);
});
```

Here `view()`, `dispatch()`, and `requestRender()` belong to the host
application. `requestRender()` schedules the supplied rendering callback; the
host can coalesce requests into one render after a batch of events.

`createTerm` and `createInput` each take `terminfo` and build their own private
state from it. Passing the same `TerminalInfo` to both passes the same plain
value twice. There is no shared memory and no attachment guard.
Expand Down
10 changes: 1 addition & 9 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -772,15 +772,7 @@ void clayterm_resize(struct Clayterm *ct, int w, int h) {
cells_fill(ct->front, w, h, 0, 0, 0);
}

int set_capabilities(struct Clayterm *ct, uint32_t caps) {
int withdrawn = (ct->caps & RENDER_CAP_POINTER_SHAPE) &&
!(caps & RENDER_CAP_POINTER_SHAPE);
int restore = withdrawn && ct->pointer_shape != 0;
if (restore)
ct->pointer_shape = 0;
ct->caps = caps;
return restore;
}
void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; }

void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
float deltaTime, float px, float py, int pointer) {
Expand Down
2 changes: 1 addition & 1 deletion src/clayterm.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct Clayterm;
int clayterm_size(int w, int h);
struct Clayterm *clayterm_init(void *mem, int w, int h);
void clayterm_resize(struct Clayterm *ct, int w, int h);
int set_capabilities(struct Clayterm *ct, uint32_t caps);
void set_capabilities(struct Clayterm *ct, uint32_t caps);
void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
float deltaTime, float px, float py, int pointer);
char *output(struct Clayterm *ct);
Expand Down
15 changes: 7 additions & 8 deletions term-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,9 @@ export interface Native {
update(w: number, h: number): void;
/**
* Push capability evidence into the renderer. The renderer holds it across
* frames and resize updates. Returns true when withdrawing pointerShape leaves a
* non-default shape on screen that the caller must reset.
* frames and resize updates.
*/
setCapabilities(caps: RenderCapabilities): boolean;
setCapabilities(caps: RenderCapabilities): void;
reduce(
ct: number,
buf: number,
Expand Down Expand Up @@ -129,7 +128,7 @@ export async function createTermNative(
): void;
output(ct: number): number;
length(ct: number): number;
set_capabilities(ct: number, caps: number): number;
set_capabilities(ct: number, caps: number): void;
pointer_over_count(): number;
pointer_over_id_string_length(index: number): number;
pointer_over_id_string_ptr(index: number): number;
Expand All @@ -149,10 +148,10 @@ export async function createTermNative(

let statePtr: number | undefined;
let opsBuf = 0;
function setCaps(next: RenderCapabilities): boolean {
function setCaps(next: RenderCapabilities): void {
let flags = (next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0) |
(next.pointerShape ? RENDER_CAP_POINTER_SHAPE : 0);
return ct.set_capabilities(statePtr!, flags) !== 0;
ct.set_capabilities(statePtr!, flags);
}

// Renderer state and the fixed transfer buffer share linear memory as
Expand Down Expand Up @@ -190,8 +189,8 @@ export async function createTermNative(
update(uw: number, uh: number): void {
layout(uw, uh);
},
setCapabilities(next: RenderCapabilities): boolean {
return setCaps(next);
setCapabilities(next: RenderCapabilities): void {
setCaps(next);
},
reduce: ct.reduce,
output: ct.output,
Expand Down
Loading
Loading