This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Preserve palette index through CellBuffer so re-emitters can keep the outer terminals theme

Part 2 of the palette-preservation work — first half was on
PtyHandle.readCells (commit 6248390). That change landed fgIndex /
bgIndex on the PtyCell shape but pty-layout tracing showed the round
trip broke inside the CellBuffer pipeline: setCell -> Cell (in
types.ts) had no index fields, so writeAnsi / diff / fullRender
silently dropped the index and re-emitted as truecolor.

Now the buffer-side Cell carries fgIndex / bgIndex (required,
number | null) and the pipeline plumbs them through:

- writeAnsi captures the index on SGR 30-37 / 90-97 / 38;5;N /
48;5;N, and clears it on 38;2 / 48;2 truecolor and on SGR 0 / 39 /
49 resets.
- cellsEqual compares the index so diff re-emits correctly when only
the index changed (same flattened RGB, different palette slot).
- diff + fullRender use shared emitFg / emitBg helpers that prefer
indexed SGR when fgIndex is non-null (SGR 30-37 for 0-7, 90-97 for
8-15, 38;5;N for 16-255) and fall back to truecolor SGR 38;2 /
48;2 otherwise.
- ptyView rendering path forwards cell.fgIndex / bgIndex from the
upstream PtyCell, so embedded panes re-emitted by consumers like
pty-layout keep the outer terminal's theme for indexed cells while
still supporting truecolor for programs that emit it.

Required rather than optional on Cell — nothing outside this repo
constructs Cell objects in our tree, and strict typing keeps the few
internal construction sites (emptyCell, makeCell, writeAnsi, FPS
counter literal in app.ts) honest. pty-layout's one selection-
highlight construction site will need a trivial fgIndex/bgIndex: null
addition.

Nathan Herald (Apr 23, 2026, 12:05 PM +0200) 82ed4463 6248390e

