Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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**:
Expand Down Expand Up @@ -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 -- <scene> # 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)
Expand All @@ -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-<what>` 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-<what>` 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 -- <scene>` (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 -- <scene>`: 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.
4 changes: 2 additions & 2 deletions hooks/earthrise.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions hooks/engine.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions hooks/register.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -1527,7 +1527,7 @@ export const register: Register = (on, options) => {
<Box flexDirection="column">
{hint}
{SCENES.map((d, i) => (
<Box flexDirection="row" gap={1}>
<Box key={`row:${d.name}`} flexDirection="row" gap={1}>
{button(d.name, i)}
<Text dimColor wrap="truncate-end">
{d.blurb}
Expand Down Expand Up @@ -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(
<Box flexDirection="row" columnGap={1}>
<Box key={`row:${at}`} flexDirection="row" columnGap={1}>
{SCENES.slice(at, at + layout.across).map((d, k) => tile(d.name, at + k))}
</Box>,
)
Expand Down
7 changes: 5 additions & 2 deletions hooks/rocket.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 3 additions & 3 deletions hooks/scene.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 13 additions & 6 deletions opencode/tui.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions pi/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
23 changes: 21 additions & 2 deletions scripts/check.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 21 additions & 1 deletion tests/flow.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.node.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"skipLibCheck": true,
"types": ["node"]
},
"include": ["scripts", "pi", "opencode"]
"include": ["scripts", "pi", "opencode", "node"]
}
Loading