Skip to content
Draft
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
20 changes: 20 additions & 0 deletions .changeset/color-encoding-tiers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---

@bombshell-cooper bombshell-cooper Bot Oct 12, 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 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. Changeset does not name the affected public API confidently. Changeset contains unsupported consumer-facing claims.

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

Adds capability-aware SGR color encoding to the renderer.

The renderer now derives its color encoding from terminal capability evidence instead of always emitting 24-bit truecolor. Terminals with truecolor evidence (`RGB`/`Tc`, `COLORTERM`, direct-color `max_colors`, or a `colordepth` probe folded through `term.update()`) continue to receive byte-identical truecolor output. Without truecolor evidence, cells emit 256-color SGR (`\x1b[38;5;Nm`) when `colors > 16`, or 4-bit SGR (`\x1b[31m`) at or below 16. Colors are downmapped deterministically: nominal palette entries keep their classic indices, grays use the grayscale ramp, and everything else rounds to the 6×6×6 cube. A tier change between frames forces a complete redraw.

The baseline — no capability evidence — now resolves to 256-color rather than truecolor. Pass `detectTerminal()` output to `createTerm` to restore truecolor output on terminals that support it.

#### Migration

```diff
  import { createTerm } from "@bomb.sh/tty";
+ import { detectTerminal } from "@bomb.sh/tty";

- const term = await createTerm({ width, height });
+ const terminfo = await detectTerminal();
+ const term = await createTerm({ width, height, terminfo });
```

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

The renderer now derives its SGR color encoding from the terminal's capability evidence instead of always emitting 24-bit truecolor. Truecolor evidence (`RGB`/`Tc` terminfo extension, `COLORTERM`, direct-color entries with `max_colors ≥ 2²⁴`, or a `colordepth` probe response folded through `term.update()`) keeps the historical byte-identical truecolor output. Without truecolor evidence, cells emit 256-color SGR (`\x1b[38;5;Nm`) when the resolved palette exceeds 16 colors, or 4-bit SGR (`\x1b[31m`) at or below 16.

Colors are downmapped deterministically: standard palette colors keep their classic indices (`rgba(255,0,0)` → `\x1b[31m` / `\x1b[38;5;1m`), grays use the 240-step grayscale ramp, and everything else rounds to the 6×6×6 cube. A tier change between frames forces a complete redraw so no stale encoding survives on screen.

**Breaking:** rendering without color evidence now produces 256-color bytes where it previously produced truecolor. Run `detectTerminal()` and pass its `TerminalInfo` to `createTerm` (or fold probe responses with `term.update()`) to keep truecolor output — terminals that support truecolor report it.

#### Migration

```diff
import { createTerm } from "@bomb.sh/tty";
+ import { detectTerminal } from "@bomb.sh/tty";

- const term = await createTerm({ width, height });
+ const terminfo = await detectTerminal();
+ const term = await createTerm({ width, height, terminfo });
```
346 changes: 346 additions & 0 deletions specs/color-encoding-spec.md

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,11 @@ capability, output, and invalidation rules in its own section of this
specification. Synchronized output (§7.9) and pointer shape (§7.10) are
specified so far.

Color encoding, Kitty keyboard mode setup, and Kitty graphics emission are
In particular, 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 each is specified.
existing hardcoded ANSI output until each is specified. Color encoding is
specified in the [Color Encoding Specification](color-encoding-spec.md), which
the renderer follows once adopted.

### 7.9 Synchronized output (DEC mode 2026)

Expand Down Expand Up @@ -594,7 +596,9 @@ parameters specify the terminal dimensions in character cells.
The optional `terminfo` value (from `detectTerminal()`; see
[Terminfo Specification](terminfo-spec.md) §10.1) initializes the Term's private
`RuntimeCapabilities` from the static `Capabilities` it carries. When omitted,
the Term uses the §7.1 baseline.
the Term uses the §7.1 baseline. The renderer resolves its color encoding from
this capability snapshot; the derivation rule and downmapping behavior are
specified in the [Color Encoding Specification](color-encoding-spec.md).

### 8.2 Render invocation

