From d82b470f103491553ed61b26c42a75efef33b80e Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 19:10:42 +0100 Subject: [PATCH 1/4] pi and opencode share how they show a /flow reply (hostReplyText: no `!` before what was wrong, the tip under it), the no-player warning (NO_PLAYER), a note without its `flow: ` (untitled), and /flow save's reply as PiSettings gives it (a FlowReply); each keeps its tips and version note in its own store as before Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- hooks/settings.ts | 16 +++++++++++++++- opencode/tui.ts | 17 +++++++---------- pi/index.ts | 16 ++++++---------- pi/session.ts | 10 +++++----- tests/flow.test.ts | 17 +++++++++++++++-- 5 files changed, 48 insertions(+), 28 deletions(-) diff --git a/hooks/settings.ts b/hooks/settings.ts index 6d92d99..3a368fc 100644 --- a/hooks/settings.ts +++ b/hooks/settings.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v179-built-against +// REVISION: flow-v188-dry // // Flow's settings and the `/flow` command's grammar, shared by every harness adapter (Claude Code's // register.tsx, pi's pi/index.ts). Pure: no engine imports. Replies carry no @@ -410,6 +410,20 @@ export function changesFor(cmd: FlowCommand, cfg: FlowConfig): Partial text.replace(/^flow: /, '') + +/** + * `/flow`'s reply as pi and opencode show it: as it is, with no `!` before what was wrong, + * and a one-time tip, if one's due, under it (as in Claude Code). + */ +export function hostReplyText(cmd: FlowCommand, reply: FlowReply, tip: string | undefined): string { + return (cmd.kind === 'error' ? reply.text.replace(/^! /, '') : reply.text) + (tip ? `\n\n${tip}` : '') +} + /** * What `/flow` needs of an adapter: the settings it shows (changed in place), * what's on now, and keeping a change, a save or a reset its own way (Claude diff --git a/opencode/tui.ts b/opencode/tui.ts index 9e5c842..80c8667 100644 --- a/opencode/tui.ts +++ b/opencode/tui.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v6-hygiene +// REVISION: flow-v7-dry // // 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, FRAME_MS } from '../hooks/activity' import { agentLine } from '../hooks/agents' import { staleSessions } from '../hooks/sessions' -import { firstTips, type FlowConfig, type Host, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type Tips, versionNote } from '../hooks/settings' +import { firstTips, type FlowConfig, type Host, hostReplyText, NO_PLAYER, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type Tips, untitled, versionNote } from '../hooks/settings' import { SceneDriver } from '../hooks/scene' import { Soundscape } from '../hooks/soundscape' import { type Scene, SCENES } from '../hooks/styles' @@ -129,7 +129,7 @@ function setup(ctx: OcContext): () => void { }) } // (The toast is titled flow already.) - return tip?.replace(/^flow: /, '') + return tip && untitled(tip) } /** The context limit of a session's model. */ @@ -228,7 +228,7 @@ function setup(ctx: OcContext): () => void { void keep(d => { d.warned = version }) - ctx.ui.toast.show({ title: 'flow', message: note.replace(/^flow: /, ''), variant: 'warning', duration: 12_000 }) + ctx.ui.toast.show({ title: 'flow', message: untitled(note), variant: 'warning', duration: 12_000 }) } }, TIP_AFTER_MS) } @@ -318,7 +318,7 @@ function setup(ctx: OcContext): () => void { cmd = { kind: 'style', name } } if (cmd.kind === 'sound' && !player) { - ctx.ui.toast.show({ title: 'flow', message: 'the soundscapes play through afplay, which only macOS has', variant: 'warning' }) + ctx.ui.toast.show({ title: 'flow', message: NO_PLAYER, variant: 'warning' }) return } const reply = await replyTo(cmd, { @@ -333,16 +333,13 @@ function setup(ctx: OcContext): () => void { }, level: () => driver.level(), tint: () => driver.tint(), - save: async () => { - const { text, saved } = await settings.save(entries) - return { text, level: saved ? 'info' : 'warning' } - }, + save: () => settings.save(entries), reset: async () => settings.reset(entries), change: changes => settings.change(changes, entries), }) // A one-time tip goes under the reply, as in Claude Code. const tip = takeTip() - const text = (cmd.kind === 'error' ? reply.text.replace(/^! /, '') : reply.text) + (tip ? `\n\n${tip}` : '') + const text = hostReplyText(cmd, reply, tip) // (The help is long: a dialog to read at leisure. The rest is a line or two, a toast.) if (cmd.kind === 'help') return void (await ctx.ui.dialog.alert({ title: 'flow', message: text })) ctx.ui.toast.show({ title: 'flow', message: text, variant: reply.level === 'warning' ? 'warning' : 'info', duration: tip ? 12_000 : undefined }) diff --git a/pi/index.ts b/pi/index.ts index b357eec..1f17116 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v185-pi-reload +// REVISION: flow-v188-dry // // 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 @@ -48,7 +48,7 @@ import { fileURLToPath } from 'node:url' import { Activity } from '../hooks/activity' import { FRAME_MS, SceneDriver } from '../hooks/scene' import { pickBlurb } from '../hooks/picker' -import { firstTips, type Host, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type FlowConfig, versionNote } from '../hooks/settings' +import { firstTips, type Host, hostReplyText, NO_PLAYER, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type FlowConfig, untitled, versionNote } from '../hooks/settings' import { Soundscape } from '../hooks/soundscape' import { SCENES, type Scene } from '../hooks/styles' import { afplayPlayer } from '../node/player' @@ -158,7 +158,7 @@ async function takeTip(cfg: FlowConfig): Promise { try { const { tip, tips, changed } = tipChance((await readStored())?.tips, cfg) if (changed) await keepStored({ tips }) - return tip?.replace(/^flow: /, '') + return tip && untitled(tip) } catch { return undefined } @@ -542,7 +542,7 @@ export default function flow(pi: PiApi) { return } if (cmd.kind === 'sound' && !player) { - ctx.ui.notify('flow: the soundscapes play through afplay, which only macOS has', 'warning') + ctx.ui.notify(say(NO_PLAYER), 'warning') return } const reply = await replyTo(cmd, { @@ -558,10 +558,7 @@ export default function flow(pi: PiApi) { }, level: () => driver.level(), tint: () => driver.tint(), - save: async () => { - const { text, saved } = await settings.save(entries) - return { text, level: saved ? 'info' : 'warning' } - }, + save: () => settings.save(entries), reset: async () => { const text = settings.reset(entries) redraw(ctx) @@ -575,8 +572,7 @@ export default function flow(pi: PiApi) { }) // A one-time tip goes under the reply, as in Claude Code and opencode. const tip = await takeTip(cfg) - // (pi shows a reply as it is: no `!` before what was wrong.) - ctx.ui.notify(say((cmd.kind === 'error' ? reply.text.replace(/^! /, '') : reply.text) + (tip ? `\n\n${tip}` : '')), reply.level) + ctx.ui.notify(say(hostReplyText(cmd, reply, tip)), reply.level) } pi.registerCommand('flow', { diff --git a/pi/session.ts b/pi/session.ts index 7672a1c..e4fa73a 100644 --- a/pi/session.ts +++ b/pi/session.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v121-fresh-defaults +// REVISION: flow-v188-dry // // pi's settings for one session, apart from pi and the disk so the shared // tests cover them: the defaults new sessions start with (flow.json, through @@ -9,7 +9,7 @@ // Pure (no Node imports). import { differences, type Own, pinShown, storedOwn, withOwn } from '../hooks/sessions' -import { ownHint, readConfig, resetText, savedText, type FlowConfig } from '../hooks/settings' +import { type FlowConfig, type FlowReply, ownHint, readConfig, resetText, savedText } from '../hooks/settings' import { ownInSession } from './mapping' import type { PiSessionEntry } from './types' @@ -75,19 +75,19 @@ export class PiSettings { } /** `/flow save`: flow.json takes every setting the session shows that it doesn't hold, so new sessions start just so. */ - async save(session: SessionEntries | undefined): Promise<{ text: string; saved: boolean }> { + async save(session: SessionEntries | undefined): Promise { const before = { ...this.defaults } const changes = differences(this.cfg, this.defaults) if (Object.keys(changes).length) { try { await this.file.save(changes) } catch { - return { text: "not saved: flow.json couldn't be written", saved: false } + return { text: "not saved: flow.json couldn't be written", level: 'warning' } } this.defaults = { ...this.defaults, ...changes } } if (session && Object.keys(this.own).length) this.keep(session, {}) - return { text: savedText(this.cfg, before, Object.keys(changes).length > 0), saved: true } + return { text: savedText(this.cfg, before, Object.keys(changes).length > 0), level: 'info' } } /** `/flow reset`: the session back on the defaults. */ diff --git a/tests/flow.test.ts b/tests/flow.test.ts index a4dc0e7..e3f5279 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v185-hygiene-bugs +// REVISION: flow-v188-dry import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' @@ -6,7 +6,7 @@ import { expect, mock, test } from 'claude-code/testing' import { AsciiFire, colorFor, params } from '../hooks/fire' import { effortFloor, Activity, linesWritten, WAIT_LEVEL } from '../hooks/activity' import { firstTips, nextTip, readTips, tipChance, changedText, changesFor, helpText, isNightAt, ownHint, parseFlowArgs, readConfig, resetText, savedText, staleRows, statusText } from '../hooks/settings' -import { BUILT_AGAINST, CLAUDE_CODE, type FlowAdapter, olderThan, replyTo, versionNote } from '../hooks/settings' +import { BUILT_AGAINST, CLAUDE_CODE, type FlowAdapter, hostReplyText, olderThan, replyTo, untitled, versionNote } from '../hooks/settings' import { differences, type Own, ownAfterSwitch, pinShown, readOwn, readRecord, SESSION_KEPT_MS, SESSIONS_KEPT, sessionKey, staleSessions, storedOwn, storedRecord, withOwn } from '../hooks/sessions' import { gridToAnsi } from '../pi/ansi' import { effortOf, ownInSession, piLinesWritten } from '../pi/mapping' @@ -4510,3 +4510,16 @@ test('a host older than Flow is built against is told so (it still runs); as new expect(note).toContain('opencode 2.0.29 or later') expect(note).toContain('this is 2.0.12') }) + +test("pi and opencode show a reply as it is (no `!` before what was wrong), a due tip under it, and drop `flow: ` where it's their title", () => { + const error = parseFlowArgs('nonsense') + expect(error.kind).toBe('error') + const wrong = hostReplyText(error, { text: '! no scene called nonsense', level: 'warning' }, undefined) + expect(wrong).toBe('no scene called nonsense') + // (Only an error's mark goes: a reply that happens to start so is left as it is.) + expect(hostReplyText({ kind: 'show' }, { text: '! as it is' }, undefined)).toBe('! as it is') + expect(hostReplyText({ kind: 'auto' }, { text: 'auto' }, 'a tip')).toBe('auto\n\na tip') + expect(hostReplyText({ kind: 'auto' }, { text: 'auto' }, '')).toBe('auto') + expect(untitled('flow: built for pi 1.0.0 or later')).toBe('built for pi 1.0.0 or later') + expect(untitled('no flow: here')).toBe('no flow: here') +}) From 7c007e6c09754e7da0c917ccbf618c030748bd84 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 19:12:29 +0100 Subject: [PATCH 2/4] One way to keep settings as stored (storedOwn, in pi's flow.json, opencode's store and /flow save's /config rows) and to read the local clock (clockAt) in every adapter; opencode takes FRAME_MS from the driver as the others do; pi/session.ts says opencode keeps its settings through it too Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- hooks/register.tsx | 11 ++++------- hooks/settings.ts | 6 ++++++ opencode/tui.ts | 13 ++++++------- pi/index.ts | 10 ++++------ pi/session.ts | 2 ++ tests/flow.test.ts | 7 ++++++- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/hooks/register.tsx b/hooks/register.tsx index 4b19995..c9eb817 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,4 +1,4 @@ -// REVISION: flow-v182-built-against +// REVISION: flow-v188-dry // // 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 @@ -52,6 +52,7 @@ import type { AgentMark } from './crew' import { FRAME_MS, SceneDriver } from './scene' import { CLAUDE_CODE, + clockAt, firstTips, ownHint, parseFlowArgs, @@ -59,7 +60,6 @@ import { replyTo, resetText, savedText, - storedValue, type FlowCommand, type FlowConfig, type FlowLayout, @@ -507,9 +507,7 @@ export async function planSave($: EngineInterface, ctx: SceneCtx): Promise [field, storedValue(field, value)] as [keyof FlowConfig, string | number], - ) + const rows = Object.entries(storedOwn(changes)) as [keyof FlowConfig, string | number][] return { before: { ...s.defaults }, changes, rows } } @@ -869,8 +867,7 @@ export const register: Register = (on, options) => { lastCells = '' } const setClock = (ms: number) => { - const d = new Date(ms) - driver.clock = desktopDriver.clock = { hour: d.getHours(), minute: d.getMinutes() } + driver.clock = desktopDriver.clock = clockAt(ms) } /** The scene no longer draws in the spine (its pane closed, or showing the band's note). */ const leftSpine = () => { diff --git a/hooks/settings.ts b/hooks/settings.ts index 3a368fc..b400f3d 100644 --- a/hooks/settings.ts +++ b/hooks/settings.ts @@ -45,6 +45,12 @@ export type FlowConfig = { /** The local time of day, as the adapter last read it. */ export type Clock = { hour: number; minute: number } +/** The local time of day at `ms` (the host runs on the person's machine, so its time is theirs). */ +export function clockAt(ms: number): Clock { + const d = new Date(ms) + return { hour: d.getHours(), minute: d.getMinutes() } +} + const DEFAULT_LEVEL = 8 /** By the clock, night runs from this hour... */ const NIGHT_FROM = 19 diff --git a/opencode/tui.ts b/opencode/tui.ts index 80c8667..2f7d228 100644 --- a/opencode/tui.ts +++ b/opencode/tui.ts @@ -25,11 +25,11 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Activity, FRAME_MS } from '../hooks/activity' +import { Activity } from '../hooks/activity' import { agentLine } from '../hooks/agents' -import { staleSessions } from '../hooks/sessions' -import { firstTips, type FlowConfig, type Host, hostReplyText, NO_PLAYER, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type Tips, untitled, versionNote } from '../hooks/settings' -import { SceneDriver } from '../hooks/scene' +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 { FRAME_MS, SceneDriver } from '../hooks/scene' import { Soundscape } from '../hooks/soundscape' import { type Scene, SCENES } from '../hooks/styles' import type { Cells } from '../hooks/cells' @@ -83,7 +83,7 @@ function setup(ctx: OcContext): () => void { load: async () => readConfig({ ...ctx.options, ...kept.defaults }), save: changes => keep(d => { - for (const [k, v] of Object.entries(changes) as [keyof FlowConfig, FlowConfig[keyof FlowConfig]][]) d.defaults[k] = storedValue(k, v) + Object.assign(d.defaults, storedOwn(changes)) }), }) /** A session's own settings, kept in the store under its id (as pi keeps them in the session itself). */ @@ -249,8 +249,7 @@ function setup(ctx: OcContext): () => void { sinceClock += elapsed if (sinceClock >= CLOCK_EVERY_MS) { sinceClock = 0 - const d = new Date() - driver.clock = { hour: d.getHours(), minute: d.getMinutes() } + driver.clock = clockAt(Date.now()) } sinceDefaults += elapsed if (sinceDefaults >= DEFAULTS_EVERY_MS && opened) { diff --git a/pi/index.ts b/pi/index.ts index 1f17116..22a0fd0 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -48,7 +48,8 @@ import { fileURLToPath } from 'node:url' import { Activity } from '../hooks/activity' import { FRAME_MS, SceneDriver } from '../hooks/scene' import { pickBlurb } from '../hooks/picker' -import { firstTips, type Host, hostReplyText, NO_PLAYER, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type FlowConfig, untitled, versionNote } from '../hooks/settings' +import { storedOwn } from '../hooks/sessions' +import { clockAt, firstTips, type Host, hostReplyText, NO_PLAYER, parseFlowArgs, readConfig, replyTo, tipChance, type FlowConfig, untitled, versionNote } from '../hooks/settings' import { Soundscape } from '../hooks/soundscape' import { SCENES, type Scene } from '../hooks/styles' import { afplayPlayer } from '../node/player' @@ -137,9 +138,7 @@ async function keepStored(changes: Record): Promise { /** Write just these settings into flow.json, as they're stored (keepStored). */ async function saveSettings(changes: Partial): Promise { - const stored: Record = {} - for (const [k, v] of Object.entries(changes) as [keyof FlowConfig, FlowConfig[keyof FlowConfig]][]) stored[k] = storedValue(k, v) - await keepStored(stored) + await keepStored(storedOwn(changes)) } /** @@ -337,8 +336,7 @@ export default function flow(pi: PiApi) { /** Read the local clock (pi runs on this machine, so its time is the person's). */ const readClock = () => { - const d = new Date() - driver.clock = { hour: d.getHours(), minute: d.getMinutes() } + driver.clock = clockAt(Date.now()) } /** Point the current scene at this frame's dials and size. */ diff --git a/pi/session.ts b/pi/session.ts index e4fa73a..2271598 100644 --- a/pi/session.ts +++ b/pi/session.ts @@ -6,6 +6,8 @@ // entries (through `entries`). An older pi without session entries shares // one set in flow.json, as before. flow.json is read afresh before anything // compares with it (`refresh`): another pi session may have saved since. +// opencode keeps its settings the same way (opencode/tui.ts): its storage +// stands in for flow.json, and each session's record there for its entries. // Pure (no Node imports). import { differences, type Own, pinShown, storedOwn, withOwn } from '../hooks/sessions' diff --git a/tests/flow.test.ts b/tests/flow.test.ts index e3f5279..2ee588c 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -6,7 +6,7 @@ import { expect, mock, test } from 'claude-code/testing' import { AsciiFire, colorFor, params } from '../hooks/fire' import { effortFloor, Activity, linesWritten, WAIT_LEVEL } from '../hooks/activity' import { firstTips, nextTip, readTips, tipChance, changedText, changesFor, helpText, isNightAt, ownHint, parseFlowArgs, readConfig, resetText, savedText, staleRows, statusText } from '../hooks/settings' -import { BUILT_AGAINST, CLAUDE_CODE, type FlowAdapter, hostReplyText, olderThan, replyTo, untitled, versionNote } from '../hooks/settings' +import { BUILT_AGAINST, CLAUDE_CODE, clockAt, type FlowAdapter, hostReplyText, olderThan, replyTo, untitled, versionNote } from '../hooks/settings' import { differences, type Own, ownAfterSwitch, pinShown, readOwn, readRecord, SESSION_KEPT_MS, SESSIONS_KEPT, sessionKey, staleSessions, storedOwn, storedRecord, withOwn } from '../hooks/sessions' import { gridToAnsi } from '../pi/ansi' import { effortOf, ownInSession, piLinesWritten } from '../pi/mapping' @@ -4523,3 +4523,8 @@ test("pi and opencode show a reply as it is (no `!` before what was wrong), a du expect(untitled('flow: built for pi 1.0.0 or later')).toBe('built for pi 1.0.0 or later') expect(untitled('no flow: here')).toBe('no flow: here') }) + +test('the clock every adapter reads is the local time of day', () => { + const at = new Date(2026, 9, 15, 19, 7, 30).getTime() + expect(clockAt(at)).toEqual({ hour: 19, minute: 7 }) +}) From 08ce5a124552138a5fede0980858f9b4de00012b Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 19:15:56 +0100 Subject: [PATCH 3/4] Scenes use what pixels.ts shares: one FNV-1a (fnv1a: a companion's seed, a clip's hash), approach() for a companion's busy, clamp for the level (sky, engine, starfield, rockets), and fire's palettes as packed stops on rampStops (every colour as before); the fire's 'tall' is everyone's (isTall), and fire.ts's own word for a body with headroom is 'stretched'. Every frame of every scene is unchanged Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- AGENTS.md | 2 +- hooks/crew.ts | 10 ++++---- hooks/engine.ts | 8 +++---- hooks/fire-palette.ts | 55 ++++++++++++++++++------------------------- hooks/fire.ts | 10 ++++---- hooks/pixels.ts | 9 ++++++- hooks/rocket.ts | 6 ++--- hooks/sky.ts | 6 ++--- hooks/sound.ts | 6 ++--- hooks/starfield.ts | 8 +++---- hooks/styles.ts | 6 ++--- 11 files changed, 61 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 62b51cd..c70b7b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ hooks/ scene-def.ts SceneDef / defineScene: what a scene file exports so it can be listed 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*, noise1, noise2, + pixels.ts shared helpers: mix, dist, clamp, smooth(step), approach, rampAt, rampStops, retain, hash*, fnv1a, noise1, noise2, luma, grey, QUAD, BITS, BRAILLE, fitQuad, groupColor 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) diff --git a/hooks/crew.ts b/hooks/crew.ts index 5530ced..205b832 100644 --- a/hooks/crew.ts +++ b/hooks/crew.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-plain-names +// REVISION: flow-v188-dry // // A scene's companions: one for each running subagent (the `agents` dial, // agents.ts), so a glance tells you which are busy, which have gone quiet or @@ -20,7 +20,7 @@ // Pure: no `$`. import type { AgentDial } from './agents' -import { smooth } from './pixels' +import { approach, fnv1a, smooth } from './pixels' /** Where a companion is drawn this frame, in cells: what desktop's hover card for its agent sits over. */ export interface AgentMark { @@ -66,8 +66,7 @@ const ANON = '~' /** [0, 1) from a string: FNV-1a, then scattered. */ export function seedOf(id: string): number { - let h = 0x811c9dc5 - for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 0x01000193) + let h = fnv1a(id) h ^= h >>> 15 h = Math.imul(h, 0x2c1b3c6d) h ^= h >>> 12 @@ -142,8 +141,7 @@ export class Crew { m.leaving = gone m.waiting = !gone && a!.state === 'waiting' const goal = !gone && a!.state === 'working' ? 1 : 0 - m.busy += (goal - m.busy) * BUSY_EASE - if (Math.abs(goal - m.busy) < 0.01) m.busy = goal + m.busy = approach(m.busy, goal, BUSY_EASE) m.p = Math.max(0, Math.min(1, m.p + (m.leaving ? -1 / this.leave : 1 / this.arrive))) m.here = smooth(m.p) m.age++ diff --git a/hooks/engine.ts b/hooks/engine.ts index c34891e..f79929a 100644 --- a/hooks/engine.ts +++ b/hooks/engine.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-plain-names +// REVISION: flow-v188-dry // // 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, clamp01, dist, mix, QUAD, rampAt } from './pixels' +import { BRAILLE, clamp, clamp01, dist, mix, QUAD, rampAt } from './pixels' import { defineScene } from './scene-def' import { hear, type SoundEvent } from './sound' import { breath, easeWait, waitTone } from './waiting' @@ -645,7 +645,7 @@ export class Engine { this.t++ this.crew.room = this.lampRoom() this.crew.update(this.agents, this.coverageBoost) - const level = Math.max(0, Math.min(10, Math.round(this.strength))) + const level = clamp(Math.round(this.strength), 0, 10) const smoke = this.tint === 'smoke' const was = breath(this.t - 1) this.kWait = easeWait(this.kWait, this.waiting) @@ -882,7 +882,7 @@ export class Engine { grid(): Cells { const out = this.out const n = this.columns * this.rows - const level = Math.max(0, Math.min(10, Math.round(this.strength))) + const level = clamp(Math.round(this.strength), 0, 10) if (level <= 0 || n === 0) { for (let i = 0; i < n; i++) out.blank(i) return out diff --git a/hooks/fire-palette.ts b/hooks/fire-palette.ts index 8714a97..72985a0 100644 --- a/hooks/fire-palette.ts +++ b/hooks/fire-palette.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v61-names +// REVISION: flow-v188-dry // // Truecolor heat palettes for ember: a black-body ramp (deep red // → orange → yellow → white-hot), a blue gas pilot for strength 1, a gray @@ -7,63 +7,54 @@ // distinct color pairs. import { SMOKE_TIPS } from './fire' +import { rampStops } from './pixels' import type { Tint } from './styles' -type Stop = [t: number, rgb: [number, number, number]] +/** A ramp's stop: where along it (0..1), and its color (0xRRGGBB). */ +type Stop = readonly [t: number, rgb: number] const BLACKBODY: Stop[] = [ - [0, [48, 6, 3]], - [0.18, [118, 16, 5]], - [0.38, [204, 48, 8]], - [0.58, [246, 124, 20]], - [0.78, [255, 204, 72]], - [1, [255, 249, 218]], + [0, 0x300603], + [0.18, 0x761005], + [0.38, 0xcc3008], + [0.58, 0xf67c14], + [0.78, 0xffcc48], + [1, 0xfff9da], ] const PILOT: Stop[] = [ - [0, [10, 18, 74]], - [0.5, [34, 88, 226]], - [1, [172, 216, 255]], + [0, 0x0a124a], + [0.5, 0x2258e2], + [1, 0xacd8ff], ] const BLUE_CORE: Stop[] = [ - [0, [48, 6, 3]], - [0.3, [150, 26, 8]], - [0.55, [238, 112, 22]], - [0.75, [124, 172, 255]], - [1, [228, 242, 255]], + [0, 0x300603], + [0.3, 0x961a08], + [0.55, 0xee7016], + [0.75, 0x7cacff], + [1, 0xe4f2ff], ] const SMOKE: Stop[] = [ - [0, [44, 44, 46]], - [1, [196, 196, 200]], + [0, 0x2c2c2e], + [1, 0xc4c4c8], ] const STEPS = 48 -function ramp(stops: Stop[], t: number): number { - const x = Math.min(1, Math.max(0, t)) - let i = 1 - while (i < stops.length - 1 && stops[i]![0] < x) i++ - const [t0, a] = stops[i - 1]! - const [t1, b] = stops[i]! - const k = t1 === t0 ? 0 : (x - t0) / (t1 - t0) - const ch = (j: 0 | 1 | 2) => Math.round(a[j] + (b[j] - a[j]) * k) - return (ch(0) << 16) | (ch(1) << 8) | ch(2) -} - const luts = new Map() function lut(stops: Stop[]): Uint32Array { let l = luts.get(stops) if (!l) { l = new Uint32Array(STEPS + 1) - for (let i = 0; i <= STEPS; i++) l[i] = ramp(stops, i / STEPS) + for (let i = 0; i <= STEPS; i++) l[i] = rampStops(stops, i / STEPS) luts.set(stops, l) } return l } const WISP_SMOKE: Stop[] = [ - [0, [46, 44, 46]], - [1, [128, 122, 120]], + [0, 0x2e2c2e], + [1, 0x807a78], ] /** Drifting smoke, `t` 0 (nearly gone) .. 1 (just turned from a spark). */ diff --git a/hooks/fire.ts b/hooks/fire.ts index 3422a77..135f029 100644 --- a/hooks/fire.ts +++ b/hooks/fire.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v143-fire-crew +// REVISION: flow-v188-dry // // A tiny Doom-fire–style cellular-automata fire, by Rob Macrae. // @@ -206,11 +206,11 @@ export class AsciiFire { } // Propagate upward with strong random cooling + wind drift (-1, 0, +1); // rising into a headroom row cools harder, so only the tallest get there. - const isTall = h > BODY_ROWS + 1 - const headroom = isTall ? 1 : Math.max(0, h - BODY_ROWS) + const stretched = h > BODY_ROWS + 1 + const headroom = stretched ? 1 : Math.max(0, h - BODY_ROWS) // Cooling per row scales with the body's height: a 30-row spine burns // about as tall, relatively, as the 4-row band. - const stretch = isTall ? Math.min(1, (BODY_ROWS / (h - 1)) * 1.8) : 1 + const stretch = stretched ? Math.min(1, (BODY_ROWS / (h - 1)) * 1.8) : 1 for (let x = 0; x < w; x++) { for (let y = 1; y < h; y++) { const src = y * w + x @@ -220,7 +220,7 @@ export class AsciiFire { } else { const extra = y - 1 < headroom ? HEADROOM_COOL_MIN + (this.rng.int() % HEADROOM_COOL) : 0 const roll = this.rng.int() % 11 - const cool = (isTall ? Math.round(roll * stretch) : roll) + extra + const cool = (stretched ? Math.round(roll * stretch) : roll) + extra const nx = x + (1 - (this.rng.int() % 3)) if (nx >= 0 && nx < w) this.cells[(y - 1) * w + nx] = this.shut[nx] ? 0 : Math.max(0, p - cool) } diff --git a/hooks/pixels.ts b/hooks/pixels.ts index 4b91adc..cc66dc9 100644 --- a/hooks/pixels.ts +++ b/hooks/pixels.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v170-dry-scenes +// REVISION: flow-v188-dry // // Small pieces the scene renderers share: packed-RGB color math and ramps, // easing, hashes and noise, glyph tables, and the fit of a cell's four @@ -27,6 +27,13 @@ export function approach(v: number, goal: number, k: number): number { return Math.abs(goal - n) < 0.01 ? goal : n } +/** A string hashed, FNV-1a (32-bit; as Math.imul leaves it, so possibly negative). */ +export function fnv1a(s: string): number { + let h = 0x811c9dc5 + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) + return h +} + /** `a` moved `k` (0..1, held there) of the way to `b`, per channel of 0xRRGGBB. */ export function mix(a: number, b: number, k: number): number { if (k <= 0) return a diff --git a/hooks/rocket.ts b/hooks/rocket.ts index c3651cc..02238b7 100644 --- a/hooks/rocket.ts +++ b/hooks/rocket.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v177-split-lamps +// REVISION: flow-v188-dry // // 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 @@ -606,7 +606,7 @@ abstract class LaunchSite extends SkyWorld { /** Step the acted-out level toward the asked one (`strength`), then fly it. */ override step(): void { - const asked = Math.max(0, Math.min(10, Math.round(this.strength))) + const asked = clamp(Math.round(this.strength), 0, 10) this.sinceStage++ if (this.host) { // A mini rocket is asked its host's level as acted out (late): it acts it out as it is. @@ -1225,7 +1225,7 @@ abstract class LaunchSite extends SkyWorld { const rest = s.mount / 2 const catchAlt = (s.armCatch - s.grip) / 2 const home = this.target <= 0 - const lv = Math.max(0, Math.min(10, Math.round(this.strength))) + const lv = clamp(Math.round(this.strength), 0, 10) const want = LAYER[lv]! if (this.fresh) { this.fresh = false diff --git a/hooks/sky.ts b/hooks/sky.ts index ab6cd05..2a68402 100644 --- a/hooks/sky.ts +++ b/hooks/sky.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v170-dry-scenes +// REVISION: flow-v188-dry // // The world the balloon and the rockets fly through, on the fire's dials: the // level is a target altitude, eased toward, and the world scrolls past the @@ -27,7 +27,7 @@ import { layered, snap } from './clouds/layered' import type { CloudContext } from './clouds/types' import type { Tint } from './styles' import { easeNight, MOON, MOON_ACROSS, MOON_ROW, NIGHT_HORIZON, NIGHT_ZENITH, STAR, STAR_DIM } from './night' -import { g, hash, lowerBlock, mix, rampStops } from './pixels' +import { clamp, g, hash, lowerBlock, mix, rampStops } from './pixels' import { easeWait, waitTone } from './waiting' /** @@ -167,7 +167,7 @@ export abstract class SkyWorld { /** The altitude this level aims for. */ protected get target(): number { - return ALTITUDE[Math.max(0, Math.min(10, this.strength))]! + return ALTITUDE[clamp(this.strength, 0, 10)]! } step(): void { diff --git a/hooks/sound.ts b/hooks/sound.ts index 68ea539..69f977b 100644 --- a/hooks/sound.ts +++ b/hooks/sound.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v181-doom-volleys +// REVISION: flow-v188-dry // // Soundscapes. Claude Code's `$.audio.play` plays a clip (macOS `afplay`) at a // gain set when it starts; it can't loop smoothly or change a clip as it @@ -26,6 +26,7 @@ // Pure: no engine imports, unit-tested directly. import { Rng, toBase64 } from './cells' +import { fnv1a } from './pixels' import { BED_GAINS } from './sound-files' import type { Tint } from './styles' @@ -418,8 +419,7 @@ const CLIP_HASH = new Map() function clipHash(clip: string): number { let h = CLIP_HASH.get(clip) if (h === undefined) { - h = 2166136261 - for (let i = 0; i < clip.length; i++) h = Math.imul(h ^ clip.charCodeAt(i), 16777619) + h = fnv1a(clip) CLIP_HASH.set(clip, h) } return h diff --git a/hooks/starfield.ts b/hooks/starfield.ts index 7b98e5f..e3ed14c 100644 --- a/hooks/starfield.ts +++ b/hooks/starfield.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v125-waiting +// REVISION: flow-v188-dry // // Warp (the `warp` style): a starfield on the same dials as the fire, the // level is the ship's speed. @@ -12,7 +12,7 @@ // stop and hold, breathing in sepia (waiting.ts). import { Cells, Rng } from './cells' -import { BRAILLE } from './pixels' +import { BRAILLE, clamp } from './pixels' import { params } from './fire' import type { Tint } from './styles' import { defineScene } from './scene-def' @@ -130,7 +130,7 @@ export class Starfield { // Fewer wanted: they go a few a frame, not all at once (none while it holds for the person); off is off at once. if (want === 0) this.stars.length = 0 else if (this.stars.length > want && this.kWait === 0) this.stars.length = Math.max(want, this.stars.length - 2) - const speed = SPEED[Math.max(0, Math.min(10, this.strength))]! * (1 - this.kWait) + const speed = SPEED[clamp(this.strength, 0, 10)]! * (1 - this.kWait) for (let i = 0; i < this.stars.length; i++) { const s = this.stars[i]! s.z -= speed @@ -152,7 +152,7 @@ export class Starfield { const { bits, bright } = this bits.fill(0) bright.fill(0) - const level = Math.max(0, Math.min(10, this.strength)) + const level = clamp(this.strength, 0, 10) const speed = SPEED[level]! const trail = level >= STREAK_FROM ? (level - STREAK_FROM + 1) * 1.4 : 0 for (const s of this.stars) { diff --git a/hooks/styles.ts b/hooks/styles.ts index aa441f4..8f62197 100644 --- a/hooks/styles.ts +++ b/hooks/styles.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v175-doom3d +// REVISION: flow-v188-dry // // The scenes, all driven by the same dials (strength 0..10, coverage boost, // the agents one by one, tint, night, waiting): SCENES, the one list of them (each scene file exports its @@ -16,7 +16,7 @@ import type { AgentDial } from './agents' import { Crew, failed, type AgentMark, type Mate } from './crew' import { AsciiFire, colorFor, glyphFor, params, SMOKE_TIPS } from './fire' -import { Cells, freshSeed, Rng } from './cells' +import { Cells, freshSeed, isTall, Rng } from './cells' export type { Cells } import { heatColor, smokeColor } from './fire-palette' import { BRAILLE, clamp, hash1, lowerBlock, mix, rampAt, smooth } from './pixels' @@ -226,7 +226,7 @@ class Ember implements Scene { } private get tall(): boolean { - return this.rows > 8 + return isTall(this.columns, this.rows) } /** A companion fire's width and the gap beside it, in columns, for this layout. */ From 1a428fb8088c2714f3727ef45b3574af67f9f9a0 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Thu, 15 Oct 2026 19:18:08 +0100 Subject: [PATCH 4/4] =?UTF-8?q?Scripts=20show=20and=20measure=20a=20scene?= =?UTF-8?q?=20at=20one=20band=20and=20spine=20(scene-lab's=20BAND=20and=20?= =?UTF-8?q?SPINE,=20the=20spine=20now=2022=C3=9750=20as=20cvd=20and=20the?= =?UTF-8?q?=20sounds'=20moods=20had=20it):=20cvd=20and=20make-sounds=20tak?= =?UTF-8?q?e=20them=20from=20there,=20so=20their=20results=20are=20as=20be?= =?UTF-8?q?fore;=20preview's=20spine=20is=2050=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01KqZeF1bhJidhjtVDZGBLgJ --- scripts/cvd-check.ts | 9 +++------ scripts/make-sounds.ts | 7 ++++--- scripts/scene-lab.ts | 5 +++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/scripts/cvd-check.ts b/scripts/cvd-check.ts index eff2920..3aa186d 100644 --- a/scripts/cvd-check.ts +++ b/scripts/cvd-check.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v188-dry // // Whether the tints read for colour-blind eyes. Every scene runs at levels 1, // 5 and 10, in the band (120 × 5) and the spine (22 × 50), by day and (for a @@ -50,7 +50,7 @@ import { join } from 'node:path' import type { Cells } from '../hooks/cells' import type { SceneName, Tint } from '../hooks/styles' import { encodePng, gridPixels } from '../hooks/svg' -import { type Backdrop, backdropsOf, build, nightsOf, option, scenesFrom, TINTS } from './scene-lab' +import { BAND, type Backdrop, backdropsOf, build, nightsOf, option, scenesFrom, SPINE, TINTS } from './scene-lab' const argv = process.argv.slice(2) const sheetsDir = option(argv, '--sheets') @@ -61,10 +61,7 @@ const names = argv.filter((a, i) => !a.startsWith('--') && argv[i - 1] !== '--sh /** The terminal behind the scene. */ const BG = light ? 0xfafafa : 0x1e1e1e -const LAYOUTS = [ - { name: 'band', columns: 120, rows: 5 }, - { name: 'spine', columns: 22, rows: 50 }, -] as const +const LAYOUTS = [BAND, SPINE] const LEVELS = [1, 5, 10] /** Frames untinted before the tint comes on: long enough for the level to settle. */ const WARM = 150 diff --git a/scripts/make-sounds.ts b/scripts/make-sounds.ts index 9a46c57..c91b02b 100644 --- a/scripts/make-sounds.ts +++ b/scripts/make-sounds.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v185-clips +// REVISION: flow-v188-dry // // Builds the soundscapes' clips into sounds/ (AAC, mono 22.05 kHz) and the // manifest hooks/sound-files.ts. Each recipe makes a WAV with sox (and Node, @@ -19,6 +19,7 @@ import { join } from 'node:path' import { BED_FADE_MS, BED_MS, BED_TAKES, type BedMood, LAYERS, layerGain, master, MOODS } from '../hooks/sound' import { makeScene } from '../hooks/styles' import { listClips } from './clips' +import { BAND, SPINE } from './scene-lab' const ROOT = join(import.meta.dirname, '..') const OUT = join(ROOT, 'sounds') @@ -1222,9 +1223,9 @@ function encode(from: string, to: string, rate: string): void { function moodsOf(scene: string): BedMood[] { const spec = MOODS[scene]! const seen = new Map() - for (const [cols, rows] of [[120, 5], [22, 50]] as const) { + for (const { columns, rows } of [BAND, SPINE]) { const f = makeScene(scene as never, 11) - f.ensure(cols, rows) + f.ensure(columns, rows) const plan = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 7, 5, 3, 1, 10, 1, 6, 10, 4, 1] for (let round = 0; round < 2; round++) for (const level of plan) diff --git a/scripts/scene-lab.ts b/scripts/scene-lab.ts index cbfc574..7fac87b 100644 --- a/scripts/scene-lab.ts +++ b/scripts/scene-lab.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v188-dry // // Shared by preview, check and cvd-check: build a scene at a size and a // setting, warm it up (with subagents' companions in every state, if asked; @@ -14,8 +14,9 @@ import type { Cells } from '../hooks/cells' export const TINTS: Tint[] = ['normal', 'smoke', 'blue'] /** Every look a scene shows over its level: each tint, and waiting on the person. */ export const LOOKS: { name: string; tint: Tint; waiting?: boolean }[] = [...TINTS.map(tint => ({ name: tint, tint })), { name: 'waiting', tint: 'normal', waiting: true }] +/** The band and the spine every script shows and measures a scene at (check times many sizes besides). */ export const BAND = { name: 'band', columns: 120, rows: 5 } -export const SPINE = { name: 'spine', columns: 22, rows: 40 } +export const SPINE = { name: 'spine', columns: 22, rows: 50 } /** * What a scene shows behind it that no setting chooses, and its time alone