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
7 changes: 7 additions & 0 deletions .changeset/sync-output-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---

@bombshell-cooper bombshell-cooper Bot Oct 9, 2026 •

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 package scope does not match the affected packages confidently. Changeset contains implementation or roadmap noise.

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

Adds synchronized output (DEC mode 2026) driven by the `sync-output` capability.

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. No-op frames emit nothing even when wrapping is active. Line-mode renders are never wrapped.

`syncOutput` is raised by three channels: the `Sync` extended capability in the parsed terminfo entry, the `TMUX` environment variable, and probe replies (DECRPM 2026 and XTGETTCAP `Sync`). Probe replies take precedence in both directions — a DECRPM 0 or 4 reply clears the capability even when detection seeded it.

#### Migration

`Capabilities` gains a new required field and `reduce` gains a new `sync` parameter.

```diff
- interface Capabilities {
+ interface Capabilities {
    readonly styledUnderline: boolean;
+   readonly syncOutput: boolean;
  }
```

```diff
- reduce(ct, buf, len, mode, row, deltaTime);
+ reduce(ct, buf, len, mode, row, deltaTime, sync);
```

Pass `0` for `sync` to preserve the previous unwrapped behaviour, or forward `RuntimeCapabilities.syncOutput ? 1 : 0` to enable wrapping.

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

Adds synchronized output (DEC mode 2026) driven by the `sync-output` capability.

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. There is no new API: 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.
44 changes: 44 additions & 0 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,50 @@ output, Kitty keyboard mode setup, and Kitty graphics emission are deferred to
their respective follow-up PRs. The renderer continues to emit its existing
hardcoded ANSI output until one of those specifications is adopted.

### 7.9 Synchronized output (DEC mode 2026)

