From 1f514e0024eb297eb915de9faf2f147053478030 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 10:30:45 -0500 Subject: [PATCH 1/3] feat(term): synchronized output (DEC mode 2026) driven by sync-output capability Full-screen frames wrap in BSU/ESU when RuntimeCapabilities.syncOutput is true; the flag rides reduce() per frame so a capability flip takes effect on the next rendered frame with no invalidation signal. Zero-diff frames emit zero bytes even when wrapped, and line mode is never wrapped. Renderer-spec 7.9 discharges the deferral recorded in 7.8. --- .changeset/sync-output-capability.md | 7 ++++ specs/renderer-spec.md | 38 ++++++++++++++++++++ src/clayterm.c | 39 +++++++++++++++++--- src/clayterm.h | 2 +- term-native.ts | 2 ++ term.ts | 10 +++++- test/term.test.ts | 53 ++++++++++++++++++++++++++++ test/width.test.ts | 2 +- 8 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 .changeset/sync-output-capability.md diff --git a/.changeset/sync-output-capability.md b/.changeset/sync-output-capability.md new file mode 100644 index 0000000..5e7aeb1 --- /dev/null +++ b/.changeset/sync-output-capability.md @@ -0,0 +1,7 @@ +--- +"@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 stays false until a DECRPM 2026 probe reply reports support (`detectTerminal()` probes `CSI ? 2026 $ p`, replies surface through `input.scan()` and fold in via `term.update()`), and frames render unwrapped when it is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 0e1d6ff..fe14c6f 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -480,6 +480,44 @@ 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` originates exclusively from the +`sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser +reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2, +and 3 (set, reset, permanently set) report support; 0 and 4 report none. The +static `Capabilities` never seed this field: it is false until a probe reply +is folded in through `update()`. + +**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 diff --git a/src/clayterm.c b/src/clayterm.c index 7e4e62e..b9b395e 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -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 @@ -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) { @@ -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); @@ -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 */ + } } /** @@ -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 */ @@ -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; @@ -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, @@ -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; diff --git a/src/clayterm.h b/src/clayterm.h index 8a24db4..41a190b 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -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); diff --git a/term-native.ts b/term-native.ts index b54549d..f9c4315 100644 --- a/term-native.ts +++ b/term-native.ts @@ -42,6 +42,7 @@ export interface Native { mode: number, row: number, deltaTime: number, + sync: number, ): void; output(ct: number): number; length(ct: number): number; @@ -99,6 +100,7 @@ export async function createTermNative( mode: number, row: number, deltaTime: number, + sync: number, ): void; output(ct: number): number; length(ct: number): number; diff --git a/term.ts b/term.ts index 5326daa..8b8c2b2 100644 --- a/term.ts +++ b/term.ts @@ -212,7 +212,15 @@ export async function createTerm(options: TermOptions): Promise { dt = now - lastRenderAt; } lastRenderAt = now; - native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt); + native.reduce( + native.statePtr, + native.opsBuf, + len, + mode, + row, + dt, + currentCaps.syncOutput ? 1 : 0, + ); if (options?.pointer) { let { x, y, down } = options.pointer; diff --git a/test/term.test.ts b/test/term.test.ts index 7190fd6..14ebb5f 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -885,4 +885,57 @@ hi expect(trim(print(decode(large.output), 120, 40))).toContain("Hi"); }); }); + + describe("synchronized output", () => { + let frame: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi"), + close(), + ]; + + let syncOn = { + type: "capability", + key: "sync-output", + value: true, + } as const; + let syncOff = { + type: "capability", + key: "sync-output", + value: false, + } as const; + + it("does not wrap frames by default", () => { + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("wraps full-screen frames when the capability is on", () => { + term.update([syncOn]); + let out = decode(term.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("emits zero bytes for a no-op frame even when wrapped", () => { + term.update([syncOn]); + term.render(frame); + expect(term.render(frame).output.length).toBe(0); + }); + + it("stops wrapping after the capability turns off", () => { + term.update([syncOn]); + term.render(frame); + term.update([syncOff]); + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("never wraps line mode output", () => { + term.update([syncOn]); + let out = decode( + term.render(frame, { mode: "line", row: 1 }).output, + ); + expect(out).not.toContain("\x1b[?2026"); + }); + }); }); diff --git a/test/width.test.ts b/test/width.test.ts index 1c54399..4475960 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -83,7 +83,7 @@ describe("width", () => { buf[at + 1] = 0xa0; buf[at + 2] = 0x80; - native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0); + native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0, 0); let out = new Uint8Array( native.memory.buffer, native.output(native.statePtr), From 1d8ef6e7de6d13223c8e1aa479df64f55918006b Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 11:19:36 -0500 Subject: [PATCH 2/3] =?UTF-8?q?style:=20reflow=20=C2=A77.9=20prose=20to=20?= =?UTF-8?q?satisfy=20deno=20fmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/renderer-spec.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index fe14c6f..20800ff 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -483,38 +483,38 @@ 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. +(§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` originates exclusively from the `sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2, and 3 (set, reset, permanently set) report support; 0 and 4 report none. The -static `Capabilities` never seed this field: it is false until a probe reply -is folded in through `update()`. +static `Capabilities` never seed this field: it is false until a probe reply is +folded in through `update()`. -**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. +**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. + 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. + 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. From 2494d02d2eba2413aad3fbceb8720a30e33e1ee0 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 13:26:39 -0500 Subject: [PATCH 3/3] feat: sync-output evidence from XTGETTCAP Sync, terminfo entry, and TMUX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the approved spec amendment (terminfo-spec §6.1/§6.3/§7.1/§7.2/§8/§9.1, renderer-spec §7.9): syncOutput is raised only on positive evidence from three channels. - Probe row 8: XTGETTCAP Sync (DCS + q 53796e63) as its own request — some terminals honor only one capability per XTGETTCAP request, so it must not merge into the RGB;Tc query. A value-carrying reply confirms; a reply naming Sync without a value denies. Sync replies never touch the colordepth tally. - Static Capabilities gains syncOutput, seeded from the Sync extended capability in the parsed terminfo entry and from TMUX environment evidence (tmux consumes mode 2026 from inner applications). - TINV-3 precedence: probe replies outrank detect-time seeding in both directions; runtimeFromStatic now flows the seeded field through. The reply's sequence value is discarded in v1: emission remains the DEC mode 2026 form, unchanged from §7.9's normative rules. --- .changeset/sync-output-capability.md | 2 +- specs/renderer-spec.md | 18 ++++--- specs/terminfo-spec.md | 74 ++++++++++++++++++---------- src/input.c | 38 ++++++++++++-- src/terminfo.c | 12 +++-- tasks/gen-fixtures.ts | 13 ++++- term.ts | 2 +- terminfo.ts | 12 +++++ test/fixtures.ts | 4 ++ test/input.test.ts | 58 ++++++++++++++++++++++ test/term.test.ts | 49 ++++++++++++++++++ test/terminfo.test.ts | 31 ++++++++++++ 12 files changed, 268 insertions(+), 45 deletions(-) diff --git a/.changeset/sync-output-capability.md b/.changeset/sync-output-capability.md index 5e7aeb1..369ee9b 100644 --- a/.changeset/sync-output-capability.md +++ b/.changeset/sync-output-capability.md @@ -4,4 +4,4 @@ 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 stays false until a DECRPM 2026 probe reply reports support (`detectTerminal()` probes `CSI ? 2026 $ p`, replies surface through `input.scan()` and fold in via `term.update()`), and frames render unwrapped when it is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped. +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. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 20800ff..80bf451 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -489,12 +489,18 @@ 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` originates exclusively from the -`sync-output` `CapabilityEvent` (input-spec §5), emitted when the input parser -reads a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`). Reply values 1, 2, -and 3 (set, reset, permanently set) report support; 0 and 4 report none. The -static `Capabilities` never seed this field: it is false until a probe reply is -folded in through `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 diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index d800909..7286e98 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -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 @@ -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. @@ -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 @@ -331,8 +343,12 @@ 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 @@ -340,6 +356,11 @@ Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence. 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). @@ -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. @@ -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 diff --git a/src/input.c b/src/input.c index 5c16e96..162e530 100644 --- a/src/input.c +++ b/src/input.c @@ -954,10 +954,34 @@ static int parse_osc_response(struct InputState *st) { } /* XTGETTCAP reply: DCS 1 + r … ST (valid) or DCS 0 + r … ST (invalid). - * We only query RGB (524742) and Tc (5463). A valid reply naming either - * confirms truecolor. The static tier is emitted only once both are - * denied without a confirmation since the last DA1 fence; an invalid - * reply naming neither denies both. */ + * We query RGB (524742), Tc (5463), and Sync (53796e63) in separate + * requests. A valid reply naming RGB or Tc confirms truecolor. The + * static tier is emitted only once both are denied without a + * confirmation since the last DA1 fence; an invalid reply naming + * neither denies both. + * + * A reply naming Sync answers the Sync query alone: a value-carrying + * reply (name '=' hex-value) confirms synchronized output; a reply + * naming Sync without a value denies it. Sync replies never touch the + * colordepth tally (terminfo-spec 6.3). */ +/* True when payload contains name, then '=', then at least one hex + * digit — an XTGETTCAP reply carrying a value for the capability. */ +static int payload_contains_value(const char *s, int len, const char *name) { + int n = (int)strlen(name); + for (int i = 0; i + n + 1 < len; i++) { + int j = 0; + while (j < n && s[i + j] == name[j]) + j++; + if (j == n && s[i + n] == '=') { + char v = s[i + n + 1]; + if ((v >= '0' && v <= '9') || (v >= 'a' && v <= 'f') || + (v >= 'A' && v <= 'F')) + return 1; + } + } + return 0; +} + static int parse_dcs_response(struct InputState *st) { if (st->len < 5) return PARSE_NEED_MORE; @@ -980,7 +1004,11 @@ static int parse_dcs_response(struct InputState *st) { int plen = end - 5 - (st->buf[end - 1] == '\x07' ? 1 : 2); int rgb = payload_contains(payload, plen, "524742"); int tc = payload_contains(payload, plen, "5463"); - if (ok == '1') { + int sync = payload_contains(payload, plen, "53796e63"); + if (sync) { + emit_cap_bool(st, CAP_SYNC_OUTPUT, + payload_contains_value(payload, plen, "53796e63")); + } else if (ok == '1') { if (rgb || tc) { st->tcap |= TCAP_CONFIRMED; struct InputEvent *ev = emit(st); diff --git a/src/terminfo.c b/src/terminfo.c index fec4d67..503adc1 100644 --- a/src/terminfo.c +++ b/src/terminfo.c @@ -94,7 +94,8 @@ static int name_is(const uint8_t *table, int table_len, int off, /* Extended capability block (after the standard sections). Returns 0 on * success, nonzero when the declared structure runs out of bounds. - * Grants flag bits into *flags for RGB/Tc/Su booleans and Smulx. */ + * Grants flag bits into *flags for RGB/Tc/Su booleans and Smulx/Sync + * strings. */ static int parse_ext(const uint8_t *b, int len, int off, int numw, uint32_t *flags) { if (off & 1) @@ -158,6 +159,8 @@ static int parse_ext(const uint8_t *b, int len, int off, int numw, continue; if (name_is(table, table_len, noff, "Smulx")) { *flags |= TERMINFO_STYLED_UNDERLINE; + } else if (name_is(table, table_len, noff, "Sync")) { + *flags |= TERMINFO_SYNC; } } } @@ -273,9 +276,10 @@ int terminfo_parse(const uint8_t *bytes, int len, struct TermInfo *ti) { /* The entry describes the terminal completely for the capabilities it * owns: replace them, leave probe-only flags and theme fields alone. */ - uint32_t keep = ~(TERMINFO_TRUECOLOR | TERMINFO_BACK_COLOR_ERASE | - TERMINFO_AUTO_RIGHT_MARGIN | TERMINFO_EAT_NEWLINE_GLITCH | - TERMINFO_ALTSCREEN | TERMINFO_STYLED_UNDERLINE); + uint32_t keep = + ~(TERMINFO_TRUECOLOR | TERMINFO_BACK_COLOR_ERASE | + TERMINFO_AUTO_RIGHT_MARGIN | TERMINFO_EAT_NEWLINE_GLITCH | + TERMINFO_ALTSCREEN | TERMINFO_STYLED_UNDERLINE | TERMINFO_SYNC); ti->flags = (ti->flags & keep) | flags; ti->colors = (uint32_t)colors; ti->generation++; diff --git a/tasks/gen-fixtures.ts b/tasks/gen-fixtures.ts index f03116c..d8eb14c 100644 --- a/tasks/gen-fixtures.ts +++ b/tasks/gen-fixtures.ts @@ -8,8 +8,9 @@ * - xterm-256color copied from the system terminfo database (legacy * format, magic 0432): real-world entry with am/xenl/bce/smcup and * colors#256, no truecolor capabilities. - * - clayterm-tc / clayterm-16 compiled with tic -x from the inline - * source below (legacy format with an extended capability block). + * - clayterm-tc / clayterm-sync / clayterm-16 compiled with tic -x from + * the inline source below (legacy format with an extended capability + * block). * - clayterm-direct hand-assembled in the extended number format * (magic 01036, 32-bit numbers) because macOS ships ncurses 6.0, * which predates that format: colors#0x1000000 plus an extended RGB @@ -26,6 +27,13 @@ const ENTRIES_SRC = `clayterm-tc|clayterm truecolor extended-caps test entry, \tkcuu1=\\EOZ, kf5=\\E[99~, \tSmulx=\\E[4:%p1%dm, +clayterm-sync|clayterm sync extended-caps test entry, +\tam, xenl, bce, +\tcolors#256, pairs#65536, cols#80, lines#24, +\tsmcup=\\E[?1049h, rmcup=\\E[?1049l, +\tcup=\\E[%i%p1%d;%p2%dH, +\tSync=\\E[?2026%p1%dh, + clayterm-16|clayterm sixteen color test entry, \tam, xenl, \tcolors#16, pairs#256, cols#80, lines#24, @@ -145,6 +153,7 @@ import { decodeBase64 } from "@std/encoding/base64"; ${constant("XTERM_256COLOR", xterm256)} ${constant("CLAYTERM_TC", compiled["clayterm-tc"])} +${constant("CLAYTERM_SYNC", compiled["clayterm-sync"])} ${constant("CLAYTERM_16", compiled["clayterm-16"])} ${constant("CLAYTERM_DIRECT", buildDirect())}`; diff --git a/term.ts b/term.ts index 8b8c2b2..1ff1c66 100644 --- a/term.ts +++ b/term.ts @@ -86,7 +86,6 @@ function applyUpdate( function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { return Object.freeze({ ...caps, - syncOutput: false, kittyKeyboard: false, kittyGraphics: false, pointerShape: false, @@ -179,6 +178,7 @@ export async function createTerm(options: TermOptions): Promise { xenl: true, altScreen: true, styledUnderline: false, + syncOutput: false, }, ); diff --git a/terminfo.ts b/terminfo.ts index bb3c081..c0b2a89 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -14,6 +14,7 @@ const FLAG_AUTO_RIGHT_MARGIN = 1 << 2; const FLAG_EAT_NEWLINE_GLITCH = 1 << 3; const FLAG_ALTSCREEN = 1 << 4; const FLAG_STYLED_UNDERLINE = 1 << 5; +const FLAG_SYNC = 1 << 6; const TermInfoStruct = struct({ generation: uint32(), @@ -43,6 +44,7 @@ export interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly syncOutput: boolean; } /** Opaque type alias for the raw terminfo key-sequence bytes. */ @@ -84,6 +86,7 @@ const PROBE = encoder.encode( "\x1b]22;?__current__\x1b\\" + "\x1bP+q524742;5463\x1b\\" + "\x1b[?2026$p" + + "\x1bP+q53796e63\x1b\\" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c", @@ -169,6 +172,14 @@ export async function detectTerminal( exports.terminfo_grant(structPtr, FLAG_TRUECOLOR); } + /* Environment evidence (terminfo-spec §7.2): a running tmux consumes + * DEC mode 2026 from applications inside it, so its presence is + * positive sync-output evidence even for tmux releases that do not + * answer the DECRPM probe. */ + if (env.TMUX) { + exports.terminfo_grant(structPtr, FLAG_SYNC); + } + let view = new DataView(memory.buffer); let flags = view.getUint32(structPtr + TI.flags, true); let capabilities: Capabilities = Object.freeze({ @@ -179,6 +190,7 @@ export async function detectTerminal( xenl: !!(flags & FLAG_EAT_NEWLINE_GLITCH), altScreen: !!(flags & FLAG_ALTSCREEN), styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), + syncOutput: !!(flags & FLAG_SYNC), }); return Object.freeze({ diff --git a/test/fixtures.ts b/test/fixtures.ts index 8030f3a..a5cb252 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -9,6 +9,10 @@ export const CLAYTERM_TC: Uint8Array = decodeBase64( "GgE4AB0ADwBYAC0AY2xheXRlcm0tdGN8Y2xheXRlcm0gdHJ1ZWNvbG9yIGV4dGVuZGVkLWNhcHMgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQBQAP//GAD//////////////////////////wAB/3///////////////////////////wAA/////////////////////////////////////////////xEA/////////////////////////////xoA////////////////////////////////////////////////////////////////////////////////IwD///////////////////////////////////////8pABtbJWklcDElZDslcDIlZEgAG1s/MTA0OWgAG1s/MTA0OWwAG1s5OX4AG09aAAACAAAAAQAEABcAAQEAAAAAAwAGABtbNDolcDElZG0AU3UAVGMAU211bHgA", ); +export const CLAYTERM_SYNC: Uint8Array = decodeBase64( + "GgE1AB0ADwApACMAY2xheXRlcm0tc3luY3xjbGF5dGVybSBzeW5jIGV4dGVuZGVkLWNhcHMgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAA//8YAP//////////////////////////AAH/f///////////////////////////AAD/////////////////////////////////////////////EQD/////////////////////////////GgAbWyVpJXAxJWQ7JXAyJWRIABtbPzEwNDloABtbPzEwNDlsAAAAAAAAAQACABMAAAAAABtbPzIwMjYlcDElZGgAU3luYwA=", +); + export const CLAYTERM_16: Uint8Array = decodeBase64( "GgEuAAUADwALABEAY2xheXRlcm0tMTZ8Y2xheXRlcm0gc2l4dGVlbiBjb2xvciB0ZXN0IGVudHJ5AAABAAABAFAA//8YAP//////////////////////////EAAAAf//////////////////////////AAAbWyVpJXAxJWQ7JXAyJWRIAA==", ); diff --git a/test/input.test.ts b/test/input.test.ts index 91ce14f..6ad3bdb 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -1085,6 +1085,59 @@ describe("terminfo integration", () => { ]); }); + it("surfaces sync-output=true from a value-carrying XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63=1b5b3f3230323668\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + ]); + }); + + it("surfaces sync-output=false from an invalid XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP0+r53796e63\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("surfaces sync-output=false from a Sync reply without a value", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("accepts a BEL-terminated XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63=31\x07")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + ]); + }); + + it("does not let a Sync denial disturb the colordepth tally", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("\x1bP0+r524742\x1b\\\x1bP0+r53796e63\x1b\\"), + ); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("keeps the colordepth denial working after a Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("\x1bP1+r53796e63=31\x1b\\\x1bP0+r\x1b\\"), + ); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + { type: "capability", key: "colordepth", value: "256" }, + ]); + }); + it("surfaces synchronized output from a DECRPM confirm report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b[?2026;2$y")); @@ -1192,6 +1245,11 @@ describe("terminfo integration", () => { ["\x1b]22;default\x1b\\", [cap("pointer-shape", true)]], ["\x1bP1+r524742=382F382F38\x1b\\", [cap("colordepth", "truecolor")]], ["\x1bP0+r\x1b\\", [cap("colordepth", "256")]], + [ + "\x1bP1+r53796e63=1b5b3f3230323668\x1b\\", + [cap("sync-output", true)], + ], + ["\x1bP0+r53796e63\x1b\\", [cap("sync-output", false)]], ["\x1b[?2026;2$y", [cap("sync-output", true)]], ["\x1b[?1u", [cap("kitty-keyboard", true)]], ["\x1b_Gi=31;OK\x1b\\", [cap("kitty-graphics", true)]], diff --git a/test/term.test.ts b/test/term.test.ts index 14ebb5f..90e5869 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -909,6 +909,55 @@ hi expect(out).not.toContain("\x1b[?2026"); }); + it("wraps frames when seeded from terminfo capabilities", async () => { + let t = await createTerm({ + width: 40, + height: 10, + terminfo: { + capabilities: { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + syncOutput: true, + }, + probe: new Uint8Array(0), + keys: new Uint8Array(0), + }, + }); + expect(t.capabilities.syncOutput).toBe(true); + let out = decode(t.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("stops wrapping when a probe reply refutes seeded evidence", async () => { + let t = await createTerm({ + width: 40, + height: 10, + terminfo: { + capabilities: { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + syncOutput: true, + }, + probe: new Uint8Array(0), + keys: new Uint8Array(0), + }, + }); + t.update([syncOff]); + let out = decode(t.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + it("wraps full-screen frames when the capability is on", () => { term.update([syncOn]); let out = decode(term.render(frame).output); diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 007b31e..6912f54 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -8,6 +8,7 @@ import { detectTerminal } from "../terminfo.ts"; import { CLAYTERM_16, CLAYTERM_DIRECT, + CLAYTERM_SYNC, CLAYTERM_TC, XTERM_256COLOR, } from "./fixtures.ts"; @@ -23,6 +24,7 @@ const BASELINE = { xenl: true, altScreen: true, styledUnderline: false, + syncOutput: false, }; describe("baseline", () => { @@ -46,6 +48,14 @@ describe("terminfo parsing", () => { expect(caps.bce).toBe(true); expect(caps.autoMargin).toBe(true); expect(caps.altScreen).toBe(true); + expect(caps.syncOutput).toBe(false); + }); + + it("reads Sync from the extended capability table", async () => { + let d = await detectTerminal({ env: {}, entry: CLAYTERM_SYNC }); + let caps = d.capabilities; + expect(caps.syncOutput).toBe(true); + expect(caps.trueColor).toBe(false); }); it("reads Tc / Su / Smulx from the extended capability table", async () => { @@ -113,6 +123,26 @@ describe("environment evidence", () => { let d = await detectTerminal({ env: { COLORTERM: "yes" } }); expect(d.capabilities.trueColor).toBe(false); }); + + it("grants syncOutput from TMUX", async () => { + let d = await detectTerminal({ + env: { TMUX: "/tmp/tmux-1000/default,12345,0" }, + }); + expect(d.capabilities.syncOutput).toBe(true); + }); + + it("ignores an empty TMUX", async () => { + let d = await detectTerminal({ env: { TMUX: "" } }); + expect(d.capabilities.syncOutput).toBe(false); + }); + + it("TMUX evidence applies even when the entry lacks Sync", async () => { + let d = await detectTerminal({ + env: { TMUX: "/tmp/tmux-1000/default,12345,0" }, + entry: XTERM_256COLOR, + }); + expect(d.capabilities.syncOutput).toBe(true); + }); }); describe("filesystem lookup", () => { @@ -184,6 +214,7 @@ const PROBE = "\x1b]10;?\x07" + "\x1b]22;?__current__\x1b\\" + "\x1bP+q524742;5463\x1b\\" + "\x1b[?2026$p" + + "\x1bP+q53796e63\x1b\\" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c";