This repository has no description
0

Configure Feed

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

readCells: preserve palette index on PtyCell so re-emitters can keep the outer terminals theme

pty-layout composites panes by reading cells and re-emitting them as
truecolor SGR bytes. readXtermCells was flattening palette-indexed
cells to a hardcoded VGA RGB via paletteToRgb(), so SGR 34 always
came out [0,0,204] even when the outer terminal (kitty, wezterm,
iterm2) had its own theme blue. pty attach is unaffected — that path
pipes raw bytes and the SGR codes pass through.

Cells returned from PtyHandle.readCells() now carry:

fgIndex: number | null // 0-255 when isFgPalette(), else null
bgIndex: number | null // 0-255 when isBgPalette(), else null

Re-emitters check the index first and round-trip as SGR 30-37/90-97
or 38;5;N / 48;5;N; the outer terminals palette then wins. Existing
fg/bg RGB fields unchanged — consumers that dont know about fgIndex
fall back to the flattened RGB and still work.

The inline readCells return type is now a named PtyCell interface
exported from @myobie/pty/tui, so consumers can import the shape
without a ReturnType<...> dance.

underlineColor deliberately skipped — the current Cell shape only
tracks underline: boolean with no color at all, so adding palette-
aware underline is a bigger change than this non-breaking addition.
Separate request if pty-layout wants it.

Nathan Herald (Apr 23, 2026, 11:50 AM +0200) 6248390e 04076b88

