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 implementation or roadmap noise. Changeset contains unsupported consumer-facing claims.

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

Changes the renderer's SGR color encoding to derive from terminal capability evidence instead of always emitting 24-bit truecolor sequences.

Terminals with truecolor evidence (`RGB`/`Tc` terminfo extension, `COLORTERM`, `max_colors ≥ 2²⁴`, or a `colordepth` probe response via `term.update()`) receive byte-identical truecolor output. Without 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. Standard palette colors keep their classic indices; grays use the 240-step grayscale ramp; everything else rounds to the 6×6×6 cube. A tier change between frames forces a complete redraw.

**The default behavior changed:** a `Term` created without color evidence now emits 256-color bytes where it previously emitted truecolor. Run `detectTerminal()` and pass its result to `createTerm`, or fold probe responses with `term.update()`, 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 });
```
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ EXPORTS = \
-Wl,--export=__heap_base \
-Wl,--export=clayterm_size \
-Wl,--export=init \
-Wl,--export=set_capabilities \
-Wl,--export=reduce \
-Wl,--export=output \
-Wl,--export=length \
Expand Down
346 changes: 346 additions & 0 deletions specs/color-encoding-spec.md

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,10 +475,13 @@ capability-specific output. Protocols that change emitted bytes MUST add their
own renderer section, capability evidence, invalidation rules, and tests in a
focused feature specification.

In particular, color encoding, synchronized-output wrapping, pointer-shape
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.
In particular, synchronized-output wrapping, pointer-shape 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. Color encoding is the
exception: it is specified in the
[Color Encoding Specification](color-encoding-spec.md), which the renderer
follows once adopted.

---

Expand All @@ -504,7 +507,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
218 changes: 198 additions & 20 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,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 @@ -179,8 +188,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 @@ -204,27 +374,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 @@ -681,6 +837,15 @@ int error_message_ptr(struct Clayterm *ct, int index) {
return (int)ct->errors[index].errorText.chars;
}

/* Push host-held color capability evidence into the instance
* (color-encoding-spec §4.2). Called once after init and after update()
* folds capability events that change either value; re-pushed by the
* host after any re-init so the values survive resize updates. */
void set_capabilities(struct Clayterm *ct, uint32_t colors, int truecolor) {
ct->colors = colors;
ct->truecolor = truecolor ? 1 : 0;
}

struct Clayterm *init(void *mem, int w, int h) {
set_clay_capacity(w, h);
struct Clayterm *ct = (struct Clayterm *)mem;
Expand All @@ -706,6 +871,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 Down Expand Up @@ -1024,6 +1191,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
1 change: 1 addition & 0 deletions src/clayterm.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct Clayterm;
/* WASM exports */
int clayterm_size(int w, int h);
struct Clayterm *init(void *mem, int w, int h);
void set_capabilities(struct Clayterm *ct, uint32_t colors, int truecolor);
void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
float deltaTime);
char *output(struct Clayterm *ct);
Expand Down
Loading
Loading