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

@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 the `img()` directive — declarative images rendered through a fidelity ladder: kitty graphics protocol pixels, shape-aware ASCII character art, or alt text, resolved from capability evidence (`RuntimeCapabilities.kittyGraphics`).

Surfaces are caller-owned decoded RGBA buffers managed by the Term:

```ts
term.setImage(1, { width: 128, height: 64, pixels: rgba8 });
term.removeImage(1); // returns deletion bytes to write now
term.render([img("cover", { image: 1, alt: "cover art" })]);
```

`createTerm` accepts an `imagePoolBytes` option (default 4 MiB) sizing the fixed pixel pool. Transmission is lazy; re-transmission only on data change; steady-state renders are byte-silent. `update()` emits cleanup bytes immediately when a capability denial or resize invalidates live placements. Line mode caps the ladder at ascii. Errors surface as `IMAGE_NOT_FOUND`, `IMAGE_PLACEMENTS_EXCEEDED`, and `IMAGE_PLACEMENT_COLLISION`.

#### Migration

This release is pre-1.0 and this addition changes the `Term` interface — any manual implementation of `Term` must add `setImage` and `removeImage`.

```diff
  const myTerm: Term = {
    render: ...,
    update: ...,
+   setImage(id, data) { /* or delegate: term.setImage(id, data) */ },
+   removeImage(id) { /* or delegate: return term.removeImage(id) */ },
    capabilities: ...,
  };
```

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

Adds the `img()` directive — declarative images rendered through a fidelity ladder: kitty graphics protocol pixels, shape-aware ASCII character art, or alt text, resolved from capability evidence (`RuntimeCapabilities.kittyGraphics`).

Surfaces are caller-owned decoded RGBA buffers managed by the Term:

```ts
term.setImage(1, { width: 128, height: 64, pixels: rgba8 });
term.removeImage(1); // returns deletion bytes to write now
term.render([img("cover", { image: 1, alt: "cover art" })]);
```

`createTerm` takes an `imagePoolBytes` option (default 4 MiB) sizing the fixed pixel pool. Transmission is lazy; re-transmission only on data change; steady-state renders are byte-silent. `update()` emits cleanup bytes immediately when a capability denial or resize kills live placements (TINV-5). Line mode caps the ladder at ascii. Errors surface as `IMAGE_NOT_FOUND`, `IMAGE_PLACEMENTS_EXCEEDED`, and `IMAGE_PLACEMENT_COLLISION`.
94 changes: 93 additions & 1 deletion ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const OP_OPEN_ELEMENT = 0x02;
const OP_TEXT = 0x03;
const OP_CLOSE_ELEMENT = 0x04;
const OP_SNAPSHOT = 0x05;
/* the void image directive (Graphics Specification §6.1) */
const OP_IMG = 0x06;

/* Property group masks for OPEN_ELEMENT */
const PROP_LAYOUT = 0x01;
Expand Down Expand Up @@ -323,6 +325,37 @@ export function pack(
o = packString(view, str, o, end, "text content");
break;
}

