Skip to content

Commit acb51e1

Browse files
authored
feat(term): synchronized output (DEC mode 2026) via the sync-output capability (#139)
1 parent 0ce8081 commit acb51e1

17 files changed

Lines changed: 466 additions & 48 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@bomb.sh/tty": minor
3+
---
4+
5+
Adds synchronized output (DEC mode 2026) driven by the `sync-output` capability.
6+
7+
When `RuntimeCapabilities.syncOutput` is true, every full-screen render is wrapped in Begin/End Synchronized Update (`CSI ? 2026 h` / `CSI ? 2026 l`) so the terminal presents the frame as one atomic repaint without tearing. The capability is raised only on positive evidence, resolved from three channels — a DECRPM 2026 probe reply (`CSI ? 2026 $ p`), an XTGETTCAP `Sync` reply (the terminal's own terminfo-declared synchronization sequence, value discarded in favor of the 2026 form), and detection-time seeding from the `Sync` extended capability in the parsed terminfo entry or the `TMUX` environment variable (tmux consumes mode 2026 from inner applications). Probe replies outrank detection-time evidence in both directions, so a DECRPM 0 or 4 reply turns the capability off even when detection seeded it. Frames render unwrapped when the capability is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped.
8+
9+
#### Migration
10+
11+
`Capabilities` gains a required `syncOutput` field. An object literal passed to `createTerm` as `terminfo.capabilities` must include it:
12+
13+
```diff
14+
terminfo: {
15+
capabilities: {
16+
colors: 256,
17+
trueColor: false,
18+
bce: true,
19+
autoMargin: true,
20+
xenl: true,
21+
altScreen: true,
22+
styledUnderline: false,
23+
+ syncOutput: false,
24+
},
25+
}
26+
```

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ EXPORTS = \
1414
-Wl,--export=__heap_base \
1515
-Wl,--export=clayterm_size \
1616
-Wl,--export=init \
17+
-Wl,--export=set_capabilities \
1718
-Wl,--export=reduce \
1819
-Wl,--export=output \
1920
-Wl,--export=length \

‎specs/renderer-spec.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,50 @@ output, Kitty keyboard mode setup, and Kitty graphics emission are deferred to
480480
their respective follow-up PRs. The renderer continues to emit its existing
481481
hardcoded ANSI output until one of those specifications is adopted.
482482

483+
### 7.9 Synchronized output (DEC mode 2026)
484+
485+
When the `syncOutput` runtime capability is true, each full-screen frame
486+
(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized Update
487+
(`CSI ? 2026 h`) before the frame's first emitted byte and End Synchronized
488+
Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between
489+
the pair and presents it as one atomic repaint, so a frame never tears
490+
mid-update.
491+
492+
**Capability evidence.** `syncOutput` is raised only on positive evidence. Two
493+
runtime channels feed the `sync-output` `CapabilityEvent` (input-spec §5,
494+
terminfo-spec §6.3): a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`) —
495+
values 1, 2, and 3 (set, reset, permanently set) report support, 0 and 4 report
496+
none — and an XTGETTCAP `Sync` reply, whose sequence value is discarded in favor
497+
of the mode 2026 form (terminfo-spec §6.3). Detection may also seed the field
498+
positively: static `Capabilities` carry `syncOutput`, raised by the `Sync`
499+
extended capability in the parsed terminfo entry and by the `TMUX` environment
500+
variable (terminfo-spec §7.1, §7.2). Evidence follows TINV-3 precedence: probe
501+
replies outrank detect-time seeding in both directions, so a DECRPM 0 or 4 reply
502+
turns the capability off even when detection seeded it. Without positive
503+
evidence the field stays false and no frame is wrapped.
504+
505+
**Invalidation and timing.** The renderer reads the capability snapshot at each
506+
render transaction, so a capability flip takes effect on the next rendered
507+
frame. No invalidation signal exists, and none is required: a flip between
508+
frames changes only whether the next frame is wrapped.
509+
510+
**Normative rules:**
511+
512+
- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`.
513+
- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in
514+
the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be
515+
interleaved with foreign content, and an unterminated synchronized block would
516+
suppress it.
517+
- A full-screen render whose diff produces no cell changes and no caret
518+
transition MUST emit zero bytes, even when `syncOutput` is true. The renderer
519+
decides after the diff, so no-op frames carry neither the wrap prefix nor the
520+
suffix.
521+
- Terminals that receive the sequences without supporting the mode ignore it by
522+
design; the probe still refines behavior for terminals that report mode 0
523+
or 4.
524+
525+
This section discharges the synchronized-output deferral recorded in §7.8.
526+
483527
---
484528

485529
## 8. Public Rendering API

‎specs/terminfo-spec.md‎

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -180,18 +180,20 @@ interface Capabilities {
180180
readonly xenl: boolean;
181181
readonly altScreen: boolean;
182182
readonly styledUnderline: boolean;
183+
readonly syncOutput: boolean;
183184
}
184185
```
185186

186-
| Field | Source |
187-
| ----------------- | ------------------------------------------------------------------- |
188-
| `colors` | `max_colors` from terminfo; 256 at baseline |
189-
| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides |
190-
| `bce` | `bce` boolean cap |
191-
| `autoMargin` | `am` boolean cap |
192-
| `xenl` | `xenl` boolean cap |
193-
| `altScreen` | `smcup` string present |
194-
| `styledUnderline` | `Su` boolean or `Smulx` string |
187+
| Field | Source |
188+
| ----------------- | ----------------------------------------------------------------------- |
189+
| `colors` | `max_colors` from terminfo; 256 at baseline |
190+
| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides |
191+
| `bce` | `bce` boolean cap |
192+
| `autoMargin` | `am` boolean cap |
193+
| `xenl` | `xenl` boolean cap |
194+
| `altScreen` | `smcup` string present |
195+
| `styledUnderline` | `Su` boolean or `Smulx` string |
196+
| `syncOutput` | `Sync` extended cap; `TMUX` env evidence (§7.2); probe overrides (§6.3) |
195197

196198
Key sequences (`key_*` capabilities) are not stored here. The input parser reads
197199
them directly from the raw terminfo bytes in `TerminalInfo.keys` at
@@ -252,20 +254,20 @@ type CapabilityEvent =
252254
};
253255
```
254256

255-
Each variant maps to one probe query. The `key` identifies the capability; the
256-
`value` is the terminal's answer. Capability references per
257+
Each variant maps to at least one probe query. The `key` identifies the
258+
capability; the `value` is the terminal's answer. Capability references per
257259
[terminfo.dev](https://terminfo.dev):
258260

259-
| `key` | Query | terminfo.dev slug |
260-
| ------------------ | -------------------- | ----------------------------------------------------------------------------------------------- |
261-
| `foreground-color` | OSC 10 | [`osc-10-fg-color-query`](https://terminfo.dev/extensions/osc-10-fg-color-query) |
262-
| `background-color` | OSC 11 | [`osc-11-bg-color-query`](https://terminfo.dev/extensions/osc-11-bg-color-query) |
263-
| `cursor-color` | OSC 12 or OSC 21 | [`osc-12-cursor-color`](https://terminfo.dev/extensions/osc-12-cursor-color) |
264-
| `colordepth` | XTGETTCAP `RGB`/`Tc` | [`24-bit-truecolor`](https://terminfo.dev/extensions/24-bit-truecolor) |
265-
| `sync-output` | DECRPM mode 2026 | [`decset-2026-synchronized-output`](https://terminfo.dev/modes/decset-2026-synchronized-output) |
266-
| `kitty-keyboard` | `CSI ? u` | [`kitty-keyboard-protocol`](https://terminfo.dev/extensions/kitty-keyboard-protocol) |
267-
| `kitty-graphics` | APC `_G…` | [`kitty-graphics-protocol`](https://terminfo.dev/extensions/kitty-graphics-protocol) |
268-
| `pointer-shape` | OSC 22 | [`osc-22-pointer-shape`](https://terminfo.dev/extensions/osc-22-pointer-shape) |
261+
| `key` | Query | terminfo.dev slug |
262+
| ------------------ | ---------------------------------- | ----------------------------------------------------------------------------------------------- |
263+
| `foreground-color` | OSC 10 | [`osc-10-fg-color-query`](https://terminfo.dev/extensions/osc-10-fg-color-query) |
264+
| `background-color` | OSC 11 | [`osc-11-bg-color-query`](https://terminfo.dev/extensions/osc-11-bg-color-query) |
265+
| `cursor-color` | OSC 12 or OSC 21 | [`osc-12-cursor-color`](https://terminfo.dev/extensions/osc-12-cursor-color) |
266+
| `colordepth` | XTGETTCAP `RGB`/`Tc` | [`24-bit-truecolor`](https://terminfo.dev/extensions/24-bit-truecolor) |
267+
| `sync-output` | DECRPM mode 2026; XTGETTCAP `Sync` | [`decset-2026-synchronized-output`](https://terminfo.dev/modes/decset-2026-synchronized-output) |
268+
| `kitty-keyboard` | `CSI ? u` | [`kitty-keyboard-protocol`](https://terminfo.dev/extensions/kitty-keyboard-protocol) |
269+
| `kitty-graphics` | APC `_G…` | [`kitty-graphics-protocol`](https://terminfo.dev/extensions/kitty-graphics-protocol) |
270+
| `pointer-shape` | OSC 22 | [`osc-22-pointer-shape`](https://terminfo.dev/extensions/osc-22-pointer-shape) |
269271

270272
**Color values.** `Rgb` values in probe responses MUST be recognized in at least
271273
the `rgb:RR/GG/BB` (1–4 hex digits per channel) and `#`-hash forms.
@@ -278,6 +280,16 @@ D }` where `D` is derived from the static
278280
preserves TINV-3: the probe response takes precedence over static evidence,
279281
including in the negative direction.
280282

283+
**XTGETTCAP `Sync`.** A reply carrying a value for `Sync` emits
284+
`{ key: "sync-output", value: true }`. An invalid-capability reply for `Sync`
285+
emits `{ key: "sync-output", value: false }`. The value carried by a positive
286+
reply is the terminal's own synchronization sequence — the iTerm2 DCS form
287+
(`DCS = 1 s` / `DCS = 2 s`) for some terminals, `CSI ? 2026 h`/`l` for others.
288+
The value is discarded in v1: only support is recorded, and the renderer emits
289+
the DEC mode 2026 form (renderer-spec §7.9). A terminal that advertises only the
290+
DCS form therefore receives 2026-form bytes it ignores; this bounded cost is
291+
accepted.
292+
281293
**OSC 21.** An OSC 21 reply may supply any subset of `foreground-color`,
282294
`background-color`, and `cursor-color`. The parser emits one `CapabilityEvent`
283295
per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does
@@ -331,15 +343,24 @@ With no terminfo bytes, no environment evidence, and no probe responses,
331343
| `xenl` | `true` |
332344
| `altScreen` | `true` |
333345
| `styledUnderline` | `false` |
346+
| `syncOutput` | `false` |
334347

335348
Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence.
349+
The same holds for synchronized output: no frame is wrapped until a `Sync`
350+
extended capability, `TMUX` environment evidence, or a probe response raises it
351+
(§7.2, §6.3).
336352

337353
### 7.2 Environment evidence
338354

339355
`detectTerminal()` applies environment evidence after parsing the terminfo
340356
entry:
341357

342358
- `COLORTERM` equal to `truecolor` or `24bit` sets `trueColor: true`.
359+
- `TMUX` set to a non-empty value sets `syncOutput: true`. A running tmux
360+
consumes DEC mode 2026 from applications running inside it and translates it
361+
to the outer terminal's synchronization mechanism (the tmux `Sync` terminfo
362+
capability and `sync` terminal feature), so its presence is positive evidence
363+
even for tmux releases that do not themselves answer the DECRPM 2026 probe.
343364

344365
Environment evidence outranks the terminfo entry and is outranked by probe
345366
responses (TINV-3).
@@ -368,7 +389,7 @@ observe only the resulting `Capabilities`.
368389
- Both storage formats MUST be supported: legacy (magic `0432`, 16-bit numbers)
369390
and extended number format (magic `01036`, 32-bit numbers).
370391
- The extended-capability string table MUST be parsed for the user-defined
371-
capabilities `RGB`, `Tc`, `Su`, and `Smulx`.
392+
capabilities `RGB`, `Tc`, `Su`, `Smulx`, and `Sync`.
372393
- All reads are bounds-checked against `len`. Out-of-range offsets, truncated
373394
sections, and odd-length string tables yield the parse failure path (TINV-2).
374395
- The maximum accepted size is 32 768 bytes, the extended ncurses format limit.
@@ -398,13 +419,14 @@ order:
398419
| 5 | Pointer shape | `OSC 22 ; ?__current__ ST` | `pointer-shape` |
399420
| 6 | Truecolor caps | `DCS + q 524742 ; 5463 ST` (XTGETTCAP `RGB;Tc`) | `colordepth` |
400421
| 7 | Synchronized output | `CSI ? 2026 $ p` (DECRQM) | `sync-output` |
401-
| 8 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` |
402-
| 9 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` |
403-
| 10 | **Fence:** DA1 | `CSI c` | (internal only) |
422+
| 8 | Sync terminfo cap | `DCS + q 53796e63 ST` (XTGETTCAP `Sync`) | `sync-output` |
423+
| 9 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` |
424+
| 10 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` |
425+
| 11 | **Fence:** DA1 | `CSI c` | (internal only) |
404426
405427
Terminals answer queries in order and ignore queries they do not understand. DA1
406428
is answered by every terminal, so its response marks the probe complete. Any of
407-
queries 1–9 not yet answered when the DA1 reply arrives will never be answered,
429+
queries 1–10 not yet answered when the DA1 reply arrives will never be answered,
408430
and the renderer keeps their current values.
409431
410432
The batch is safe to emit unconditionally: every query is either answered or

‎src/clayterm.c‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ struct Clayterm {
8787
Clay_ErrorData errors[MAX_ERRORS];
8888
int error_count;
8989
int animating_count;
90+
/* CAP_* bits pushed by the host via set_capabilities(); init() zeroes
91+
* them, so the host re-pushes after every re-init */
92+
uint32_t caps;
9093
/* Caret state for hardware-cursor management. The renderer records the
9194
* first text node carrying a caret declaration per frame, precomputes the
9295
* caret's byte offset into that node's content, then places the cell as
@@ -116,6 +119,12 @@ struct Clayterm {
116119
* to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */
117120
#define OUT_BYTES_PER_CELL 128
118121

122+
/* Fixed per-frame slack on top of the per-cell budget, for output that wraps
123+
* the whole frame rather than scaling with cell count — the BSU/ESU
124+
* synchronized-output pair. Keeps the wrap from being dropped on tiny grids
125+
* where the per-cell budget alone is tight. */
126+
#define OUT_FRAME_OVERHEAD 32
127+
119128
/* ── Cell buffer ops ──────────────────────────────────────────────── */
120129

121130
static Cell *cell_at(struct Clayterm *ct, Cell *buf, int x, int y) {
@@ -261,6 +270,19 @@ static void present_cups(struct Clayterm *ct, int row) {
261270
ct->lastx = -1;
262271
ct->lasty = -1;
263272

273+
/* Synchronized Output (DEC mode 2026): the terminal buffers everything
274+
* between BSU and ESU and presents it in one atomic repaint, so a frame
275+
* never tears mid-update. Unsupported terminals ignore the private mode;
276+
* whether we emit the wrap at all is decided by the sync-output
277+
* capability. */
278+
int sync = ct->caps & RENDER_CAP_SYNC_OUTPUT;
279+
int start = ct->out.length;
280+
if (sync)
281+
buf_str(&ct->out, "\x1b[?2026h");
282+
/* Body start: the no-op check below compares against this so the BSU
283+
* itself never counts as frame content. */
284+
int body = ct->out.length;
285+
264286
for (int y = 0; y < ct->h; y++) {
265287
for (int x = 0; x < ct->w;) {
266288
Cell *back = cell_at(ct, ct->back, x, y);
@@ -310,6 +332,14 @@ static void present_cups(struct Clayterm *ct, int row) {
310332
buf_str(&ct->out, "\x1b[?25l");
311333
}
312334
ct->had_caret_last_frame = ct->has_caret;
335+
336+
if (ct->out.length == body) {
337+
/* Nothing was emitted — no cell changes and no caret transition.
338+
* Rewind the BSU so a no-op frame still produces zero bytes. */
339+
ct->out.length = start;
340+
} else if (sync) {
341+
buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */
342+
}
313343
}
314344

315345
/**
@@ -624,7 +654,7 @@ int clayterm_size(int w, int h) {
624654
set_clay_capacity(w, h);
625655
int cell_count = w * h;
626656
int cell_bytes = cell_count * (int)sizeof(Cell);
627-
int out_bytes = cell_count * OUT_BYTES_PER_CELL;
657+
int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD;
628658
int clay_bytes = (int)Clay_MinMemorySize();
629659
return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */
630660
+ align8(cell_bytes) /* back */
@@ -686,7 +716,7 @@ struct Clayterm *init(void *mem, int w, int h) {
686716
struct Clayterm *ct = (struct Clayterm *)mem;
687717
int cell_count = w * h;
688718
int cell_bytes = align8(cell_count * (int)sizeof(Cell));
689-
int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL);
719+
int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD);
690720
char *base = (char *)mem + align8((int)sizeof(struct Clayterm));
691721

692722
char *clay_mem = base + cell_bytes * 2 + out_bytes;
@@ -701,7 +731,8 @@ struct Clayterm *init(void *mem, int w, int h) {
701731
.h = h,
702732
.front = (Cell *)base,
703733
.back = (Cell *)(base + cell_bytes),
704-
.out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL},
734+
.out = {base + cell_bytes * 2, 0,
735+
cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD},
705736
.lastfg = 0xffffffff,
706737
.lastbg = 0xffffffff,
707738
.lastx = -1,
@@ -716,6 +747,8 @@ struct Clayterm *init(void *mem, int w, int h) {
716747
return ct;
717748
}
718749

750+
void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; }
751+
719752
void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
720753
float deltaTime) {
721754
int i = 0;

‎src/clayterm.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@
99

1010
struct Clayterm;
1111

12+
/* Capability bits for set_capabilities(). */
13+
#define RENDER_CAP_SYNC_OUTPUT (1u << 0)
14+
1215
/* WASM exports */
1316
int clayterm_size(int w, int h);
1417
struct Clayterm *init(void *mem, int w, int h);
18+
void set_capabilities(struct Clayterm *ct, uint32_t caps);
1519
void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
1620
float deltaTime);
1721
char *output(struct Clayterm *ct);

0 commit comments

Comments
 (0)