When the `syncOutput` runtime capability is true, each full-screen frame
(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized Update
(`CSI ? 2026 h`) before the frame's first emitted byte and End Synchronized
Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between
the pair and presents it as one atomic repaint, so a frame never tears
mid-update.

**Capability evidence.** `syncOutput` is raised only on positive evidence. Two
runtime channels feed the `sync-output` `CapabilityEvent` (input-spec §5,
terminfo-spec §6.3): a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`) —
values 1, 2, and 3 (set, reset, permanently set) report support, 0 and 4 report
none — and an XTGETTCAP `Sync` reply, whose sequence value is discarded in favor
of the mode 2026 form (terminfo-spec §6.3). Detection may also seed the field
positively: static `Capabilities` carry `syncOutput`, raised by the `Sync`
extended capability in the parsed terminfo entry and by the `TMUX` environment
variable (terminfo-spec §7.1, §7.2). Evidence follows TINV-3 precedence: probe
replies outrank detect-time seeding in both directions, so a DECRPM 0 or 4 reply
turns the capability off even when detection seeded it. Without positive
evidence the field stays false and no frame is wrapped.

**Invalidation and timing.** The renderer reads the capability snapshot at each
render transaction, so a capability flip takes effect on the next rendered
frame. No invalidation signal exists, and none is required: a flip between
frames changes only whether the next frame is wrapped.

**Normative rules:**

- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`.
- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in
the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be
interleaved with foreign content, and an unterminated synchronized block would
suppress it.
- A full-screen render whose diff produces no cell changes and no caret
transition MUST emit zero bytes, even when `syncOutput` is true. The renderer
decides after the diff, so no-op frames carry neither the wrap prefix nor the
suffix.
- Terminals that receive the sequences without supporting the mode ignore it by
design; the probe still refines behavior for terminals that report mode 0
or 4.

This section discharges the synchronized-output deferral recorded in §7.8.

---

## 8. Public Rendering API
Expand Down
74 changes: 48 additions & 26 deletions specs/terminfo-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,20 @@ interface Capabilities {
readonly xenl: boolean;
readonly altScreen: boolean;
readonly styledUnderline: boolean;
readonly syncOutput: boolean;
}
```

| Field | Source |
| ----------------- | ------------------------------------------------------------------- |
| `colors` | `max_colors` from terminfo; 256 at baseline |
| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides |
| `bce` | `bce` boolean cap |
| `autoMargin` | `am` boolean cap |
| `xenl` | `xenl` boolean cap |
| `altScreen` | `smcup` string present |
| `styledUnderline` | `Su` boolean or `Smulx` string |
| Field | Source |
| ----------------- | ----------------------------------------------------------------------- |
| `colors` | `max_colors` from terminfo; 256 at baseline |
| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides |
| `bce` | `bce` boolean cap |
| `autoMargin` | `am` boolean cap |
| `xenl` | `xenl` boolean cap |
| `altScreen` | `smcup` string present |
| `styledUnderline` | `Su` boolean or `Smulx` string |
| `syncOutput` | `Sync` extended cap; `TMUX` env evidence (§7.2); probe overrides (§6.3) |

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

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

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

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

**XTGETTCAP `Sync`.** A reply carrying a value for `Sync` emits
`{ key: "sync-output", value: true }`. An invalid-capability reply for `Sync`
emits `{ key: "sync-output", value: false }`. The value carried by a positive
reply is the terminal's own synchronization sequence — the iTerm2 DCS form
(`DCS = 1 s` / `DCS = 2 s`) for some terminals, `CSI ? 2026 h`/`l` for others.
The value is discarded in v1: only support is recorded, and the renderer emits
the DEC mode 2026 form (renderer-spec §7.9). A terminal that advertises only the
DCS form therefore receives 2026-form bytes it ignores; this bounded cost is
accepted.

**OSC 21.** An OSC 21 reply may supply any subset of `foreground-color`,
`background-color`, and `cursor-color`. The parser emits one `CapabilityEvent`
per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does
Expand Down Expand Up @@ -331,15 +343,24 @@ With no terminfo bytes, no environment evidence, and no probe responses,
| `xenl` | `true` |
| `altScreen` | `true` |
| `styledUnderline` | `false` |
| `syncOutput` | `false` |

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

### 7.2 Environment evidence

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

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

Environment evidence outranks the terminfo entry and is outranked by probe
responses (TINV-3).
Expand Down Expand Up @@ -368,7 +389,7 @@ observe only the resulting `Capabilities`.
- Both storage formats MUST be supported: legacy (magic `0432`, 16-bit numbers)
and extended number format (magic `01036`, 32-bit numbers).
- The extended-capability string table MUST be parsed for the user-defined
capabilities `RGB`, `Tc`, `Su`, and `Smulx`.
capabilities `RGB`, `Tc`, `Su`, `Smulx`, and `Sync`.
- All reads are bounds-checked against `len`. Out-of-range offsets, truncated
sections, and odd-length string tables yield the parse failure path (TINV-2).
- The maximum accepted size is 32 768 bytes, the extended ncurses format limit.
Expand Down Expand Up @@ -398,13 +419,14 @@ order:
| 5 | Pointer shape | `OSC 22 ; ?__current__ ST` | `pointer-shape` |
| 6 | Truecolor caps | `DCS + q 524742 ; 5463 ST` (XTGETTCAP `RGB;Tc`) | `colordepth` |
| 7 | Synchronized output | `CSI ? 2026 $ p` (DECRQM) | `sync-output` |
| 8 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` |
| 9 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` |
| 10 | **Fence:** DA1 | `CSI c` | (internal only) |
| 8 | Sync terminfo cap | `DCS + q 53796e63 ST` (XTGETTCAP `Sync`) | `sync-output` |
| 9 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` |
| 10 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` |
| 11 | **Fence:** DA1 | `CSI c` | (internal only) |

Terminals answer queries in order and ignore queries they do not understand. DA1
is answered by every terminal, so its response marks the probe complete. Any of
queries 1–9 not yet answered when the DA1 reply arrives will never be answered,
queries 1–10 not yet answered when the DA1 reply arrives will never be answered,
and the renderer keeps their current values.

The batch is safe to emit unconditionally: every query is either answered or
Expand Down
39 changes: 35 additions & 4 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ struct Clayterm {
Clay_ErrorData errors[MAX_ERRORS];
int error_count;
int animating_count;
/* wrap each full-screen frame in BSU/ESU (DEC mode 2026); driven by the
* RuntimeCapabilities.syncOutput capability, passed per render call */
int sync;
/* Caret state for hardware-cursor management. The renderer records the
* first text node carrying a caret declaration per frame, precomputes the
* caret's byte offset into that node's content, then places the cell as
Expand Down Expand Up @@ -116,6 +119,12 @@ struct Clayterm {
* to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */
#define OUT_BYTES_PER_CELL 128

/* Fixed per-frame slack on top of the per-cell budget, for output that wraps
* the whole frame rather than scaling with cell count — the BSU/ESU
* synchronized-output pair. Keeps the wrap from being dropped on tiny grids
* where the per-cell budget alone is tight. */
#define OUT_FRAME_OVERHEAD 32

/* ── Cell buffer ops ──────────────────────────────────────────────── */

static Cell *cell_at(struct Clayterm *ct, Cell *buf, int x, int y) {
Expand Down Expand Up @@ -261,6 +270,18 @@ static void present_cups(struct Clayterm *ct, int row) {
ct->lastx = -1;
ct->lasty = -1;

/* Synchronized Output (DEC mode 2026): the terminal buffers everything
* between BSU and ESU and presents it in one atomic repaint, so a frame
* never tears mid-update. Unsupported terminals ignore the private mode;
* whether we emit the wrap at all is decided by the sync-output
* capability, threaded through reduce() each frame. */
int start = ct->out.length;
if (ct->sync)
buf_str(&ct->out, "\x1b[?2026h");
/* Body start: the no-op check below compares against this so the BSU
* itself never counts as frame content. */
int body = ct->out.length;

for (int y = 0; y < ct->h; y++) {
for (int x = 0; x < ct->w;) {
Cell *back = cell_at(ct, ct->back, x, y);
Expand Down Expand Up @@ -310,6 +331,14 @@ static void present_cups(struct Clayterm *ct, int row) {
buf_str(&ct->out, "\x1b[?25l");
}
ct->had_caret_last_frame = ct->has_caret;

if (ct->out.length == body) {
/* Nothing was emitted — no cell changes and no caret transition.
* Rewind the BSU so a no-op frame still produces zero bytes. */
ct->out.length = start;
} else if (ct->sync) {
buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */
}
}

/**
Expand Down Expand Up @@ -624,7 +653,7 @@ int clayterm_size(int w, int h) {
set_clay_capacity(w, h);
int cell_count = w * h;
int cell_bytes = cell_count * (int)sizeof(Cell);
int out_bytes = cell_count * OUT_BYTES_PER_CELL;
int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD;
int clay_bytes = (int)Clay_MinMemorySize();
return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */
+ align8(cell_bytes) /* back */
Expand Down Expand Up @@ -686,7 +715,7 @@ struct Clayterm *init(void *mem, int w, int h) {
struct Clayterm *ct = (struct Clayterm *)mem;
int cell_count = w * h;
int cell_bytes = align8(cell_count * (int)sizeof(Cell));
int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL);
int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD);
char *base = (char *)mem + align8((int)sizeof(struct Clayterm));

char *clay_mem = base + cell_bytes * 2 + out_bytes;
Expand All @@ -701,7 +730,8 @@ struct Clayterm *init(void *mem, int w, int h) {
.h = h,
.front = (Cell *)base,
.back = (Cell *)(base + cell_bytes),
.out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL},
.out = {base + cell_bytes * 2, 0,
cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD},
.lastfg = 0xffffffff,
.lastbg = 0xffffffff,
.lastx = -1,
Expand All @@ -717,9 +747,10 @@ struct Clayterm *init(void *mem, int w, int h) {
}

void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
float deltaTime) {
float deltaTime, int sync) {
int i = 0;
ct_active_context = ct;
ct->sync = sync;
ct->error_count = 0;
ct->animating_count = 0;
ct->caret_text_chars = NULL;
Expand Down
2 changes: 1 addition & 1 deletion src/clayterm.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct Clayterm;
int clayterm_size(int w, int h);
struct Clayterm *init(void *mem, int w, int h);
void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
float deltaTime);
float deltaTime, int sync);
char *output(struct Clayterm *ct);
int length(struct Clayterm *ct);
int animating(struct Clayterm *ct);
Expand Down
Loading
Loading