case OP_IMG: {
view.setUint32(o, OP_IMG, true);
o += 4;

o = packString(view, encoder.encode(op.id), o, end, "element id");

// image registry id; 0 = omitted (alt-only element)
view.setUint32(o, op.image ?? 0, true);
o += 4;

// variant (low byte) | bg_flag (byte 1) | pad
view.setUint32(
o,
IMG_VARIANT_CODE[op.variant ?? "auto"] |
((op.bg !== undefined ? 1 : 0) << 8),
true,
);
o += 4;

o = packAxis(view, o, op.width ?? fit());
o = packAxis(view, o, op.height ?? fit());

o = packString(view, encoder.encode(op.alt), o, end, "alt text");

if (op.bg !== undefined) {
view.setUint32(o, op.bg, true);
o += 4;
}
break;
}
}
if (o > end) {
throw new RangeError(
Expand Down Expand Up @@ -488,12 +521,61 @@ export interface Text {
caret?: number;
}

export type ImgVariant = "auto" | "kitty" | "ascii" | "alt";

export interface ImgProps {
/** Registry id of the image to render. Omit for an alt-only element. */
image?: number;
/** Required. Text fallback and description. "" = decorative. */
alt: string;
width?: SizingAxis;
height?: SizingAxis;
/** Tier override; evidence-gated — falls through the auto ladder when the
* tier's evidence is missing (Graphics Specification §9.1). */
variant?: ImgVariant;
/** Element background, same value space as open({ bg }). */
bg?: number;
}

/** The void image element: one self-closing directive, no children, not
* paired with close() (Graphics Specification §6.1). */
export interface Img {
directive: typeof OP_IMG;
id: string;
image?: number;
alt: string;
width?: SizingAxis;
height?: SizingAxis;
variant?: ImgVariant;
bg?: number;
}

const IMG_VARIANT_CODE: Record<ImgVariant, number> = {
auto: 0,
kitty: 1,
ascii: 2,
alt: 3,
};

export function img(id: string, props: ImgProps): Img {
return {
directive: OP_IMG,
id,
image: props.image,
alt: props.alt,
width: props.width ?? fit(),
height: props.height ?? fit(),
variant: props.variant ?? "auto",
bg: props.bg,
};
}

interface Snapshot {
directive: typeof OP_SNAPSHOT;
data: Uint8Array;
}

export type Op = OpenElement | Text | CloseElement | Snapshot;
export type Op = OpenElement | Text | CloseElement | Snapshot | Img;

export function open(
id: string,
Expand Down Expand Up @@ -542,6 +624,16 @@ function packSize(ops: Op[]): number {
n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string
break;
}
case OP_IMG: {
n += 4; // opcode
n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string
n += 4; // image registry id
n += 4; // variant | bg_flag | pad
n += 6 * 4; // width + height axes
n += 4 + Math.ceil(encoder.encode(op.alt).length / 4) * 4; // alt string
if (op.bg !== undefined) n += 4; // element background
break;
}
}
}
return n;
Expand Down
128 changes: 123 additions & 5 deletions term.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ export interface TermOptions {
* baseline.
*/
terminfo?: TerminalInfo;
/**
* Sizes the Term's carved image pixel pool (Graphics Specification §5.3).
* Default 4 MiB. The registry never grows at runtime (INV-I8).
*/
imagePoolBytes?: number;
}

/**
* Decoded raster pixels for the image registry (Graphics Specification
* §6.2): straight (non-premultiplied) RGBA8, top-to-bottom rows.
*/
export interface ImageData {
width: number; // positive integer
height: number; // positive integer
pixels: Uint8Array; // exactly width * height * 4 bytes
}

/**
Expand Down Expand Up @@ -126,6 +141,9 @@ const ERROR_TYPES = [
"UNBALANCED_OPEN_CLOSE",
"CLIP_DEPTH_EXCEEDED",
"COMBINING_MARKS_EXCEEDED",
"IMAGE_NOT_FOUND",
"IMAGE_PLACEMENTS_EXCEEDED",
"IMAGE_PLACEMENT_COLLISION",
] as const;

export interface ClayError {
Expand Down Expand Up @@ -157,17 +175,45 @@ export interface Term {
*/
update(events: readonly InputEvent[]): Uint8Array;

/**
* Put a decoded image into the Term's pixel-surface registry (Graphics
* Specification §6.2). Synchronous, byte-free: transmission is lazy, on
* the first render that places the image. Throws RangeError on
* validation failure or pool/registry exhaustion. Re-setting a live id
* replaces the image and re-transmits on the next placing render.
*/
setImage(id: number, data: ImageData): void;

/**
* Remove a registry image. Returns bytes to write now: the terminal's
* data-freeing deletion block when the id was live, empty otherwise.
* Idempotent; the covered cells are reconciled so the next render
* repaints them.
*/
removeImage(id: number): Uint8Array;

/** Frozen snapshot of the current merged capability state. */
readonly capabilities: RuntimeCapabilities;
}