Expand Down
237 changes: 207 additions & 30 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ struct Clayterm {
Buffer out;
uint32_t lastfg, lastbg;
int lastx, lasty;
/* Color capability evidence pushed by the host (color-encoding-spec
* §4.2). Authoritative for emission; the host re-pushes after any
* re-init, so the values survive resize updates. */
uint32_t colors; /* max_colors evidence; §7.1 baseline is 256 */
int truecolor; /* positive truecolor evidence */
/* Emission tier, resolved once per reduce. last_tier detects tier
* changes, which force a complete redraw (color-encoding-spec CI-5). */
int tier;
int last_tier;
/* clip region (active top mirrored here so setcell stays unchanged) */
int clipx, clipy, clipw, cliph;
int clipping;
Expand Down Expand Up @@ -217,8 +226,169 @@ static void append_combining(struct Clayterm *ct, int x, int y, uint32_t cp) {
report_combining_exceeded(ct);
}

/* ── Color tiers and downmapping ──────────────────────────────────── */

/* Emission tiers (color-encoding-spec §6.1). */
#define TIER_TRUECOLOR 0
#define TIER_256 1
#define TIER_16 2

/* Nominal 16-color palette (color-encoding-spec §6.3.1): the renderer's
* canonical model of what each 4-bit index means for downmapping. Pure
* primaries for 0-7, pastel brights for 8-15. Not a claim about any
* terminal's actual palette. */
static const uint32_t NOMINAL_PALETTE[16] = {
0x000000, 0xff0000, 0x00ff00, 0xffff00, 0x0000ff, 0xff00ff,
0x00ffff, 0xc0c0c0, 0x808080, 0xff5555, 0x55ff55, 0xffff55,
0x5555ff, 0xff55ff, 0x55ffff, 0xffffff,
};

/* 6x6x6 color cube channel values (indices 16-231). */
static const int CUBE_STEPS[6] = {0, 95, 135, 175, 215, 255};

/* Resolve the emission tier from the capability evidence pushed by the
* host (color-encoding-spec §6.1). The colors <= 16 boundary matches the
* colordepth denial mapping in terminfo-spec §6.3. */
static int resolve_tier(const struct Clayterm *ct) {
if (ct->truecolor)
return TIER_TRUECOLOR;
if (ct->colors <= 16)
return TIER_16;
return TIER_256;
}

/* Nearest nominal palette entry by squared Euclidean distance; ties
* resolve to the lowest index (color-encoding-spec §6.3.2). */
static int map_16(uint32_t rgb) {
int r = (rgb >> 16) & 0xff;
int g = (rgb >> 8) & 0xff;
int b = rgb & 0xff;
int best = 0;
int best_dist = -1;
for (int i = 0; i < 16; i++) {
uint32_t c = NOMINAL_PALETTE[i];
int dr = r - (int)((c >> 16) & 0xff);
int dg = g - (int)((c >> 8) & 0xff);
int db = b - (int)(c & 0xff);
int dist = dr * dr + dg * dg + db * db;
if (best_dist < 0 || dist < best_dist) {
best_dist = dist;
best = i;
}
}
return best;
}

/* The cube step for an exact cube value, or -1 when the value is not a
* cube channel value. */
static int cube_exact(int v) {
for (int i = 0; i < 6; i++) {
if (CUBE_STEPS[i] == v)
return i;
}
return -1;
}

/* Nearest cube step for an arbitrary channel value; ties resolve to the
* smaller value (color-encoding-spec §6.3.3 rule 4). */
static int cube_step(int v) {
int best = 0;
int best_dist = -1;
for (int i = 0; i < 6; i++) {
int d = v - CUBE_STEPS[i];
if (d < 0)
d = -d;
if (best_dist < 0 || d < best_dist) {
best_dist = d;
best = i;
}
}
return best;
}

/* Downmap a 24-bit RGB value to a 256-palette index. Ordered rules;
* the first match wins (color-encoding-spec §6.3.3). Rules 1-3 make
* every 256-palette entry resolve to an index of the identical color
* (CI-4; the nominal index wins where the palettes overlap). */
static int map_256(uint32_t rgb) {
int r = (rgb >> 16) & 0xff;
int g = (rgb >> 8) & 0xff;
int b = rgb & 0xff;

/* 1. exact nominal match: standard colors keep their 4-bit indices */
for (int i = 0; i < 16; i++) {
if (NOMINAL_PALETTE[i] == rgb)
return i;
}

/* 2. exact cube match */
int cr = cube_exact(r);
int cg = cube_exact(g);
int cb = cube_exact(b);
if (cr >= 0 && cg >= 0 && cb >= 0)
return 16 + 36 * cr + 6 * cg + cb;

/* 3. grayscale: nearest of the ramp extended by both endpoints.
* Candidates run in ascending index order (16, 231, 232..255), so a
* strict comparison resolves ties to the lowest index. */
if (r == g && g == b) {
int best = 16;
int best_dist = r; /* value 0 at index 16 */
int d = 255 - r; /* value 255 at index 231 */
if (d < best_dist) {
best = 231;
best_dist = d;
}
for (int i = 0; i < 24; i++) {
int val = 8 + 10 * i;
d = r - val;
if (d < 0)
d = -d;
if (d < best_dist) {
best = 232 + i;
best_dist = d;
}
}
return best;
}

/* 4. nearest cube value per channel */
return 16 + 36 * cube_step(r) + 6 * cube_step(g) + cube_step(b);
}

/* ── Escape sequence generation ───────────────────────────────────── */

/* Emit the SGR color sequence for one packed RGB value under the active
* tier (color-encoding-spec §6.2). `is_fg` selects the foreground
* (38, 3x/9x) or background (48, 4x/10x) code family. */
static void emit_sgr_color(struct Clayterm *ct, uint32_t rgb, int is_fg) {
switch (ct->tier) {
case TIER_16: {
int idx = map_16(rgb);
int base = is_fg ? 30 : 40;
if (idx >= 8)
base += 60; /* aixterm bright codes: 90-97 / 100-107 */
buf_str(&ct->out, "\x1b[");
buf_num(&ct->out, base + (idx & 7));
buf_put(&ct->out, "m", 1);
break;
}
case TIER_256:
buf_str(&ct->out, is_fg ? "\x1b[38;5;" : "\x1b[48;5;");
buf_num(&ct->out, map_256(rgb));
buf_put(&ct->out, "m", 1);
break;
default: /* TIER_TRUECOLOR */
buf_str(&ct->out, is_fg ? "\x1b[38;2;" : "\x1b[48;2;");
buf_num(&ct->out, (rgb >> 16) & 0xff);
buf_put(&ct->out, ";", 1);
buf_num(&ct->out, (rgb >> 8) & 0xff);
buf_put(&ct->out, ";", 1);
buf_num(&ct->out, rgb & 0xff);
buf_put(&ct->out, "m", 1);
}
}

static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) {
if (fg == ct->lastfg && bg == ct->lastbg)
return;
Expand All @@ -242,27 +412,13 @@ static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) {
if (fg & ATTR_STRIKEOUT)
buf_str(&ct->out, "\x1b[9m");

/* foreground truecolor */
if (!(fg & ATTR_DEFAULT)) {
buf_str(&ct->out, "\x1b[38;2;");
buf_num(&ct->out, (fg >> 16) & 0xff);
buf_put(&ct->out, ";", 1);
buf_num(&ct->out, (fg >> 8) & 0xff);
buf_put(&ct->out, ";", 1);
buf_num(&ct->out, fg & 0xff);
buf_put(&ct->out, "m", 1);
}
/* foreground */
if (!(fg & ATTR_DEFAULT))
emit_sgr_color(ct, fg & COLOR_MASK, 1);

/* background truecolor */
if (!(bg & ATTR_DEFAULT)) {
buf_str(&ct->out, "\x1b[48;2;");
buf_num(&ct->out, (bg >> 16) & 0xff);
buf_put(&ct->out, ";", 1);
buf_num(&ct->out, (bg >> 8) & 0xff);
buf_put(&ct->out, ";", 1);
buf_num(&ct->out, bg & 0xff);
buf_put(&ct->out, "m", 1);
}
/* background */
if (!(bg & ATTR_DEFAULT))
emit_sgr_color(ct, bg & COLOR_MASK, 0);

ct->lastfg = fg;
ct->lastbg = bg;
Expand Down Expand Up @@ -740,6 +896,24 @@ int error_message_ptr(struct Clayterm *ct, int index) {
return (int)ct->errors[index].errorText.chars;
}

/* Push host-held capability evidence into the instance. Called once after
* init and after update() folds events that change any value; re-pushed by the
* host after any re-init so the evidence survives resize updates. Returns true
* when withdrawing pointerShape leaves a non-default shape on screen that the
* caller must reset. */
int set_capabilities(struct Clayterm *ct, uint32_t caps, uint32_t colors,
int truecolor) {
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;
ct->colors = colors;
ct->truecolor = truecolor ? 1 : 0;
return restore;
}

struct Clayterm *init(void *mem, int w, int h) {
set_clay_capacity(w, h);
struct Clayterm *ct = (struct Clayterm *)mem;
Expand All @@ -766,6 +940,8 @@ struct Clayterm *init(void *mem, int w, int h) {
.lastbg = 0xffffffff,
.lastx = -1,
.lasty = -1,
.colors = 256, /* §7.1 baseline until the host pushes evidence */
.last_tier = -1,
};

// initialize back buffer with spaces and default fg/bg
Expand All @@ -776,16 +952,6 @@ struct Clayterm *init(void *mem, int w, int h) {
return ct;
}

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;
}

int pointer_shape(struct Clayterm *ct) { return ct->pointer_shape; }

void set_pointer_shape(struct Clayterm *ct, int shape) {
Expand Down Expand Up @@ -1113,6 +1279,17 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
}
}

/* Resolve the emission tier for this frame and force a complete
* redraw when it changed (color-encoding-spec CI-5): diff state is
* cell-based, so unchanged cells would otherwise keep bytes from the
* previous encoding on screen. Zeroing the front buffer makes every
* cell diff as changed. */
ct->tier = resolve_tier(ct);
if (ct->tier != ct->last_tier) {
cells_fill(ct->front, ct->w, ct->h, 0, 0, 0);
ct->last_tier = ct->tier;
}

if (mode == 1) {
present_lines(ct);
} else {
Expand Down
3 changes: 2 additions & 1 deletion src/clayterm.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ struct Clayterm;
/* WASM exports */
int clayterm_size(int w, int h);
struct Clayterm *init(void *mem, int w, int h);
int set_capabilities(struct Clayterm *ct, uint32_t caps);
int set_capabilities(struct Clayterm *ct, uint32_t caps, uint32_t colors,
int truecolor);
int pointer_shape(struct Clayterm *ct);
void set_pointer_shape(struct Clayterm *ct, int shape);
void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
Expand Down
Loading
Loading