+338 -44
+1
CHANGELOG.md
··· 21 21 - Add integration coverage for both installers: a real `systemctl --user` install/uninstall test and a runit install test that boots the generated service under a private `runsvdir`. 22 22 23 23 ### TUI framework 24 + - **Palette index preserved end-to-end through the CellBuffer pipeline.** Second half of the palette-preservation work (first half was on `PtyHandle.readCells`). The buffer-side `Cell` interface in `src/tui/types.ts` gained `fgIndex: number | null` and `bgIndex: number | null` (required, to match the strictness of `fg`/`bg` and keep all internal constructors honest). `writeAnsi` now captures the index on SGR 30-37 / 90-97 / 38;5;N / 48;5;N (and nulls it on truecolor 38;2 or resets 0/39/49). `diff` and `fullRender` emit the indexed SGR when `fgIndex` / `bgIndex` is non-null and fall back to 38;2 truecolor otherwise, so round-trips like `writeAnsi("\x1b[34mhi")` → `fullRender` produce `\x1b[34m` and not `\x1b[38;2;0;0;204m`. `cellsEqual` compares the indices, so the diff layer correctly re-emits when only the index changed. Same change propagated through `emptyCell`, `makeCell`, and the few inline cell literals. The ptyView rendering path now forwards `cell.fgIndex`/`bgIndex` into the buffer, so embedded PTY panes re-emitted by consumers like pty-layout keep the outer terminal's theme for indexed colors while still supporting truecolor for programs that emit it. 24 25 - **`PtyHandle.readCells` now preserves palette indices (`fgIndex` / `bgIndex`).** Cells returned from `readCells()` gained two optional fields: `fgIndex: number | null` and `bgIndex: number | null`, populated with the raw 0-255 palette index whenever the source used SGR 30-37 / 90-97 / 38;5;N / 48;5;N. Previously palette cells were flattened to hardcoded VGA RGB via `paletteToRgb()` (blue became `[0,0,204]`, etc.), which was correct for `ptyView()` rendering but wrong for consumers like pty-layout that reconstruct the cell grid into SGR bytes aimed at a real terminal — the outer terminal's theme was lost. The existing `fg` / `bg` RGB fields are unchanged and still filled with the flattened approximation, so nothing breaks; re-emitters just check `fgIndex` first. Named `PtyCell` type (new) exported from `@myobie/pty/tui` for consumers that want the shape without a `ReturnType<...>` dance. `underlineColor` deliberately skipped — the existing `Cell` shape only tracks `underline: boolean`, so palette-aware underline colors would be a bigger addition than a nullable-index twin of an existing field. 25 26 - **Automatic re-render on embedded PTY data arrival.** `PtyHandle` now carries a reactive `rev` signal that bumps on every data/exit/resize/theme event, and `ptyView()`'s render path reads it — so the framework's `effect()` re-renders automatically whenever the embedded terminal content changes. Previously the consumer had to wire `handle.onActivity` to a signal bump manually; forgetting to meant output appeared to "buffer" until some unrelated signal read triggered a render (e.g., a keystroke forcing a cursor-position update). `onActivity` remains as an escape hatch for non-render side effects (stats, notifications) but is no longer required for the common case. Non-breaking. 26 27 - **Focus manager.** `createFocusManager()` + `ctx.focus` on every `ScreenContext`. Stack-based: `push(scope)` returns a disposer, `dispatchKey` / `dispatchMouse` walk innermost → outermost with bubbling (return `false` to let the parent scope try). Scopes can be conditionally active via an `active: () => boolean` predicate — useful for keeping sibling scopes alive (like one per pane) and switching which one dispatches without pushing/popping. Solves the "nested panes / modals / overlays" routing problem that every app was re-implementing by hand. The playground now uses it: global pane-switch chords via `AppConfig.onKey` (they intercept before any scope so modals can't swallow them); pane scopes in the focus stack with `active: () => pane.get() === ...`. Modal overlays just push a scope on top and dispose it when closed. `FocusManager`, `FocusScope` exported from `@myobie/pty/tui`.
+1
src/tui/app.ts
··· 162 162 if (col + i >= 0 && col + i < buf.cols) { 163 163 buf.cells[0][col + i] = { 164 164 char: label[i], fg: theme.bg1 ? [...theme.bg1] : null, bg: theme.fgAc ? [...theme.fgAc] : null, 165 + fgIndex: null, bgIndex: null, 165 166 bold: false, dim: false, italic: false, underline: false, 166 167 }; 167 168 }
+106 -42
src/tui/buffer.ts
··· 48 48 let curCol = 0; 49 49 let fgColor: [number, number, number] | null = null; 50 50 let bgColor: [number, number, number] | null = null; 51 + // Palette index tracking — populated on indexed SGR (30-37 / 90-97 / 52 + // 38;5;N / 48;5;N), nulled on truecolor SGR (38;2) and resets (0, 39, 49). 53 + let fgIndex: number | null = null; 54 + let bgIndex: number | null = null; 51 55 let isBold = false; 52 56 let isDim = false; 53 57 let isItalic = false; ··· 75 79 const p = parts[j]!; 76 80 if (p === 0) { 77 81 fgColor = null; bgColor = null; 82 + fgIndex = null; bgIndex = null; 78 83 isBold = false; isDim = false; isItalic = false; isUnderline = false; 79 84 } else if (p === 1) isBold = true; 80 85 else if (p === 2) isDim = true; ··· 84 89 else if (p === 23) isItalic = false; 85 90 else if (p === 24) isUnderline = false; 86 91 else if (p === 27) { /* reset inverse - ignore */ } 87 - else if (p === 39) fgColor = null; 88 - else if (p === 49) bgColor = null; 92 + else if (p === 39) { fgColor = null; fgIndex = null; } 93 + else if (p === 49) { bgColor = null; bgIndex = null; } 89 94 else if (p === 38 && parts[j + 1] === 2) { 90 95 fgColor = [parts[j + 2] ?? 0, parts[j + 3] ?? 0, parts[j + 4] ?? 0]; 96 + fgIndex = null; // truecolor has no palette index 91 97 j += 4; 92 98 } else if (p === 48 && parts[j + 1] === 2) { 93 99 bgColor = [parts[j + 2] ?? 0, parts[j + 3] ?? 0, parts[j + 4] ?? 0]; 100 + bgIndex = null; 94 101 j += 4; 95 102 } else if (p === 38 && parts[j + 1] === 5) { 96 - fgColor = ansi256ToRgb(parts[j + 2] ?? 0); 103 + const n = parts[j + 2] ?? 0; 104 + fgColor = ansi256ToRgb(n); 105 + fgIndex = n; 97 106 j += 2; 98 107 } else if (p === 48 && parts[j + 1] === 5) { 99 - bgColor = ansi256ToRgb(parts[j + 2] ?? 0); 108 + const n = parts[j + 2] ?? 0; 109 + bgColor = ansi256ToRgb(n); 110 + bgIndex = n; 100 111 j += 2; 101 - } else if (p >= 30 && p <= 37) fgColor = ansi16ToRgb(p - 30); 102 - else if (p >= 40 && p <= 47) bgColor = ansi16ToRgb(p - 40); 103 - else if (p >= 90 && p <= 97) fgColor = ansi16ToRgb(p - 90 + 8); 104 - else if (p >= 100 && p <= 107) bgColor = ansi16ToRgb(p - 100 + 8); 112 + } else if (p >= 30 && p <= 37) { fgColor = ansi16ToRgb(p - 30); fgIndex = p - 30; } 113 + else if (p >= 40 && p <= 47) { bgColor = ansi16ToRgb(p - 40); bgIndex = p - 40; } 114 + else if (p >= 90 && p <= 97) { fgColor = ansi16ToRgb(p - 90 + 8); fgIndex = p - 90 + 8; } 115 + else if (p >= 100 && p <= 107) { bgColor = ansi16ToRgb(p - 100 + 8); bgIndex = p - 100 + 8; } 105 116 j++; 106 117 } 107 118 } else if (cmd === "H") { ··· 151 162 char: ch, 152 163 fg: fgColor ? [...fgColor] : null, 153 164 bg: bgColor ? [...bgColor] : null, 165 + fgIndex, 166 + bgIndex, 154 167 bold: isBold, 155 168 dim: isDim, 156 169 italic: isItalic, ··· 162 175 char: "", 163 176 fg: fgColor ? [...fgColor] : null, 164 177 bg: bgColor ? [...bgColor] : null, 178 + fgIndex, 179 + bgIndex, 165 180 bold: isBold, 166 181 dim: isDim, 167 182 italic: isItalic, ··· 187 202 } 188 203 } 189 204 205 + /** SGR emitter state used by diff and fullRender. 206 + * `fgIdx` / `bgIdx` track the palette index when the last-emitted color 207 + * was indexed; `fgRgb` / `bgRgb` track truecolor. They're always in sync 208 + * — an index-based emit leaves `fgIdx` set AND `fgRgb` populated with 209 + * the flattened value, so re-checking equality against a later cell 210 + * that happens to carry the same RGB but a different index (or no 211 + * index at all) still fires a re-emit through the index check. */ 212 + interface EmitState { 213 + fgIdx: number | null; 214 + fgRgb: [number, number, number] | null; 215 + bgIdx: number | null; 216 + bgRgb: [number, number, number] | null; 217 + } 218 + 219 + function indexedFgSgr(idx: number): string { 220 + if (idx < 8) return `\x1b[${30 + idx}m`; 221 + if (idx < 16) return `\x1b[${90 + idx - 8}m`; 222 + return `\x1b[38;5;${idx}m`; 223 + } 224 + 225 + function indexedBgSgr(idx: number): string { 226 + if (idx < 8) return `\x1b[${40 + idx}m`; 227 + if (idx < 16) return `\x1b[${100 + idx - 8}m`; 228 + return `\x1b[48;5;${idx}m`; 229 + } 230 + 231 + /** Emit an SGR for the foreground of `cell`, if different from `state`. 232 + * Mutates `state`. Index-first: palette cells round-trip as SGR 30-37 / 233 + * 90-97 / 38;5;N so the outer terminal's theme wins. Truecolor cells 234 + * emit SGR 38;2. Default cells emit SGR 39 only when transitioning 235 + * away from a non-default color. */ 236 + function emitFg(cell: Cell, state: EmitState): string { 237 + if (cell.fgIndex !== null) { 238 + if (state.fgIdx === cell.fgIndex) return ""; 239 + state.fgIdx = cell.fgIndex; 240 + state.fgRgb = cell.fg; 241 + return indexedFgSgr(cell.fgIndex); 242 + } 243 + if (cell.fg !== null) { 244 + if (state.fgIdx === null && colorEq(state.fgRgb, cell.fg)) return ""; 245 + state.fgIdx = null; 246 + state.fgRgb = cell.fg; 247 + return `\x1b[38;2;${cell.fg[0]};${cell.fg[1]};${cell.fg[2]}m`; 248 + } 249 + // Default 250 + if (state.fgIdx === null && state.fgRgb === null) return ""; 251 + state.fgIdx = null; 252 + state.fgRgb = null; 253 + return "\x1b[39m"; 254 + } 255 + 256 + function emitBg(cell: Cell, state: EmitState): string { 257 + if (cell.bgIndex !== null) { 258 + if (state.bgIdx === cell.bgIndex) return ""; 259 + state.bgIdx = cell.bgIndex; 260 + state.bgRgb = cell.bg; 261 + return indexedBgSgr(cell.bgIndex); 262 + } 263 + if (cell.bg !== null) { 264 + if (state.bgIdx === null && colorEq(state.bgRgb, cell.bg)) return ""; 265 + state.bgIdx = null; 266 + state.bgRgb = cell.bg; 267 + return `\x1b[48;2;${cell.bg[0]};${cell.bg[1]};${cell.bg[2]}m`; 268 + } 269 + if (state.bgIdx === null && state.bgRgb === null) return ""; 270 + state.bgIdx = null; 271 + state.bgRgb = null; 272 + return "\x1b[49m"; 273 + } 274 + 275 + function resetState(state: EmitState): void { 276 + state.fgIdx = null; state.fgRgb = null; 277 + state.bgIdx = null; state.bgRgb = null; 278 + } 279 + 190 280 /** Diff two buffers and emit minimal ANSI to update from prev to next. 191 281 * Uses DEC synchronized output (mode 2026) to prevent tearing. */ 192 282 export function diff(prev: CellBuffer, next: CellBuffer): string { 193 283 let out = "\x1b[?2026h"; // Begin synchronized update 194 284 let lastRow = -1; 195 285 let lastCol = -1; 196 - let lastFg: [number, number, number] | null = null; 197 - let lastBg: [number, number, number] | null = null; 286 + const state: EmitState = { fgIdx: null, fgRgb: null, bgIdx: null, bgRgb: null }; 198 287 let lastBold = false; 199 288 let lastDim = false; 200 289 let lastItalic = false; ··· 227 316 228 317 if (needReset) { 229 318 out += "\x1b[0m"; 230 - lastFg = null; 231 - lastBg = null; 319 + resetState(state); 232 320 lastBold = false; 233 321 lastDim = false; 234 322 lastItalic = false; ··· 241 329 if (nc.italic && !lastItalic) out += "\x1b[3m"; 242 330 if (nc.underline && !lastUnderline) out += "\x1b[4m"; 243 331 244 - if (nc.fg && !colorEq(nc.fg, lastFg)) { 245 - out += `\x1b[38;2;${nc.fg[0]};${nc.fg[1]};${nc.fg[2]}m`; 246 - } else if (!nc.fg && lastFg) { 247 - out += "\x1b[39m"; 248 - } 249 - 250 - if (nc.bg && !colorEq(nc.bg, lastBg)) { 251 - out += `\x1b[48;2;${nc.bg[0]};${nc.bg[1]};${nc.bg[2]}m`; 252 - } else if (!nc.bg && lastBg) { 253 - out += "\x1b[49m"; 254 - } 332 + out += emitFg(nc, state); 333 + out += emitBg(nc, state); 255 334 256 335 out += nc.char; 257 336 258 337 lastRow = r; 259 338 lastCol = c + 1; 260 - lastFg = nc.fg; 261 - lastBg = nc.bg; 262 339 lastBold = nc.bold; 263 340 lastDim = nc.dim; 264 341 lastItalic = nc.italic; ··· 274 351 /** Render the full buffer to ANSI (for initial draw). */ 275 352 export function fullRender(buf: CellBuffer): string { 276 353 let out = "\x1b[?2026h\x1b[H\x1b[0m"; 277 - let lastFg: [number, number, number] | null = null; 278 - let lastBg: [number, number, number] | null = null; 354 + const state: EmitState = { fgIdx: null, fgRgb: null, bgIdx: null, bgRgb: null }; 279 355 let lastBold = false; 280 356 let lastDim = false; 281 357 let lastItalic = false; ··· 297 373 298 374 if (needReset) { 299 375 out += "\x1b[0m"; 300 - lastFg = null; 301 - lastBg = null; 376 + resetState(state); 302 377 lastBold = false; 303 378 lastDim = false; 304 379 lastItalic = false; ··· 310 385 if (cell.italic && !lastItalic) out += "\x1b[3m"; 311 386 if (cell.underline && !lastUnderline) out += "\x1b[4m"; 312 387 313 - if (cell.fg && !colorEq(cell.fg, lastFg)) { 314 - out += `\x1b[38;2;${cell.fg[0]};${cell.fg[1]};${cell.fg[2]}m`; 315 - } else if (!cell.fg && lastFg) { 316 - out += "\x1b[39m"; 317 - } 318 - 319 - if (cell.bg && !colorEq(cell.bg, lastBg)) { 320 - out += `\x1b[48;2;${cell.bg[0]};${cell.bg[1]};${cell.bg[2]}m`; 321 - } else if (!cell.bg && lastBg) { 322 - out += "\x1b[49m"; 323 - } 388 + out += emitFg(cell, state); 389 + out += emitBg(cell, state); 324 390 325 391 out += cell.char; 326 - lastFg = cell.fg; 327 - lastBg = cell.bg; 328 392 lastBold = cell.bold; 329 393 lastDim = cell.dim; 330 394 lastItalic = cell.italic;
+12 -1
src/tui/screen.ts
··· 285 285 bold: boolean = false, 286 286 dim: boolean = false, 287 287 italic: boolean = false, 288 + fgIndex: number | null = null, 289 + bgIndex: number | null = null, 288 290 ): Cell { 289 - return { char: ch, fg: fgc ? [...fgc] : null, bg: bgc ? [...bgc] : null, bold, dim, italic, underline: false }; 291 + return { 292 + char: ch, 293 + fg: fgc ? [...fgc] : null, 294 + bg: bgc ? [...bgc] : null, 295 + fgIndex, 296 + bgIndex, 297 + bold, dim, italic, 298 + underline: false, 299 + }; 290 300 } 291 301 292 302 /** Write a plain string into the buffer at (row, col). Clips to buffer bounds. */ ··· 721 731 cell.fg ?? (theme.fg1 ? [...theme.fg1] : null), 722 732 cell.bg ?? (theme.bg1 ? [...theme.bg1] : null), 723 733 cell.bold, cell.dim, cell.italic, 734 + cell.fgIndex, cell.bgIndex, 724 735 ); 725 736 } 726 737 }
+15 -1
src/tui/types.ts
··· 6 6 // --- Cell buffer types --- 7 7 export interface Cell { 8 8 char: string; 9 + /** Flattened RGB foreground. Populated for both truecolor and palette 10 + * cells (palette flattened via the 16/256 tables). `null` means "use 11 + * the terminal default." */ 9 12 fg: [number, number, number] | null; 10 13 bg: [number, number, number] | null; 14 + /** 0-255 palette index when the source was an indexed SGR color 15 + * (30-37 / 90-97 / 38;5;N). `null` for truecolor (38;2;r;g;b) and 16 + * default-color cells. Re-emitters prefer this over the flattened 17 + * RGB so the outer terminal's theme wins for indexed colors. */ 18 + fgIndex: number | null; 19 + bgIndex: number | null; 11 20 bold: boolean; 12 21 dim: boolean; 13 22 italic: boolean; ··· 15 24 } 16 25 17 26 export function emptyCell(): Cell { 18 - return { char: " ", fg: null, bg: null, bold: false, dim: false, italic: false, underline: false }; 27 + return { 28 + char: " ", fg: null, bg: null, fgIndex: null, bgIndex: null, 29 + bold: false, dim: false, italic: false, underline: false, 30 + }; 19 31 } 20 32 21 33 export function cellsEqual(a: Cell, b: Cell): boolean { ··· 25 37 a.dim === b.dim && 26 38 a.italic === b.italic && 27 39 a.underline === b.underline && 40 + a.fgIndex === b.fgIndex && 41 + a.bgIndex === b.bgIndex && 28 42 colorEqual(a.fg, b.fg) && 29 43 colorEqual(a.bg, b.bg) 30 44 );
+203
tests/buffer-palette.test.ts
··· 1 + // Palette-index round-trip through the CellBuffer pipeline: 2 + // writeAnsi(indexed SGR) -> cell.fgIndex set -> fullRender emits 3 + // the same indexed SGR (not truecolor). 4 + // 5 + // Exists so consumers like pty-layout — which re-emit cells to a real 6 + // terminal — can keep the outer terminal's theme instead of getting a 7 + // flattened VGA RGB. See builders.ts PtyCell and buffer.ts emitFg/emitBg. 8 + 9 + import { describe, it, expect } from "vitest"; 10 + import { CellBuffer, fullRender, diff } from "../src/tui/buffer.ts"; 11 + import { cellsEqual, emptyCell } from "../src/tui/types.ts"; 12 + 13 + function findCellByChar(buf: CellBuffer, ch: string) { 14 + for (const row of buf.cells) { 15 + for (const cell of row) { 16 + if (cell.char === ch) return cell; 17 + } 18 + } 19 + return null; 20 + } 21 + 22 + describe("writeAnsi preserves palette index", () => { 23 + it("SGR 30-37: low 8 colors → fgIndex 0-7, flattened fg still populated", () => { 24 + const buf = new CellBuffer(1, 20); 25 + buf.writeAnsi("\x1b[34mB\x1b[0m"); 26 + const cell = findCellByChar(buf, "B")!; 27 + expect(cell.fgIndex).toBe(4); 28 + // fg stays populated for consumers that don't know about fgIndex. 29 + expect(cell.fg).not.toBeNull(); 30 + }); 31 + 32 + it("SGR 90-97: bright 8 colors → fgIndex 8-15", () => { 33 + const buf = new CellBuffer(1, 20); 34 + buf.writeAnsi("\x1b[94mX\x1b[0m"); 35 + expect(findCellByChar(buf, "X")!.fgIndex).toBe(12); 36 + }); 37 + 38 + it("SGR 38;5;N: 256-palette → fgIndex=N", () => { 39 + const buf = new CellBuffer(1, 20); 40 + buf.writeAnsi("\x1b[38;5;17mY\x1b[0m"); 41 + expect(findCellByChar(buf, "Y")!.fgIndex).toBe(17); 42 + }); 43 + 44 + it("SGR 40-47: bg low → bgIndex 0-7", () => { 45 + const buf = new CellBuffer(1, 20); 46 + buf.writeAnsi("\x1b[42mg\x1b[0m"); 47 + expect(findCellByChar(buf, "g")!.bgIndex).toBe(2); 48 + }); 49 + 50 + it("SGR 48;5;N: 256-palette bg → bgIndex=N", () => { 51 + const buf = new CellBuffer(1, 20); 52 + buf.writeAnsi("\x1b[48;5;124mR\x1b[0m"); 53 + expect(findCellByChar(buf, "R")!.bgIndex).toBe(124); 54 + }); 55 + 56 + it("SGR 38;2;r;g;b: truecolor → fgIndex=null, fg=RGB", () => { 57 + const buf = new CellBuffer(1, 20); 58 + buf.writeAnsi("\x1b[38;2;10;20;30mT\x1b[0m"); 59 + const cell = findCellByChar(buf, "T")!; 60 + expect(cell.fgIndex).toBeNull(); 61 + expect(cell.fg).toEqual([10, 20, 30]); 62 + }); 63 + 64 + it("SGR 0 clears both color and index", () => { 65 + const buf = new CellBuffer(1, 20); 66 + buf.writeAnsi("\x1b[34mA\x1b[0mB"); 67 + const a = findCellByChar(buf, "A")!; 68 + const b = findCellByChar(buf, "B")!; 69 + expect(a.fgIndex).toBe(4); 70 + expect(b.fgIndex).toBeNull(); 71 + expect(b.fg).toBeNull(); 72 + }); 73 + 74 + it("SGR 39 clears fg index (but not bg)", () => { 75 + const buf = new CellBuffer(1, 20); 76 + buf.writeAnsi("\x1b[34;42mA\x1b[39mB"); 77 + const b = findCellByChar(buf, "B")!; 78 + expect(b.fgIndex).toBeNull(); 79 + expect(b.fg).toBeNull(); 80 + expect(b.bgIndex).toBe(2); 81 + }); 82 + 83 + it("SGR 49 clears bg index (but not fg)", () => { 84 + const buf = new CellBuffer(1, 20); 85 + buf.writeAnsi("\x1b[34;42mA\x1b[49mB"); 86 + const b = findCellByChar(buf, "B")!; 87 + expect(b.bgIndex).toBeNull(); 88 + expect(b.bg).toBeNull(); 89 + expect(b.fgIndex).toBe(4); 90 + }); 91 + 92 + it("truecolor after indexed clears the index", () => { 93 + const buf = new CellBuffer(1, 20); 94 + buf.writeAnsi("\x1b[34mA\x1b[38;2;1;2;3mB"); 95 + const b = findCellByChar(buf, "B")!; 96 + expect(b.fgIndex).toBeNull(); 97 + expect(b.fg).toEqual([1, 2, 3]); 98 + }); 99 + }); 100 + 101 + describe("fullRender emits indexed SGR (not truecolor) for palette cells", () => { 102 + it("SGR 34 round-trips as SGR 34, not 38;2", () => { 103 + const buf = new CellBuffer(1, 10); 104 + buf.writeAnsi("\x1b[34mhi"); 105 + const out = fullRender(buf); 106 + expect(out).toContain("\x1b[34m"); 107 + expect(out).not.toContain("\x1b[38;2;"); 108 + }); 109 + 110 + it("SGR 94 (bright blue) round-trips as SGR 94", () => { 111 + const buf = new CellBuffer(1, 10); 112 + buf.writeAnsi("\x1b[94mhi"); 113 + const out = fullRender(buf); 114 + expect(out).toContain("\x1b[94m"); 115 + expect(out).not.toContain("\x1b[38;2;"); 116 + }); 117 + 118 + it("SGR 38;5;17 round-trips as SGR 38;5;17", () => { 119 + const buf = new CellBuffer(1, 10); 120 + buf.writeAnsi("\x1b[38;5;17mhi"); 121 + const out = fullRender(buf); 122 + expect(out).toContain("\x1b[38;5;17m"); 123 + expect(out).not.toContain("\x1b[38;2;"); 124 + }); 125 + 126 + it("truecolor stays truecolor", () => { 127 + const buf = new CellBuffer(1, 10); 128 + buf.writeAnsi("\x1b[38;2;10;20;30mhi"); 129 + const out = fullRender(buf); 130 + expect(out).toContain("\x1b[38;2;10;20;30m"); 131 + }); 132 + 133 + it("bg round-trips for low / bright / 256", () => { 134 + const low = new CellBuffer(1, 4); 135 + low.writeAnsi("\x1b[42mh"); 136 + expect(fullRender(low)).toContain("\x1b[42m"); 137 + 138 + const bright = new CellBuffer(1, 4); 139 + bright.writeAnsi("\x1b[102mh"); 140 + expect(fullRender(bright)).toContain("\x1b[102m"); 141 + 142 + const ext = new CellBuffer(1, 4); 143 + ext.writeAnsi("\x1b[48;5;124mh"); 144 + expect(fullRender(ext)).toContain("\x1b[48;5;124m"); 145 + }); 146 + 147 + it("transition from indexed to truecolor emits the truecolor code", () => { 148 + const buf = new CellBuffer(1, 10); 149 + buf.writeAnsi("\x1b[34mA\x1b[38;2;10;20;30mB"); 150 + const out = fullRender(buf); 151 + expect(out).toContain("\x1b[34m"); 152 + expect(out).toContain("\x1b[38;2;10;20;30m"); 153 + }); 154 + 155 + it("transition from truecolor to indexed emits the indexed code", () => { 156 + const buf = new CellBuffer(1, 10); 157 + buf.writeAnsi("\x1b[38;2;10;20;30mA\x1b[34mB"); 158 + const out = fullRender(buf); 159 + expect(out).toContain("\x1b[38;2;10;20;30m"); 160 + expect(out).toContain("\x1b[34m"); 161 + }); 162 + }); 163 + 164 + describe("cellsEqual compares palette index", () => { 165 + it("cells differing only by fgIndex are unequal", () => { 166 + const a = emptyCell(); 167 + a.fgIndex = 4; a.fg = [0, 0, 204]; 168 + const b = emptyCell(); 169 + b.fgIndex = 12; b.fg = [0, 0, 204]; // same flattened RGB, different index 170 + expect(cellsEqual(a, b)).toBe(false); 171 + }); 172 + 173 + it("cells with same fg but one indexed and one truecolor are unequal", () => { 174 + const a = emptyCell(); 175 + a.fgIndex = 4; a.fg = [0, 0, 204]; 176 + const b = emptyCell(); 177 + b.fgIndex = null; b.fg = [0, 0, 204]; // same RGB, no index (truecolor) 178 + expect(cellsEqual(a, b)).toBe(false); 179 + }); 180 + 181 + it("cells with identical fgIndex and fg are equal", () => { 182 + const a = emptyCell(); 183 + a.fgIndex = 4; a.fg = [0, 0, 204]; 184 + const b = emptyCell(); 185 + b.fgIndex = 4; b.fg = [0, 0, 204]; 186 + expect(cellsEqual(a, b)).toBe(true); 187 + }); 188 + }); 189 + 190 + describe("diff respects index transitions", () => { 191 + it("re-emits SGR when only the index changes (same flattened RGB)", () => { 192 + // Craft two buffers where cell.fg is equal but cell.fgIndex differs. 193 + // This can't come from writeAnsi alone (same index → same RGB), so 194 + // forge the cells directly to exercise the diff logic. 195 + const prev = new CellBuffer(1, 1); 196 + prev.cells[0][0] = { ...emptyCell(), char: "A", fg: [0, 0, 204], fgIndex: 4 }; 197 + const next = new CellBuffer(1, 1); 198 + next.cells[0][0] = { ...emptyCell(), char: "A", fg: [0, 0, 204], fgIndex: 12 }; 199 + 200 + const out = diff(prev, next); 201 + expect(out).toContain("\x1b[94m"); // bright-blue SGR for index 12 202 + }); 203 + });