const IMAGE_ERROR_MESSAGES: Record<number, string> = {
[-1]: "registry id outside [1, 4294967295]",
[-2]: "width and height must be positive integers",
[-4]: "image registry table full",
[-5]: "image pixel pool exhausted (raise the imagePoolBytes option)",
[-6]: "unknown registry id",
};

function imageRangeError(n: number): RangeError {
return new RangeError(
`setImage failed: ${IMAGE_ERROR_MESSAGES[n] ?? `native error ${n}`}`,
);
}

export async function createTerm(options: TermOptions): Promise<Term> {
let { width, height, terminfo } = options;
let { width, height, terminfo, imagePoolBytes } = options;

let native = await createTermNative(
width,
height,
);
let native = await createTermNative(width, height, imagePoolBytes);
let { memory } = native;

let currentCaps: RuntimeCapabilities = runtimeFromStatic(
Expand Down Expand Up @@ -283,6 +329,50 @@ export async function createTerm(options: TermOptions): Promise<Term> {
return { output, events, info, errors, animating };
},

setImage(id: number, data: ImageData): void {
if (!Number.isInteger(id) || id < 1 || id > 4294967295) {
throw new RangeError(`invalid image registry id ${id}`);
}
if (
!Number.isInteger(data.width) || data.width < 1 ||
!Number.isInteger(data.height) || data.height < 1
) {
throw new RangeError(
`invalid image dimensions ${data.width}x${data.height}`,
);
}
if (data.pixels.length !== data.width * data.height * 4) {
throw new RangeError(
`image pixels length ${data.pixels.length} !== ${data.width} * ${data.height} * 4`,
);
}
let addr = native.imageBegin(
native.statePtr,
id,
data.width,
data.height,
);
if (addr < 0) {
throw imageRangeError(addr);
}
new Uint8Array(memory.buffer).set(data.pixels, addr);
let committed = native.imageCommit(native.statePtr, id);
if (committed < 0) {
throw imageRangeError(committed);
}
},

removeImage(id: number): Uint8Array {
let n = native.imageRemove(native.statePtr, id);
if (n <= 0) {
return new Uint8Array(0);
}
// copy: the native output buffer is reused by the next call; these
// bytes are the caller's to write now (TINV-5)
return new Uint8Array(memory.buffer, native.output(native.statePtr), n)
.slice();
},

update(events: readonly InputEvent[]): Uint8Array {
let out: Uint8Array[] = [];

Expand All @@ -298,6 +388,18 @@ export async function createTerm(options: TermOptions): Promise<Term> {
throw new RangeError(`invalid terminal dimensions ${w}x${h}`);
}
if (w !== width || h !== height) {
// Graphics Specification §10.5: deletion bytes for live
// placements precede the placements record's discard
let prepared = native.graphicsResizePrepare(native.statePtr);
if (prepared > 0) {
out.push(
new Uint8Array(
memory.buffer,
native.output(native.statePtr),
prepared,
).slice(),
);
}
width = w;
height = h;
native.update(width, height);
Expand All @@ -307,6 +409,22 @@ export async function createTerm(options: TermOptions): Promise<Term> {
lastRenderAt = undefined;
wasAnimating = false;
}
} else if (
c.type === "capability" && c.key === "kitty-graphics"
) {
// the renderer's capability mirror (terminfo-spec §4.2); the
// denial path emits §11.2's deletion bytes immediately (TINV-5)
let kitty = c.value ? 1 : 0;
let wrote = native.graphicsCapability(native.statePtr, kitty);
if (wrote > 0) {
out.push(
new Uint8Array(
memory.buffer,
native.output(native.statePtr),
wrote,
).slice(),
);
}
}

currentCaps = next;
Expand Down
Loading
Loading