TUI shell and renderer rules for Token Tamers — 4:3 canvas layout, diff renderer, sub-cell (sextant/octant) compositor, SGR mouse, performance budgets, golden-frame testing. Use when working under packages/tui.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add zivkong/token-tamers --skill develop-tui-renderer --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Develop Tui Renderer?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/zivkong-develop-tui-renderer)More formats (shields.io, HTML) on the badges page.
---
name: develop-tui-renderer
description: TUI shell and renderer rules for Token Tamers — 4:3 canvas layout, diff renderer, sub-cell (sextant/octant) compositor, SGR mouse, performance budgets, golden-frame testing. Use when working under packages/tui.
---
# Develop the TUI shell & renderer (packages/tui)
Source of truth: `docs/design/visuals-habitats-achievements.md` (rendering) and
`docs/design/architecture.md` (shell). Custom slim
TUI core — NO framework (Ink/OpenTUI rejected by design; OpenTUI is the documented
fallback if the renderer hits a wall). Zero runtime dependencies; works on any Node
LTS over any SSH. tui imports `@token-tamers/core` only — never adapters or content.
> **TUI-first (binding).** The interactive shell is the PRIMARY player surface — most
> players use the TUI, not the CLI. Every player-facing feature must be fully usable in the
> shell; a feature is NOT done while it's CLI-only. When a feature lands, add its page (a
> `renderFn(ctx: RenderContext)` in `src/pages/`, dispatched in `render/frame.ts`), an in-shell
> entry point (a hotkey/menu item, or a contextual key on a related page — e.g. Battle is a
> top-level menu page whose setup screen takes a pasted DNA code or an own-Dex pick), and
> golden-frame tests, in the same change as the
> engine/CLI work. Keep per-frame work pure (`f(state, pack, ui, frame)` → buffer) so playback/
> animation stays deterministic and golden-testable; pre-compute anything stochastic (e.g. a
> battle timeline) ONCE outside the renderer and play it back. If the shell lacks a primitive a
> feature needs, call that out as an unfinished TUI gap — don't declare the feature complete on
> the CLI alone. (The shell now has a minimal free-text field — the Battle setup paste field in
> `pages/battle-setup.ts` accumulates printable key events into `ui.battle.input`; reuse that
> pattern rather than reaching for a framework.) See the TUI-first rule in CLAUDE.md.
## Layout law (rev 1.2 — responsive, orientation-aware)
- The shell adapts to the terminal shape (`render/layout.ts` → `pickOrientation`, a pure
function of cols:rows with a dead-band so a resize across the boundary doesn't flicker;
golden-frame deterministic). Two orientations:
- **Vertical** (a tall "side pane"): the rev 1.1 stack — **top-oriented, full-width**, sections
from row 0 with **no gutters/side-padding**; menu a left-aligned button flow docked AFTER the
canvas, slack below. `canvasX=0`, `canvasCols=termCols`, `menuRail=false`. **Min 34×24**.
- **Horizontal** (a wide, short "bottom dock"): **two columns** — game canvas LEFT, chrome
column (header/stats/vitals) RIGHT, nav menu a vertical **rail** (`menuRail=true`,
`menuRect`/`menuDividerX`) down the right edge. Content region is the left column
(`canvasCols < termCols`, `canvasRows = termRows`). **Min 72×12** — docks are short, so the
row floor is far below vertical's. The pet content column splits ~38% to the chrome with
hard floors (`MIN_CHROME_COLS`/`MIN_CANVAS_COLS` = 24 each) so neither sub-column collapses;
`HORIZONTAL_MIN_COLS` is set to keep both above their floors (a layout test guards the row
floor against the chrome/rail stack heights too).
- The **game canvas is a true 4:3 box** (`fit43`): cells are ~1:2, so a 4:3-pixel habitat reads
as 4:3 only at an **8:3 cell box** (`rows = cols × 3/8`). `fit43(band)` returns the largest 8:3
box that fits, **centered with gutters (letterboxed)** — the ONE allowed gutter; never stretch
to fill. Size sprites/wander against the returned `scene` rect (not the band). Use
`CANVAS_CELL_W=8`/`CANVAS_CELL_H=3`, NOT the retired `cols/4` (pre-octant 4:1) target.
- The menu is drawn from `layout.menuRect` + `packMenu(menuRect.cols)` (offset to `menuRect.x`)
in BOTH orientations, so `frame.ts` (draw) and `shell-input.ts` (hit-test) stay in lockstep —
vertical packs a full-width band, horizontal packs a one-per-row rail. Adding a page is
unchanged; never hand-roll menu geometry from `termCols`.
- Section order (pet page, VERTICAL): **header** (`headerRows=3`: name / identity / stats) → _divider+gap_ →
**game canvas** → _gap + labeled `VITALS` divider + gap_ → **vitals panel** (`panelRows=4`: food /
diet / grow / odds on consecutive rows) → _bottom-padding gap_ → **labeled `── Menu ──` divider** → **menu**.
`petSections(layout)` carves the bands and returns `rules` (the horizontal dividers to draw —
header + labeled VITALS in vertical; just VITALS in horizontal) plus an optional `separator`
(the vertical rule between canvas and chrome, horizontal only). In HORIZONTAL the scene takes
the left of the content column (4:3, letterboxed) and header+vitals stack in a chrome
sub-column to its right — same rects, orientation-agnostic page code. The **menu is GLOBAL
chrome** drawn by the frame (a band below in vertical, a rail on the right in horizontal);
`components/divider.ts` draws rules (`drawDivider` horizontal, `drawVDivider` vertical).
- **Standard full-screen page scaffold** (`components/page.ts`): every non-Pet page (Dex,
Loot, Feats, Settings) shares ONE chrome so they never drift apart — `drawPageHeader(ctx, {icon,
title, completion?})` draws a left-aligned `icon Title` (Title-Case), an optional right-aligned
completion bar, and the standard `drawDivider` beneath, returning the first body row
(`canvasY + PAGE_HEADER_ROWS`, = 3); `drawPageFooter(ctx, text)` draws a left status line on the
bottom canvas row. Pages render only their BODY between. Do NOT hand-roll page headers/titles, and
NEVER draw a nav legend on a page — the global `── Menu ──` buttons are the only navigation (a
per-page key legend is a duplicate). The Pet page is the sole exception (it's the game canvas; see
`petSections`). List pages (Loot, Feats) register a hit region per body row themselves
(`unlock:item:<i>`); list scroll is clamped via the shared `clampScroll` (`components/page.ts`).
- **Completion is per-page, not global** (`components/meter.ts` → `drawCompletionHeader`, surfaced
via `drawPageHeader`'s `completion`): Dex shows `completion.dex`, Loot shows
unlocked/total, Feats shows earned/total, top-right. The full `CompletionBreakdown` flows `ShellHost.completion()` →
`RenderContext.completion`. Settings has no completion (nothing to track); the pet page has NO
completion meter.
- **Evolution-mystery rule (amended) + two fixed-row lifecycle countdowns:** the **"Grow" row**
NAMES the current stage (`Mote`/`Sprite`/`Rookie`/`Evolved`/`Prime`/`Apex`) and carries the
**MOLT (5-h) countdown** (`Evolved · Molt 4h 59m 12s`, live `ctx.live.secsToMolt`/`nextMoltCloseAt`;
bar fill via `core.growthProgress(state)`). The molt rolls grade too, so the countdown STAYS at
Apex (the next A→S chance); a maxed Apex-S reads `Apex · max grade`. Golden frames (no `live`) show
the stage name alone. Mystery now covers ONLY the next FORM/branch — never the target species. The
**"Odds" row** carries the **REBORN (7-day) countdown** (`C › B 25% · Reborn 6d 23h`,
`secsToRebirth`/`nextRebirthAt`); at **Apex** it becomes the clickable **`[ Reborn Now · … ]`**
button (`pet:reborn-now` hit region) that opens the **reusable confirm modal** (`components/modal.ts`,
driven by `shell-modal.ts`); for a non-S Apex the modal CLEARLY warns the grade can still roll
higher before confirming `host.rebornNow()`. The two countdowns never swap rows. Keep the
`calibrating` cue (data readiness).
- **Grade display:** on the pet header, grade is the name's styling — the whole name is drawn
**bold (`buf.textBold`) in `GRADE_ACCENT[grade]`** with a trailing `GRADE_BADGE` symbol; no
`[B]` text. Bold is a `Cell.bold` attribute (a no-op in `--no-color`/`none` mode).
- **Two disjoint color maps — keep them apart:** `GRADE_ACCENT` (render/sprite.ts) is the RARITY
ladder (C grey · B green · A violet · S gold) — it ALONE signals value. `HOUSE_ACCENT`
(helpers/lookup.ts) is the canonical per-House identity color (Aether cyan · Cipher red · Flux
rose · Forge orange · Wild green) — a NEUTRAL categorical palette, equal-weight so no House looks
superior (invariant #3), deliberately sharing NO color with the grade ladder. ALL House coloring
goes through `houseTint(house)` (hex, for `buildPalette`) / `houseColor(house)` (Rgb) — never
per-gene `models.json` tints (those mirror `HOUSE_ACCENT` for content honesty but the UI ignores
them). New House-colored UI must use these helpers, and new colors must not collide with grades.
- **Header band** (`drawHeaderBand` in `pages/pet.ts`, `HEADER_ROWS=3` on `HEADER_BG`) — the
pet's IDENTITY card: row 0 name+grade (left) / habitat (right), row 1 pattern · traits, row 2 the
**Stats** readout (`icon LABEL: value` for PWR/SPD/WIS/GRT, spread space-between across the full
width, NO bar — a fixed equal budget, House-tinted icons). Stats live HERE (who the pet IS), kept
apart from the live VITALS panel; drawn via `pet-vitals.ts`'s exported `drawStatsRow(ctx, rect, y,
bg)` so it renders on the band background.
- **Vitals panel** (`pages/pet-vitals.ts`) — 4 LIVE rows: **Food** (REAL-TIME growth: open-window
tokens fill toward `VITALITY_FULL_TOKENS`=200M, SINGLE-tinted, `+N% molt` = real `vitalityBonus`
preview; token counts only), **Diet** (the
ALWAYS-FULL House-share bar — composition not progress — + a House-name legend), **Grow** (the
maturation row: `drawMeter` filled to `growthProgress(state).frac` in a neutral teal `GROWTH_FILL`
— off BOTH the grade and House ladders — labelled by `growLabel` with the CURRENT stage name
(`STAGE_LABEL`) + the MOLT countdown `Stage · Molt 4h 59m 12s` (`ctx.live.secsToMolt`), which STAYS
at Apex for the grade roll, or `Apex · max grade` once Apex-S; the next FORM/branch is never named.
The bar fill needs no `ctx.live`, so golden frames show the stage name alone), **Odds** (the
LIVE current→next grade forecast: `from → to NN%`, grade-tinted via `GRADE_ACCENT`, ` (capped)`
at the A→S ceiling, `S ★ apex` at the top, then INLINE the REBORN affordance after the odds:
normally a muted `· Reborn <countdown>` (`ctx.live.secsToRebirth`, the deadline for the grade to
keep rolling; replaced the old `rolls at next molt` hint), but at **Apex** the clickable
`· [ Reborn Now · … ]` button (`drawRebornButton`, warm `REBORN` accent; `pet:reborn-now` hit
region, always shown at Apex since the action is always available) that opens the reborn confirm
modal. The two countdowns sit on FIXED rows and never swap).
Food, Diet and Grow share ONE bar geometry
(`barGeom`) so they line up at every width; the Food/Grow bars use `drawMeter` (single tint), the
Diet bar `drawSegmentedMeter` at 100% fill (House tints). The Odds number comes from
`ctx.live.nextGrade` (the host's `gradeOdds(state, pending)` — core owns the math, shared with the
engine's roll), and falls back to the published base odds (`gradeOdds(state)`) when there's no live
readout so golden frames stay deterministic. `LiveStats` flows `ShellHost.liveStats()` →
`FrameInput.live` → `RenderContext.live`; the cli derives it from the open window
(`engine.pendingEvents()`), the `eventTokens`/`eventEssence` helpers, the baselines, `gradeOdds`,
and the pure `nextMoltCloseAt`/`nextRebirthAt` forecasts (turned into `secsToMolt`/
`secsToRebirth` against the wall clock). Undefined in golden tests → Food shows an empty/awaiting
state, the countdowns are omitted, and Odds shows the base-odds fallback (frames stay
deterministic). Completion is per-page (NOT in this panel).
- Cells are ~1:2 w:h. The **sub-cell compositor** (`render/sprite.ts`) packs each cell at the
active density — **octant 2×4** (default, 4× half-block, Unicode 16 `render/octant-table.ts`),
**sextant 2×3** (3×, Unicode 13), **half-block 1×2** (fallback) — quantizing every cell to 2 SGR
colors + the matching block glyph (partly-transparent cells composite over the backdrop). The
mode is chosen ONCE per session via `setSubcellMode(name)`: the cli reads the `subcell`
setting (`auto`|`octant`|`sextant`|`half`) and resolves it (`apps/cli/.../subcell.ts`) —
explicit modes pass through; **`auto` → the universally-safe `half`**. octant/sextant are
explicit opt-in because a cursor-width probe CANNOT detect font glyph coverage: the cursor
advances by the Unicode width table, not glyph presence, so an octant (U+1CD00, assigned +
narrow) measures 1 column whether the terminal draws it OR a width-1 tofu box (macOS
Terminal.app does the latter), and there's no env-free capability query. The tui's OWN default
mode stays octant, so golden frames stay octant (the cli `auto`→`half` only affects runtime). Size sprites with the exported `subcellCols(width)`/`subcellRows(height)`, NOT `ceil(height/2)`.
Habitat scenes are 128×96 px (4:3). The canvas is the **true 4:3 box** from `fit43` (8:3
cells, `rows = cols × 3/8`), letterboxed in its band — the backdrop **scales uniformly to
fill that box** via `drawSprite`'s `destW`/`destH` (nearest-neighbor), never stretched. The
**pet and trinkets both scale off the `scene.cols / HABITAT_COLS` factor** (`sceneScale` in
pet.ts; pass scaled dims into the wander geometry AND `drawSprite`) so they stay proportionate
at any width. Note `sceneScale = scene.cols / 128` folds the px→cell conversion into the
habitat-width divisor, so an unboosted sprite is only ~`subcellRows(px)/48` of habitat height
(rookie ~1/8, apex ~3/16) — smaller than the art's px ratio implies. The PET sprite therefore
takes a flat **`PET_SCALE_BOOST` = 2** (players found the unboosted pet too small): a uniform
multiplier that restores px-proportionate sizing so the apex reads at ~1/3 of habitat height
(prime ~1/3, rookie ~1/4) while preserving the per-stage size ladder (egg 16 … apex 36 px).
Trinkets keep the **unboosted** `scale`, so a toy reads smaller than the creature. Min terminal
34×24 vertical / 72×12 horizontal.
- Canvas hosts: pet + habitat + trinkets, cutscenes, battle view, and full-screen pages
(Dex, Loot, Feats, Settings) inside the same content region.
- Menu flow (`render/menu.ts` → `packMenu(cols)`, shared by `layout` for `menuRows`+`menuBtnH`,
`frame` to draw, and `shell` to hit-test): a labeled `── Menu ──` divider (frame, `menuDividerY`)
then the 7 nav buttons (Pet/Dex/Loot/Feats/Battle/Settings/Quit) as real BUTTONS — one uniform width
(`menuButtonWidth()` = widest text + interior padding) and one uniform HEIGHT (`MENU_BTN_H`=1 — a
single row keeps the label CENTERED on both axes; even heights bottom-bias the text, so the button
shape comes from the filled `MENU_BTN_BG` block + interior padding, active = `MENU_ACTIVE_BG`, not
extra rows), the LABEL centered in the button and the hotkey RIGHT-aligned to the `MENU_PAD_X`
edge; buttons are distributed SPACE-BETWEEN across the full width (first flush-left,
last flush-right; a lone column is centered). `packMenu` returns x + wrap-`row`; HEIGHT is applied
by the caller via `menuButtonY(row, btnH)`/`menuBandRows(rows, btnH)` (height-aware so a taller
`MENU_BTN_H` would still work) and `computeLayout` only ever shrinks `menuBtnH` to fit a short
terminal, never below 1. On narrow widths the grid wraps with
columns aligned across rows (partial last row fills the leftmost columns). All three consumers
read `packMenu` + `layout.menuBtnH`, so sizing/draw/hit-test stay in lockstep. The completion
meter is NOT in the menu (it's shown per-page — Dex/Loot/Feats). Adding a page = extend the `PageId`
union, push a `MENU_ITEMS` entry (icon + hotkey), add a `freshUi` slot, a `handleKey` case, and a
`renderFrame` switch arm — keep all five in lockstep.
- **Keyboard parity is mandatory:** every click has a hotkey; with no mouse
reporting the game is 100% playable by keys.
- **Idle purity:** the entire UI is optional browsing — nothing gameplay-critical
ever requires input (pillar 1).
## Rendering technique
- Half-block pixel rendering: `▀` cells, fg = top pixel, bg = bottom pixel.
(Note: emit `▀` via `String.fromCodePoint(0x2580)` — literal multibyte chars have
been corrupted in file writes before.)
- Palette indirection: sprite assets carry palette indices; House tint + grade LUT
resolve RGB at render time (see create-sprites for the beauty ladder).
- Diff renderer: front/back cell buffers; emit ANSI only for changed cells with
run-coalescing. An identical re-flush must emit ZERO bytes (tested).
- Precomputed LUTs (ramps, shimmer frames, dither patterns) at load — zero per-frame
color math or allocation. Layered compositing: base sprite / aura+particles / UI
chrome — only dirty layers re-composite.
- Color degradation: truecolor → 256 → 8 → ASCII (`--no-color`), information never
disappears.
## Frame loop & performance budgets (CI-gated aspirations)
- **30fps default**, fixed-timestep with accumulator (motion even at any rate).
Adaptive DOWN if output can't drain (coalesce to 15fps; idle ~10fps event-driven);
`render.fps: 60` is opt-in config, never the default.
- Budgets: per-frame flush < 2ms (reference scene); CPU ~1–2% foreground, ~0 with no
viewer; RSS < 40MB; bundle < 1MB. Frame-skip when unfocused. The daemon/engine
never renders.
- All wall-clock through a single injected `now()` so tests can fake time.
## Input
- Raw mode + alt screen lifecycle with guaranteed terminal restore (also on
SIGINT/SIGTERM).
- SGR mouse reporting (`CSI ?1000;1002;1006 h`): press/release/move/wheel. A
hit-region registry is rebuilt from the layout each frame; topmost region wins.
Edge cases to mind: tmux passthrough, mosh, Windows Terminal.
## Testing: golden frames
Render to a string buffer (fake stdout sink, fixed size like 100×30) and
snapshot-compare. Required coverage: every page, diff-renderer zero-byte re-flush,
layout math, SGR mouse parser cases, color degradation, shell loop with injected
`now()`/IO/maxFrames. Never weaken a golden frame to pass — regenerate only when the
visual change is intended and reviewed.
## Module map (current)
`ansi.ts` (Writer/sinks/SGR), `buffer.ts` (FrameBuffer+diff), `sprite.ts`
(compositor+palette ladder, `destW`/`destH` scaling), `tiles.ts` (the UI `?` tiles —
locked + ornate-gold legend — drawn through the same sprite pipeline), `input.ts` (key/mouse decode),
`hit.ts`, `layout.ts` (`computeLayout`/`petSections`), `menu.ts` (`packMenu` left-aligned
flow), `frame.ts` (frame + menu draw), `shell.ts` (runShell loop) + `shell-io.ts` (default
stdio/terminal wiring, split out to keep `shell.ts` under the line ceiling), `status.ts`
(one-liners), `pages/` (pet, pet-vitals, **dex** + **dex-sky** (the constellation sky + focus rail),
**dex-detail**, **unlockables** (the Loot page), **achievements** (the Feats page), settings, **battle**), `lookup.ts`
(pack helpers). Shared UI lives
under `components/`: `divider.ts` (`drawDivider` — ALL-CAPS BOLD label, rule, gap-after);
`tabs.ts` (`drawTabStrip` — the centered `‹ A B C ›` category selector shared by the Dex
House skies, the Loot Habitats/Trinkets tabs, and the Feats category tabs: active tab bold in
an accent, one click region per visible tab `<prefix>:<i>`, ←→ to switch — the page owns the
filtered list + selection clamp; `ui.tab` holds the active index);
`page.ts` (the standard full-screen page scaffold — `drawPageHeader`/`drawPageFooter`/
`PAGE_HEADER_ROWS`, `clampScroll`, used by Dex/Loot/Feats/Settings); and `meter.ts` — the ONE progress bar:
`drawMeter` (filled `█` + a clearly-visible `▒` track), `drawSegmentedMeter` (filled portion split
into colored slices, e.g. the diet-tinted food), and `drawCompletionHeader`; `marquee.ts`
(`drawMarquee` — a frame-counter-driven scrolling ticker, golden-frame safe; used by the Pet
page's opt-in update notice); and `modal.ts` (`drawModal` — a centered confirm/cancel pop-up that
overlays the page, drawn LAST in the frame, registering `modal:confirm`/`modal:cancel` hit regions).
Reuse these — don't hand-roll rules/bars/headers/tickers/dialogs.
- **Modal dialogs (reusable confirm pop-up).** For ANY confirm/cancel decision, open one via the
shell's `openConfirmModal(rt, { title, lines, confirmLabel?, cancelLabel?, tone?, onConfirm })`
(`shell-modal.ts`) — never hand-roll a bespoke prompt. The render-only `ModalView` (`pages/types`)
flows `ShellRuntime.modal.view` → `FrameInput.modal` → `drawModal`, while the `onConfirm` closure
stays on the runtime (off the render path, so `drawModal` stays golden-frame pure). While a modal
is open the shell routes ALL input to `shell-modal.handleModalEvent` (←/→/Tab move focus, Enter
activates the focused button, `y`/`n`/Esc shortcut, ctrl-c still quits, clicks resolve only the two
buttons) — the page behind it is frozen. Focus defaults to **Cancel** so a destructive action is
never one stray Enter away; pass `tone: 'warning'` for a caution-tinted confirm.
## Dex constellation → detail navigation (single-level drill-in)
The Dex is a **constellation field guide**, NOT a list (`pages/dex.ts` owns the node model + page
scaffold; `pages/dex-sky.ts` draws the sky + focus rail). One House is a "sky" at a time: its
evolution tree drawn as **glow-dot stars** (apex at the top down to the sprite/egg rooted in the
shared Mote), owned species glowing in their best grade (`GRADE_BADGE`/`GRADE_ACCENT`), unseen ones
dim `?` points, joined by faint House-tinted lineage lines (`buildHouseNodes` derives tier + in-House
parents from `evolvesTo`; order is tier-asc then Dex num — deterministic). `ui.dex.house` selects the
sky (`←→` pans, wrapping; clickable House tabs register `dex:house:<i>`), `ui.dex.selected` the star
(`↑↓`; each star registers `dex:star:<idx>`). A **focus rail** (dropped below `RAIL_MIN_COLS` so the
sky takes the full width) renders the selected star's REAL sprite + identity, or a **square `?` tile**
when undiscovered — the plain slate tile, or the ornate gold **legend** tile for a reserved special
slot (wired but dormant in Season 0; honors the spoiler rule). The two color maps stay disjoint:
`GRADE_ACCENT` = the star's rarity glow, `houseColor`/`houseTint` = identity (lines + sprite palette).
The **Dex detail** page (`pages/dex-detail.ts`) is the drill-in: a `PageId` reached by Enter /
clicking an owned star (`openDexDetail` stashes `speciesId` on `ui['dex-detail']`); Esc or any click
on the detail body returns to `'dex'`. It renders the species sprite via `buildPalette(houseTint,
bestGrade, frame)`, a battle/graft-readiness banner (`isBattleReady`), and up to 3 record cards
(stats, captured date, **DNA code** via `encodeDna`, graft tier). Keep both deterministic for golden
frames: positions derive only from species data + the rect (no RNG), dates use `toISOString()` (UTC),
stat columns are fixed-width. New pages need a `frame.ts` switch case + a `freshUi()` entry;
verify/regenerate golden frames.
## Settings page: `ShellInfo` (static) + `SettingsState` (editable)
The Settings page mixes **read-only facts** with the shell's **only editable surface** — the
opt-in **update mode** and the **pet-global cycle clock** (policy + the subscription anchor
adapter). Adapters themselves are read-only here (pure data sources). Two contracts, two
responsibilities:
- **`ShellInfo`** — static facts (version, runtime, fps, dataDir). The page must stay
deterministic for golden frames, so it NEVER reads the wall clock, `process.*`, or the
filesystem itself. The cli composes these once and passes `runShell({ info })`; the
shell threads it into `RenderContext.info` (optional — undefined in tests, where the
page falls back to `—`). The app version is the single `apps/cli/src/version.ts` const
(kept in sync with package.json), re-exported from `main.ts`. Rule for any future info
page: derive process/fs facts in the cli, pass them in — never reach for them in `tui`.
- **`SettingsState`** — a live working copy of `updateMode` + the pet-global cycle clock
(`cyclePolicy`, `anchorAdapter`) + read-only `adapters`, plus `selected` (a flat field index:
**0 = update mode, 1 = cycle policy, 2 = anchor adapter**). The anchor field (index 2) appears
ONLY when `cyclePolicy === 'subscription'` and more than one adapter is configured, so
`settingsFieldCount` is 2 or 3 (`anchorFieldShown`). The page renders it and a hit region per
field but owns NO mutation. The shell drives editing: ↑↓ move `selected` (`moveSelection`),
←→ cycle the focused value (`cycleSelectedField` in `pages/settings.ts`). On each change the
shell persists to the RIGHT store by field: the update-mode field (`isUpdateFieldSelected`)
→ `options.onUpdateModeChange(mode)` → settings.json; the cycle fields →
`options.onCycleChange(policy, anchorAdapter)` → config.json. Edits apply on the **next
launch**, never mid-session — the cycle reshapes molt windows, which must not shift under a
running pet; the update mode is read at launch too. The update toggle only WRITES the mode
string — it never imports the updater network surface (the isolation gate holds; off stays the
default). Adapters are pure data sources (read-only here); adding/removing them and editing scan
paths stays in `tt init`. The pet game itself stays fully idle: Settings is optional config.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!