+136 -17
+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 + - **`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. 24 25 - **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. 25 26 - **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`. 26 27 - **Mouse support.** Set `AppConfig.mouse: true` to enable SGR mouse reporting. Screens get an optional `handleMouse(event, ctx)` alongside `handleKey`. `MouseEvent` carries `action` (`press` / `release` / `drag` / `move` / `scrollUp` / `scrollDown`), `button` (left/middle/right/none), 0-based `x`/`y`, and modifier flags. `hitTest(roots, x, y)` walks the rendered tree by `_rect` to find the deepest node under a point; `findInPath` locates a typed ancestor. Widgets that benefit from mouse — `virtual-list` (click + scroll), `stream-view` (scroll), `tabs` (click to select) — gained `handleVirtualMouse` / `handleStreamMouse` / `handleTabsMouse` helpers. `parseInput(buf)` returns the unified `InputEvent` stream; the legacy `parseKey(buf)` filters it to keyboard-only so existing consumers are unaffected.
+23 -15
src/tui/builders.ts
··· 6 6 IconNode, RowNode, ColumnNode, HStackNode, PanelNode, 7 7 ScrollableNode, SelectableNode, StatusBarNode, FooterNode, 8 8 AskBarNode, TextInputNode, FPSCounterNode, CanvasNode, 9 - DrawContext, PtyHandle, PtyViewNode, 9 + DrawContext, PtyHandle, PtyViewNode, PtyCell, 10 10 } from "./nodes.ts"; 11 11 import type { BoxStyle, Theme } from "./colors.ts"; 12 12 import type { ScrollRegion } from "./scrollable.ts"; ··· 325 325 const startLine = Math.max(0, baseY - scrollOffset); 326 326 const grid: ReturnType<PtyHandle["readCells"]> = []; 327 327 328 + const EMPTY: PtyCell = { 329 + char: " ", fg: null, bg: null, fgIndex: null, bgIndex: null, 330 + bold: false, dim: false, italic: false, underline: false, 331 + }; 332 + 328 333 for (let r = 0; r < rows; r++) { 329 334 const lineIdx = startLine + r; 330 335 const line = lineIdx < buf.length ? buf.getLine(lineIdx) : null; 331 336 const row: typeof grid[0] = []; 332 337 for (let c = 0; c < cols; c++) { 333 - if (!line) { 334 - row.push({ char: " ", fg: null, bg: null, bold: false, dim: false, italic: false, underline: false }); 335 - continue; 336 - } 338 + if (!line) { row.push({ ...EMPTY }); continue; } 337 339 const cell = line.getCell(c); 338 - if (!cell) { 339 - row.push({ char: " ", fg: null, bg: null, bold: false, dim: false, italic: false, underline: false }); 340 - continue; 341 - } 340 + if (!cell) { row.push({ ...EMPTY }); continue; } 342 341 343 - // Extract foreground color 342 + // Foreground. Preserve the palette index when the source was 343 + // indexed so consumers that re-emit to a real terminal can keep 344 + // the outer theme (kitty/wezterm/iterm2 user palette). 344 345 let fg: [number, number, number] | null = null; 346 + let fgIndex: number | null = null; 345 347 if (cell.isFgRGB()) { 346 348 const v = cell.getFgColor(); 347 349 fg = [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; 348 350 } else if (cell.isFgPalette()) { 349 - fg = paletteToRgb(cell.getFgColor()); 351 + const n = cell.getFgColor(); 352 + fg = paletteToRgb(n); 353 + fgIndex = n; 350 354 } 351 - // isFgDefault() → null (use theme default) 355 + // isFgDefault() → both null (use theme default) 352 356 353 - // Extract background color 354 357 let bg: [number, number, number] | null = null; 358 + let bgIndex: number | null = null; 355 359 if (cell.isBgRGB()) { 356 360 const v = cell.getBgColor(); 357 361 bg = [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]; 358 362 } else if (cell.isBgPalette()) { 359 - bg = paletteToRgb(cell.getBgColor()); 363 + const n = cell.getBgColor(); 364 + bg = paletteToRgb(n); 365 + bgIndex = n; 360 366 } 361 - // isBgDefault() → null (use theme default) 367 + // isBgDefault() → both null 362 368 363 369 row.push({ 364 370 char: cell.getChars() || " ", 365 371 fg, 366 372 bg, 373 + fgIndex, 374 + bgIndex, 367 375 bold: !!cell.isBold(), 368 376 dim: !!cell.isDim(), 369 377 italic: !!cell.isItalic(),
+1 -1
src/tui/index.ts
··· 62 62 ScrollableNode, SelectableNode, 63 63 StatusBarNode, FooterNode, AskBarNode, TextInputNode, 64 64 FPSCounterNode, CanvasNode, CanvasCell, DrawContext, 65 - PtyHandle, PtyViewNode, 65 + PtyHandle, PtyViewNode, PtyCell, 66 66 } from "./nodes.ts"; 67 67 68 68 // Builders
+27 -1
src/tui/nodes.ts
··· 241 241 _rect?: Rect; 242 242 } 243 243 244 + /** One grid cell as read by `PtyHandle.readCells`. Keeps both the 245 + * resolved RGB and — when the source was palette-indexed — the raw 246 + * 0-255 index. Re-emitters that want the outer terminal's theme 247 + * (kitty, wezterm, iterm2 with a user palette) should prefer the 248 + * index; consumers that need a concrete RGB fall back to `fg`/`bg`. */ 249 + export interface PtyCell { 250 + char: string; 251 + /** RGB foreground. `null` when the cell used the terminal's default 252 + * fg and no explicit color was ever set. For palette cells this is 253 + * populated with a flattened VGA/xterm-cube approximation — check 254 + * `fgIndex` first if you want the outer terminal's theme. */ 255 + fg: [number, number, number] | null; 256 + bg: [number, number, number] | null; 257 + /** 0-255 palette index when the cell was written with an indexed 258 + * SGR color (30-37, 90-97, 38;5;N, 48;5;N). `null` for default- 259 + * color cells and for true-color cells. Added so pty-layout and 260 + * similar re-emitters can round-trip indexed colors and let the 261 + * outer terminal resolve them against its own theme. */ 262 + fgIndex: number | null; 263 + bgIndex: number | null; 264 + bold: boolean; 265 + dim: boolean; 266 + italic: boolean; 267 + underline: boolean; 268 + } 269 + 244 270 /** Handle returned by createPty(). Holds the spawned process + xterm terminal. */ 245 271 export interface PtyHandle { 246 272 /** Write raw input to the child process. */ ··· 252 278 * xterm parsed is preserved. No serialize round-trip. 253 279 * @param scrollOffset Lines to scroll back into history (0 = live viewport). 254 280 */ 255 - readCells(scrollOffset?: number): { char: string; fg: [number, number, number] | null; bg: [number, number, number] | null; bold: boolean; dim: boolean; italic: boolean; underline: boolean }[][]; 281 + readCells(scrollOffset?: number): PtyCell[][]; 256 282 /** 257 283 * Read per-row "wrapped" flags aligned with the rows returned by 258 284 * `readCells(scrollOffset)`. A `true` at index `r` means row `r`
+84
tests/pty-handle.test.ts
··· 331 331 expect(text).toContain("hello-world"); 332 332 }); 333 333 }); 334 + 335 + // --- Palette-indexed color preservation (fgIndex / bgIndex) --- 336 + // 337 + // Regression guard for pty-layout: readCells used to flatten palette 338 + // cells to a hardcoded VGA RGB via paletteToRgb(), which meant 339 + // consumers that re-emit cells to a real terminal lost the outer 340 + // terminal's theme (SGR 34 became [0,0,204] always, even in kitty 341 + // with a blue theme). Cells now also carry `fgIndex` / `bgIndex` 342 + // so re-emitters can produce SGR 30-37 / 38;5;N and let the outer 343 + // terminal's palette win. 344 + 345 + // Find the first cell whose char matches `ch` and return it. Used 346 + // because the cursor trails the emitted output and the SGR attrs 347 + // only live on the printed chars, not the cursor row. 348 + function findCellByChar( 349 + cells: ReturnType<PtyHandle["readCells"]>, 350 + ch: string, 351 + ): ReturnType<PtyHandle["readCells"]>[0][0] | null { 352 + for (const row of cells) { 353 + for (const cell of row) { 354 + if (cell.char === ch) return cell; 355 + } 356 + } 357 + return null; 358 + } 359 + 360 + describe("readCells — palette-indexed colors", () => { 361 + it("preserves low-palette fg (SGR 34 → fgIndex=4)", async () => { 362 + const h = spawn("bash", ["-c", "printf '\\x1b[34mB\\x1b[0m'; sleep 10"]); 363 + await waitFor(h, () => findCellByChar(h.readCells(), "B") !== null); 364 + const cell = findCellByChar(h.readCells(), "B")!; 365 + expect(cell.fgIndex).toBe(4); 366 + // `fg` is still populated for back-compat — consumers that don't 367 + // know about fgIndex fall back to the flattened RGB. 368 + expect(cell.fg).not.toBeNull(); 369 + }); 370 + 371 + it("preserves bright-palette fg (SGR 94 → fgIndex=12)", async () => { 372 + const h = spawn("bash", ["-c", "printf '\\x1b[94mX\\x1b[0m'; sleep 10"]); 373 + await waitFor(h, () => findCellByChar(h.readCells(), "X") !== null); 374 + const cell = findCellByChar(h.readCells(), "X")!; 375 + expect(cell.fgIndex).toBe(12); 376 + }); 377 + 378 + it("preserves 256-palette fg (SGR 38;5;17 → fgIndex=17)", async () => { 379 + const h = spawn("bash", ["-c", "printf '\\x1b[38;5;17mY\\x1b[0m'; sleep 10"]); 380 + await waitFor(h, () => findCellByChar(h.readCells(), "Y") !== null); 381 + const cell = findCellByChar(h.readCells(), "Y")!; 382 + expect(cell.fgIndex).toBe(17); 383 + }); 384 + 385 + it("preserves 256-palette bg (SGR 48;5;124 → bgIndex=124)", async () => { 386 + const h = spawn("bash", ["-c", "printf '\\x1b[48;5;124mZ\\x1b[0m'; sleep 10"]); 387 + await waitFor(h, () => findCellByChar(h.readCells(), "Z") !== null); 388 + const cell = findCellByChar(h.readCells(), "Z")!; 389 + expect(cell.bgIndex).toBe(124); 390 + }); 391 + 392 + it("truecolor RGB leaves fgIndex null (cell is not indexed)", async () => { 393 + const h = spawn("bash", ["-c", "printf '\\x1b[38;2;10;20;30mT\\x1b[0m'; sleep 10"]); 394 + await waitFor(h, () => findCellByChar(h.readCells(), "T") !== null); 395 + const cell = findCellByChar(h.readCells(), "T")!; 396 + expect(cell.fgIndex).toBeNull(); 397 + expect(cell.fg).toEqual([10, 20, 30]); 398 + }); 399 + 400 + it("default-color cells have both fg=null and fgIndex=null", async () => { 401 + const h = spawn("bash", ["-c", "printf 'D'; sleep 10"]); 402 + await waitFor(h, () => findCellByChar(h.readCells(), "D") !== null); 403 + const cell = findCellByChar(h.readCells(), "D")!; 404 + expect(cell.fg).toBeNull(); 405 + expect(cell.fgIndex).toBeNull(); 406 + expect(cell.bg).toBeNull(); 407 + expect(cell.bgIndex).toBeNull(); 408 + }); 409 + 410 + it("fg and bg indices are tracked independently", async () => { 411 + const h = spawn("bash", ["-c", "printf '\\x1b[31;42mM\\x1b[0m'; sleep 10"]); 412 + await waitFor(h, () => findCellByChar(h.readCells(), "M") !== null); 413 + const cell = findCellByChar(h.readCells(), "M")!; 414 + expect(cell.fgIndex).toBe(1); // SGR 31 415 + expect(cell.bgIndex).toBe(2); // SGR 42 416 + }); 417 + });