diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1249557..ab5a170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,10 @@ jobs: DISABLE_AUTOUPDATER: '1' CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' steps: - - uses: actions/checkout@v7 + # Actions pinned to a commit (a tag can be moved; a commit can't), the release named beside it. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 diff --git a/AGENTS.md b/AGENTS.md index c70b7b6..73e2aea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ hooks/ pixel-scene.ts PixelScene: the quick base for a scene that paints pixels (easing, fold, specks, level 0) cells.ts Cells (the grid every scene returns), Rng and freshSeed, Raster encoding pixels.ts shared helpers: mix, dist, clamp, smooth(step), approach, rampAt, rampStops, retain, hash*, fnv1a, noise1, noise2, - luma, grey, QUAD, BITS, BRAILLE, fitQuad, groupColor + luma, grey, QUAD, BITS, BRAILLE, fitQuad night.ts the shared night: sky colors, STAR, MOON, moon placement, easeNight waiting.ts the shared wait on the person: easeWait, the breath, waitTone (sepia, breathing) fire.ts the fire scene's automaton (its heat grid), glyphs and 256-color ramps @@ -79,6 +79,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - **Drawing**: - Most scenes paint a pixel layer at 2 × 2 pixels per cell, then fold it into quadrant glyphs with `fitQuad`. A cell holds only two colors; give `fitQuad` a sprite's pixels as its `keep` so a small figure never drops out of its own cell. - Braille (`BRAILLE`, 2 × 4 dots a cell) is for fine specks: spray, stars, bubbles. + - `grid()` only draws what `step()` made: the same frame however often it's called, and never the `Rng` (a flicker drawn there is a `hash` of the frame and the place). A test checks every scene. - `DEFAULT_COLOR` (0x01000000) is the terminal's own color, i.e. transparent. - A scene can also draw itself at real pixels (`Scene.picture(w, h, budget)`: RGBA, the open sky transparent), which pi shows as a kitty image in a terminal that draws them (`pi/picture.ts`); earthrise alone does so far, as a halftone like its original (each cell of a square grid a dot sized by its light, Bayer-dithered, in the original's palette), its ground built at the dots' grid, not at every pixel. A new size may take a while to make: `picture` makes `budget` of it a call and returns undefined until it's done (pi makes it between frames, showing the cells meanwhile). What took long goes in `pictureCache()` under `pictureKey(w, h)`, and pi keeps it in `~/.cache/flow` (`restorePicture` takes it back): bump the scene's version of it when what it makes changes. - **Limits**: @@ -147,7 +148,7 @@ claude plugin validate . # the module's rules, the manifests, the hooks claude plugin test . # the tests, including those that need the engine npm install # once, for the scripts below (tsx); no lockfile is kept (see below) npm run preview -- # print it here: levels 1/5/10, band and spine, day/night, each tint -npm run check # plugin.json in step, level 0 blank, colour pairs, timing (with companions, waiting, and +npm run check # plugin.json in step, FLOW_REVISION and the versions, level 0 blank, colour pairs, timing (with companions, waiting, and # over each of avalon's suns and nebulae); exits 1 on a problem npm run cvd -- [scene] # how far apart the tints look to colour-blind eyes (and --sheets DIR for pictures) npm run sounds # rebuild the soundscapes' clips (needs sox and ffmpeg) @@ -156,7 +157,7 @@ npm run sounds # rebuild the soundscapes' clips (needs sox and ffm - The oldest Claude Code, opencode and pi Flow is built against are `BUILT_AGAINST` (`settings.ts`, and the README's Compatibility): on an older one each adapter gives `versionNote` once for that version (Claude Code's `session.version()`, opencode's `app.version`, pi's own `VERSION`), kept where it keeps the rest (Claude Code's store `warned`, opencode's store, flow.json's `warned`), and runs all the same. Raise them when Flow starts needing something newer, and say so in the README. - No `package-lock.json` is committed (it's gitignored): Claude Code runs `npm ci` in a plugin's folder when it finds a lockfile beside `package.json`, and Flow needs none of its dev tools to run. `package.json` pins them to exact versions instead; bump them there. - Hot reload doesn't follow symlinks. To use a dev-mods folder, copy the plugin's files in rather than linking it. -- Every file starts with a `// REVISION: flow-vNN-` line; bump it when you change the file. On load the mod logs `[flow] REVISION: …`, the local time and the UTC offset to the debug log (`claude --debug`), so you can tell which version is running. +- Every file starts with a `// REVISION: flow-vNN-` line; bump it when you change the file. On load the mod logs `[flow] REVISION: …` (`FLOW_REVISION` in `register.tsx`: the newest of the files', which `npm run check` holds it to), the local time and the UTC offset to the debug log (`claude --debug`), so you can tell which version is running. `npm run check` also holds `plugin.json`'s and `package.json`'s versions to one. - After visual changes, run `npm run preview -- ` (levels 1, 5 and 10 in both layouts, by day and night, with each tint and waiting on the person; `--waiting` for just that) and look at it. - Run `npm run check`: the color-pair count and the timing for each scene. - After changing a tint, run `npm run cvd -- `: for each pair of tints, the CIEDE2000 distance of what the tint changes, seen with normal vision, protanopia, deuteranopia, tritanopia (Machado 2009) and achromatopsia (luminance alone), worst setting first. Under 10 is weak, under 5 can't be told at a glance; it lists what colour blindness loses that normal vision sees, and `--sheets DIR` draws those settings as each vision sees them. diff --git a/hooks/earthrise.ts b/hooks/earthrise.ts index f3796d3..28aa408 100644 --- a/hooks/earthrise.ts +++ b/hooks/earthrise.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v8-earthrise-tones +// REVISION: flow-v191-tidy // // Earthrise (the `earthrise` scene): the Earth coming up over a cratered // lunar horizon in long, low sunlight, after "earthrise" by Shubham on @@ -1297,7 +1297,7 @@ function layout(pw: number, ph: number, ry: number, tall: boolean): Layout { * Once the last column is in, mark where the Earth can reach. */ function build(L: Layout, budget: number): void { - const { pw, ph, s, sy, col0, row0, k, ex, er, sub, at, ground } = L + const { pw, ph, s, sy, col0, k, sub, at, ground } = L if (L.done >= pw) return const rows = ph * sub if (gx.length < rows) { diff --git a/hooks/engine.ts b/hooks/engine.ts index f79929a..f09e2cb 100644 --- a/hooks/engine.ts +++ b/hooks/engine.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v188-dry +// REVISION: flow-v189-pure-grid // // Engine (the `engine` style): a Victorian steam engine room on the same dials // as the fire; the level is how hard it is being driven. At 1 it stands cold, @@ -38,7 +38,7 @@ import type { AgentDial } from './agents' import { Cells, DEFAULT_COLOR, freshSeed, Rng, isTall } from './cells' import { beckon, Crew, failed, type AgentMark } from './crew' import type { Tint } from './styles' -import { BRAILLE, clamp, clamp01, dist, mix, QUAD, rampAt } from './pixels' +import { BRAILLE, clamp, clamp01, dist, hash, mix, QUAD, rampAt } from './pixels' import { defineScene } from './scene-def' import { hear, type SoundEvent } from './sound' import { breath, easeWait, waitTone } from './waiting' @@ -1368,7 +1368,7 @@ export class Engine { } private fireAt(qx: number, qy: number): number { - const flick = this.rng.f() * 0.18 - 0.06 + Math.sin(this.t * 0.7 + qx * 1.3) * 0.04 + const flick = hash(qx + this.t * 7919, qy, 61) * 0.18 - 0.06 + Math.sin(this.t * 0.7 + qx * 1.3) * 0.04 const h = this.heat * (qy & 1 ? 1 : 0.88) + flick * (0.4 + this.heat) // Blue: a gas flame, never cold-dark, so it reads at every level. if (this.tint === 'blue') return fireColor(0.3 + h * 0.75, BLUE_FIRE) diff --git a/hooks/register.tsx b/hooks/register.tsx index c9eb817..0d6523f 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,4 +1,4 @@ -// REVISION: flow-v188-dry +// REVISION: flow-v191-tidy // // Flow for Claude Code, by Rob Macrae: ambient scenes (a fire, the surf, a ski run, // rockets, a hot-air balloon and more) drawn as one terminal `Raster` in the @@ -106,7 +106,7 @@ import { } from './picker' -const FLOW_REVISION = 'flow-v173-directory' +const FLOW_REVISION = 'flow-v191-tidy' const PLUGIN = 'flow-scenes' const KEY = 'flow' /** The command. */ @@ -1527,7 +1527,7 @@ export const register: Register = (on, options) => { {hint} {SCENES.map((d, i) => ( - + {button(d.name, i)} {d.blurb} @@ -1555,7 +1555,7 @@ export const register: Register = (on, options) => { const grid: RenderElement[] = [] for (let at = 0; at < SCENES.length; at += layout.across) { grid.push( - + {SCENES.slice(at, at + layout.across).map((d, k) => tile(d.name, at + k))} , ) diff --git a/hooks/rocket.ts b/hooks/rocket.ts index 02238b7..641f816 100644 --- a/hooks/rocket.ts +++ b/hooks/rocket.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v188-dry +// REVISION: flow-v189-pure-grid // // Two launch sites in the sky world (sky.ts): a Falcon 9 and a Starship, each // beside a lattice launch tower (Starship's with two catch arms). The level is the @@ -775,6 +775,8 @@ abstract class LaunchSite extends SkyWorld { private twinH = 0 private braking = false private rng: Rng + /** Salts the plume's flicker (drawn from a hash, so drawing never moves the rng). */ + private readonly flicker: number /** The layer it's flying in (world rows), eased; -1 until the first frame. */ private layer = -1 /** Tiles folded away so far: offsets each tile's clouds sideways. */ @@ -837,6 +839,7 @@ abstract class LaunchSite extends SkyWorld { constructor(seed = freshSeed()) { super(seed) this.rng = new Rng(seed * 2654435761) + this.flicker = seed | 0 } override ensure(columns: number, rows: number): void { @@ -1945,7 +1948,7 @@ abstract class LaunchSite extends SkyWorld { const p = this.part // (No plume from the Ship until it's all but upright: the plume falls straight down, and a tilted Ship over it looks wrong.) if (this.thr <= 0 || p === 'dragon' || p === 'capsule' || this.flop > 0.06) return 0 - let len = this.spec.plume * this.thr * (0.85 + 0.3 * this.rng.f()) * (this.part === 'upper' ? 0.7 : 1) + let len = this.spec.plume * this.thr * (0.85 + 0.3 * hash(this.t, this.flicker, 41)) * (this.part === 'upper' ? 0.7 : 1) if (this.tint === 'smoke' && hash(this.t >> 1, 3, 43) < 0.35) len *= 0.25 return len } diff --git a/hooks/scene.ts b/hooks/scene.ts index bf98c26..9e234e6 100644 --- a/hooks/scene.ts +++ b/hooks/scene.ts @@ -1,7 +1,7 @@ -// REVISION: flow-v172-cleanup-followups +// REVISION: flow-v191-tidy // -// What every harness adapter (Claude Code's register.tsx, pi's pi/index.ts) -// does the same way: one scene instance per style (so a switch resumes where +// What every harness adapter (Claude Code's register.tsx, opencode's +// opencode/tui.ts, pi's pi/index.ts) does the same way: one scene instance per style (so a switch resumes where // that scene left off), the level the settings and the activity call for, whether // anything shows at all, and pointing the current scene at those dials, the // time of day and whether Claude waits on the person. Two drivers can share diff --git a/opencode/tui.ts b/opencode/tui.ts index 2f7d228..156f6f9 100644 --- a/opencode/tui.ts +++ b/opencode/tui.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v7-dry +// REVISION: flow-v9-tidy // // Flow for opencode (2.0), by Rob Macrae: the same ambient scenes as the // Claude Code mod and the pi extension, in opencode's terminal UI. A terminal @@ -28,7 +28,7 @@ import { fileURLToPath } from 'node:url' import { Activity } from '../hooks/activity' import { agentLine } from '../hooks/agents' import { staleSessions, storedOwn } from '../hooks/sessions' -import { clockAt, firstTips, type FlowConfig, type Host, hostReplyText, NO_PLAYER, parseFlowArgs, readConfig, replyTo, tipChance, type Tips, untitled, versionNote } from '../hooks/settings' +import { clockAt, firstTips, type Host, hostReplyText, NO_PLAYER, parseFlowArgs, readConfig, replyTo, tipChance, type Tips, untitled, versionNote } from '../hooks/settings' import { FRAME_MS, SceneDriver } from '../hooks/scene' import { Soundscape } from '../hooks/soundscape' import { type Scene, SCENES } from '../hooks/styles' @@ -68,6 +68,8 @@ type Kept = { /** The plugin's folder: its sounds/ are played from there. */ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const sameWords = (a: Uint32Array, b: Uint32Array): boolean => a.length === b.length && a.every((w, i) => w === b[i]) + function setup(ctx: OcContext): () => void { const player = afplayPlayer(ROOT) const HOST: Host = { panes: true, sound: player !== undefined, thumbnails: false } @@ -161,10 +163,11 @@ function setup(ctx: OcContext): () => void { /** * The scene at a view's size, stepped by the frame loop: the view only draws it. opencode redraws far more * often than a frame (each key, each streamed word), so a frame is made once a step and drawn as often as - * asked; made again on every redraw it would cost a frame's work each time, and the scenes that flicker at - * random as they draw (engine, the rockets) would flicker faster. + * asked; made again on every redraw it would cost a frame's work each time. */ let made: { scene: Scene; cells: Cells } | undefined + /** The frame last sent to a view, and which: one just like it (a calm balloon's often is) asks for no redraw. */ + let sent: { view: SceneView; words: Uint32Array } | undefined const frameAt = (columns: number, rows: number) => { const f = driver.scene if (!driver.isShown()) return undefined @@ -272,8 +275,12 @@ function setup(ctx: OcContext): () => void { f.ensure(live.width, live.height) f.step() made = undefined - live.requestRender() - } + const words = frameAt(live.width, live.height)?.words + if (words && (sent?.view !== live || !sameWords(words, sent.words))) { + sent = { view: live, words: words.slice() } + live.requestRender() + } + } else sent = undefined sound.step(elapsed, { heard: live !== undefined, shown: live ? f : undefined, diff --git a/pi/index.ts b/pi/index.ts index 22a0fd0..bdf9ce8 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v188-dry +// REVISION: flow-v190-same-frame // // Flow for pi (badlogic/pi-mono), by Rob Macrae: the same ambient // scenes as the Claude Code mod, in a widget above pi's editor. pi's events @@ -403,7 +403,8 @@ export default function flow(pi: PiApi) { if (shown && tui) { sincePicture += elapsed const next = nextLines(f) - if (next) { + // (A frame like the last, as a calm balloon's often is, asks pi for no render of its own.) + if (next && (next.length !== lines.length || next.some((l, i) => l !== lines[i]))) { lines = next tui.requestRender() } diff --git a/scripts/check.ts b/scripts/check.ts index b8ad519..b7538ed 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,7 +1,8 @@ -// REVISION: flow-v185-clips +// REVISION: flow-v191-tidy // // What every scene must hold to (AGENTS.md), measured: plugin.json lists -// the scenes in SCENES, hooks/sound-files.ts lists the clips in sounds/, +// the scenes in SCENES, register.tsx's FLOW_REVISION is the files' newest, +// plugin.json's and package.json's versions agree, hooks/sound-files.ts lists the clips in sounds/, // level 0 draws nothing, a frame has at most 1024 colour pairs, and // step() + grid() takes under ~2 ms at the band's and the spine's sizes // (a scene with a taller view of its own, doom3d's, at its taller band too) @@ -13,6 +14,7 @@ // npm run check -- surf ski just these // FLOW_MAX_MS=6 npm run check a looser time budget, as CI runs it +import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { SOUND_FILES } from '../hooks/sound-files' import { makeScene } from '../hooks/styles' @@ -48,6 +50,23 @@ const bad = (why: string) => { if (syncManifest(false)) bad('plugin.json is out of step with SCENES: run `npm run sync`') +// The REVISION the mod logs at load (register.tsx's FLOW_REVISION) is the newest of the files' own (the counter +// hooks/, scripts/, pi/ and tests/ share), so the debug log says which version is running. +const ROOT = join(import.meta.dirname, '..') +const revisionOf = (text: string) => Number(/^\/\/ REVISION: flow-v(\d+)-/.exec(text)?.[1] ?? 0) +const newest = Math.max( + ...['hooks', 'scripts', 'pi', 'tests'].flatMap(dir => + readdirSync(join(ROOT, dir)) + .filter(f => /\.tsx?$/.test(f)) + .map(f => revisionOf(readFileSync(join(ROOT, dir, f), 'utf8'))), + ), +) +const logged = Number(/const FLOW_REVISION = 'flow-v(\d+)-/.exec(readFileSync(join(ROOT, 'hooks', 'register.tsx'), 'utf8'))?.[1] ?? 0) +if (logged !== newest) bad(`register.tsx's FLOW_REVISION is v${logged}, the files' newest v${newest}: make it that`) +// One version: the plugin's (plugin.json, what Claude Code shows) and the package's (pi's and opencode's). +const versionOf = (file: string) => (JSON.parse(readFileSync(join(ROOT, file), 'utf8')) as { version?: string }).version +if (versionOf('.claude-plugin/plugin.json') !== versionOf('package.json')) bad('plugin.json and package.json give different versions') + // The clips on disk and the ones the manifest names, as scripts/make-sounds.ts lists them. const SOUNDS = join(import.meta.dirname, '..', 'sounds') const clips = listClips(SOUNDS) diff --git a/tests/flow.test.ts b/tests/flow.test.ts index 2ee588c..e9dcd33 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v188-dry +// REVISION: flow-v189-pure-grid import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' @@ -1565,6 +1565,26 @@ test('every scene has a unique lowercase name, a blurb, and builds', () => { } }) +test('every scene draws without moving on: grid() twice gives one frame, and drawing never changes the frames after', { timeoutMs: SLOW_MS }, () => { + // (Hosts draw a step once, but a scene that drew from its rng would flicker under a second look, and step differently for it.) + for (const name of STYLES) for (const [columns, rows] of [[120, 5], [22, 50]] as const) { + const looked = makeScene(name, 7) + const not = makeScene(name, 7) + for (const s of [looked, not]) { + s.ensure(columns, rows) + s.strength = 10 + } + for (let i = 0; i < 60; i++) { + looked.step() + not.step() + const first = looked.grid().words.slice() + expect(looked.grid().words, `${name} ${columns}×${rows}, step ${i}`).toEqual(first) + if (i % 3 === 0) continue // (the other only looks every third step) + expect(not.grid().words, `${name} ${columns}×${rows}, step ${i}`).toEqual(first) + } + } +}) + test('desktop: shades fill the whole cell, blended, rather than a dither', () => { const g = new Cells(1, 1) g.set(0, 0x2592, 0xff8800, DEFAULT) // ▒ over the terminal's own color diff --git a/tsconfig.node.json b/tsconfig.node.json index a0a5609..b56980f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -10,5 +10,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["scripts", "pi", "opencode"] + "include": ["scripts", "pi", "opencode", "node"] }