diff --git a/.gitignore b/.gitignore index babd15a..3cc1b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# macOS Finder's folder settings +.DS_Store + # Written by the Claude Code engine on each load .claude-plugin/types/ diff --git a/AGENTS.md b/AGENTS.md index acb1ef4..62b51cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ opencode/ settings in its storage, the soundscape) mapping.ts opencode's events in the heat model's terms, its subagents (child sessions) on the roster (pure) view.ts the scene as an OpenTUI renderable painting its cells, on the running opencode's own classes + colors.ts the view's colours, made once while in use and bounded (pure) types.ts opentui.d.ts what Flow uses of opencode's and OpenTUI's API, declared (nothing installed) node/ player.ts the soundscape's player where the host has none (opencode, pi): afplay run in the background diff --git a/hooks/surf.ts b/hooks/surf.ts index 069ddc9..432c057 100644 --- a/hooks/surf.ts +++ b/hooks/surf.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v173-directory +// REVISION: flow-v187-surf-hood // // Surf (the `surf` style): a surfer and the ocean on the same dials as the // fire; the level is the swell. At 1 the sea is glassy under a dawn sky and @@ -138,6 +138,8 @@ const NIGHT: Palette = { const NIGHT_SHADE = 0x0a1428 /** Moonlight caught along the top of the water by night: the line between sea and sky. */ const MOON_RIM = 0x4a7c94 +/** What the barrel (its lip, its tube) dulls toward under smoke: a grey sea in overcast light. */ +const SMOKE_LIP = 0x5d6466 /** The band's sea by night: one even teal under the black sky (darkening a little with depth). */ const BAND_SEA = 0x0c3448 @@ -767,7 +769,10 @@ export class Surf { // Water. let wet = clamp(y + 1 - surf) let inTube = false - let lip = 0 + let onLip = false + // Water the crest throws forward over the barrel (above the face's own + // surface): the back of the wave curling over, so shaded as the back. + let hooded = false if (lip > 0 && isMain && Math.abs(xc - this.ex) < this.rx + 1 && yc < seaY + 1) { const dx = (xc - this.ex) / this.rx const dy = (yc - this.ey) / this.ry @@ -779,14 +784,23 @@ export class Surf { const sa = Math.sin((phi * Math.PI) / 180) const R = Math.hypot(this.rx * ca, 2 * this.ry * sa) const th = ((vertical ? 5 : 2.6) * (1 - 0.45 * prog) * (0.6 + 0.4 * lip)) / R - if (phi <= this.phiEnd && r > 1 - th && r < 1 && (yc < surf || phi > -140)) lip = 1 + if (phi <= this.phiEnd && r > 1 - th && r < 1 && (yc < surf || phi > -140)) onLip = true else if (yc < surf && r < 1) inTube = true - } else if (yc >= hood) wet = 1 - } else if (yc >= hood && yc < surf) wet = clamp(y + 1 - hood) - if (lip) { + } else if (yc >= hood) { + wet = 1 + hooded = yc < surf + } + } else if (yc >= hood && yc < surf) { + wet = clamp(y + 1 - hood) + hooded = true + } + if (onLip) { // The lip: lit through, pale green-blue; frothing at the tip. const dyc = (yc - this.ey) / this.ry c = mix(P.faceLip, P.foam, clamp(0.25 + dyc * 0.5) * 0.7) + // Under smoke the barrel dulls further than the sea's greying alone: + // pale and bright, half-greyed it keeps its lightness (tritan eyes lose it). + if (this.kGrey > 0) c = mix(c, SMOKE_LIP, 0.6 * this.kGrey) if (bandNight > 0.01) c = mix(c, mix(BAND_SEA, MOON_RIM, 0.45), 0.7 * bandNight) this.water[k] = 1 } else if (inTube) { @@ -796,6 +810,7 @@ export class Surf { // the lip, close to the face's own color toward the mouth. const lit = clamp((dx + dy) * 0.4 + 0.55) c = mix(mix(P.tube, P.seaTop, 0.4), mix(P.seaTop, P.faceLip, 0.5), lit * 0.8) + if (this.kGrey > 0) c = mix(c, SMOKE_LIP, 0.6 * this.kGrey) if (bandNight > 0.01) c = mix(c, mix(BAND_SEA, P.seaDeep, 0.3), 0.7 * bandNight) this.water[k] = 0 } else if (wet > 0) { @@ -808,7 +823,7 @@ export class Surf { // crest, so the face never starts as a hard-edged dark block. const backC = mix(P.seaTop, P.back, clamp(hf)) const faceC = mix(mix(P.faceDeep, P.seaTop, 0.45), P.faceLip, Math.pow(clamp(hf), 1.25)) - const fk = clamp((xc - cx) / (vertical ? Math.max(4, this.Wf * 0.45) : 5) + 0.35) + const fk = hooded ? 0 : clamp((xc - cx) / (vertical ? Math.max(4, this.Wf * 0.45) : 5) + 0.35) wc = mix(backC, faceC, fk) // The barrel's shadow fades out ahead of it rather than stopping on a line. if (lip > 0) { @@ -823,7 +838,8 @@ export class Surf { const even = mix(BAND_SEA, P.seaDeep, clamp((yc - surf) / Math.max(2, ph - surf)) * 0.35) wc = mix(wc, even, 0.85 * bandNight) } - const depth = yc - surf + // (The hood's surface is its own top edge, not the face's below it.) + const depth = hooded ? yc - hood : yc - surf if (depth < 1) wc = mix(wc, P.foamShade, hump > 0.4 && hf > 0.5 ? 0.18 + 0.4 * clamp(hf - 0.5) : 0.18 * (1 - kNight)) // Moonlight along the crests, strongest on the tallest faces; on a calm // sea only a faint line, or the swell reads as dark blocks on a bright stripe. diff --git a/opencode/colors.ts b/opencode/colors.ts new file mode 100644 index 0000000..6f15bc4 --- /dev/null +++ b/opencode/colors.ts @@ -0,0 +1,24 @@ +// REVISION: flow-v1-color-cache +// +// The view's colours as OpenTUI has them, made once while in use (pure: the +// view hands it how to make one, from the running opencode's own RGBA). + +/** How many colours `colorCache` keeps before it starts afresh. */ +export const COLOR_CACHE_MAX = 4096 + +/** + * Each colour as OpenTUI has it, made once and kept while it's in use. Most frames repeat the last one's + * colours, but waiting's breath, the night and a tint easing in make new ones every frame, without end + * (earthrise reaches tens of thousands in a quarter of an hour), so past `max` it starts afresh. + */ +export function colorCache(make: (c: number) => T, max = COLOR_CACHE_MAX): (c: number) => T { + const kept = new Map() + return c => { + let v = kept.get(c) + if (v === undefined) { + if (kept.size >= max) kept.clear() + kept.set(c, (v = make(c))) + } + return v + } +} diff --git a/opencode/mapping.ts b/opencode/mapping.ts index 7ab98e4..6a57b7c 100644 --- a/opencode/mapping.ts +++ b/opencode/mapping.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v3-opencode-deleted +// REVISION: flow-v4-hygiene // // opencode's events in the heat model's terms, for the session on screen and // the subagents it runs (child sessions, each with its `parentID`). Pure (no @@ -177,6 +177,12 @@ export class OpenCodeWork { return } case 'session.deleted': { + // What's kept of any session goes with it: these hear the whole server, every session it ever makes. + if (typeof data.sessionID === 'string') { + this.parents.delete(data.sessionID) + this.used.delete(data.sessionID) + this.variants.delete(data.sessionID) + } // A subagent's session deleted before its run ended (else it would stay working, quiet, for good). const child = typeof data.sessionID === 'string' ? this.children.get(data.sessionID) : undefined if (child?.status !== 'running') return diff --git a/opencode/tui.ts b/opencode/tui.ts index 79978b1..9e5c842 100644 --- a/opencode/tui.ts +++ b/opencode/tui.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v5-shared-player +// REVISION: flow-v6-hygiene // // 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 @@ -31,7 +31,8 @@ import { staleSessions } from '../hooks/sessions' import { firstTips, type FlowConfig, type Host, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type Tips, versionNote } from '../hooks/settings' import { SceneDriver } from '../hooks/scene' import { Soundscape } from '../hooks/soundscape' -import { SCENES } from '../hooks/styles' +import { type Scene, SCENES } from '../hooks/styles' +import type { Cells } from '../hooks/cells' import { FLOW_ENTRY } from '../pi/mapping' import { PiSettings, type SessionEntries } from '../pi/session' import { OpenCodeWork } from './mapping' @@ -157,12 +158,20 @@ function setup(ctx: OcContext): () => void { return driver.scene.taller ? Math.max(ROWS, Math.min(TALL_ROWS, Math.floor(terminal * TALL_SHARE))) : ROWS } - /** The scene at a view's size, stepped by the frame loop: the view only draws it. */ + /** + * 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. + */ + let made: { scene: Scene; cells: Cells } | undefined const frameAt = (columns: number, rows: number) => { const f = driver.scene if (!driver.isShown()) return undefined + if (made?.scene === f && made.cells.columns === columns && made.cells.rows === rows) return made.cells f.ensure(columns, rows) - return f.grid() + made = { scene: f, cells: f.grid() } + return made.cells } /** * A press on the scene: a companion there (or a cell beside it: they're small) shows its subagent's card, as @@ -199,8 +208,14 @@ function setup(ctx: OcContext): () => void { /** A session has shown since Flow started: the first is a chance for a tip. */ let tipChanceTaken = false const follow = () => { - const id = shownSession?.id() - if (!id) return + // A session shows while one of its views is up: back on the home screen they're gone, and there's none. + const up = (v: SceneView | undefined) => v !== undefined && !v.isDestroyed + const id = up(views.band) || up(views.spine) ? shownSession?.id() : undefined + if (!id) { + if (opened !== undefined) work.show(undefined) + opened = openedAt = undefined + return + } if (!tipChanceTaken) { tipChanceTaken = true setTimeout(() => { @@ -257,6 +272,7 @@ function setup(ctx: OcContext): () => void { if (live) { f.ensure(live.width, live.height) f.step() + made = undefined live.requestRender() } sound.step(elapsed, { diff --git a/opencode/view.ts b/opencode/view.ts index 7eecd7e..d8d02c9 100644 --- a/opencode/view.ts +++ b/opencode/view.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v2-opencode-cards +// REVISION: flow-v3-color-cache // // A scene in opencode's terminal UI: an OpenTUI renderable as wide as its // place (above the prompt, or the sidebar) and as tall as Flow asks, that @@ -15,6 +15,7 @@ import type { OptimizedBuffer, RenderContext, Renderable, RGBA } from '@opentui/core' import { type Cells, DEFAULT_COLOR } from '../hooks/cells' +import { colorCache } from './colors' /** A scene's frame at a size, or none to draw nothing. */ export type FrameAt = (columns: number, rows: number) => Cells | undefined @@ -44,14 +45,7 @@ export function sceneViewClass(root: Renderable, theme: unknown): SceneViewClass const Rgba = rgbaIn(theme) if (!Rgba) throw new Error("flow: opencode's theme has no colours to draw with") const clear = Rgba.fromInts(0, 0, 0, 0) - /** Each colour as OpenTUI has it, made once (a scene uses a few hundred at most). */ - const colors = new Map() - const color = (c: number): RGBA => { - if (c === DEFAULT_COLOR) return clear - let rgba = colors.get(c) - if (!rgba) colors.set(c, (rgba = Rgba.fromInts((c >> 16) & 255, (c >> 8) & 255, c & 255, 255))) - return rgba - } + const color = colorCache(c => (c === DEFAULT_COLOR ? clear : Rgba.fromInts((c >> 16) & 255, (c >> 8) & 255, c & 255, 255))) return class extends Base { constructor( diff --git a/pi/index.ts b/pi/index.ts index e58d533..b357eec 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v177-pi-sound +// REVISION: flow-v185-pi-reload // // 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 @@ -418,8 +418,13 @@ export default function flow(pi: PiApi) { if (timer) clearTimeout(timer) timer = undefined } + // pi quitting: the clips playing stop with it (else each would play out, up to a bed's 14 s). Held only while a + // session is: /reload loads Flow afresh in the same process, so a listener left behind would keep the old one. + const quitting = () => sound.stop() pi.on('session_start', async (_e, ctx) => { + process.off('exit', quitting) + process.once('exit', quitting) // A new session starts on the defaults; a resumed, forked or reloaded one on its own over them. (In every // mode: `/flow` runs in any, and compares with the defaults as they're kept.) await settings.open(entriesOf(ctx)) @@ -456,6 +461,9 @@ export default function flow(pi: PiApi) { pi.on('session_shutdown', (_e, ctx) => { stop() sound.stop() + process.off('exit', quitting) + // A picture being made a slice at a time stops with it. + making = undefined if (isMounted && ctx.hasUI) ctx.ui.setWidget(KEY, undefined) isMounted = false ctxRef = undefined @@ -570,8 +578,6 @@ export default function flow(pi: PiApi) { // (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) } - // pi quitting: the clips playing stop with it (else each would play out, up to a bed's 14 s). - process.once('exit', () => sound.stop()) pi.registerCommand('flow', { description: diff --git a/scripts/check.ts b/scripts/check.ts index a17c6a1..b8ad519 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v174-taller-scenes +// REVISION: flow-v185-clips // // 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/, @@ -13,10 +13,10 @@ // npm run check -- surf ski just these // FLOW_MAX_MS=6 npm run check a looser time budget, as CI runs it -import { readdirSync } from 'node:fs' import { join } from 'node:path' import { SOUND_FILES } from '../hooks/sound-files' import { makeScene } from '../hooks/styles' +import { listClips } from './clips' import { syncManifest } from './sync-manifest' import { backdropsOf, build, LOOKS, nightsOf, pairs, scenesFrom } from './scene-lab' @@ -50,9 +50,7 @@ if (syncManifest(false)) bad('plugin.json is out of step with SCENES: run `npm r // 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 = readdirSync(SOUNDS, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .flatMap(d => readdirSync(join(SOUNDS, d.name)).filter(f => !f.startsWith('.')).map(f => `sounds/${d.name}/${f}`)) +const clips = listClips(SOUNDS) const named = new Set(SOUND_FILES) const unnamed = clips.filter(f => !named.has(f)) const gone = SOUND_FILES.filter(f => !clips.includes(f)) diff --git a/scripts/clips.ts b/scripts/clips.ts new file mode 100644 index 0000000..4635997 --- /dev/null +++ b/scripts/clips.ts @@ -0,0 +1,17 @@ +// REVISION: flow-v185-clips +// +// The clips in sounds/, as make-sounds writes them into hooks/sound-files.ts +// and check compares them with it: each scene's folder's files, sorted; +// anything else there (Finder's .DS_Store, a stray file) left out. Not part +// of the mod (Node). + +import { readdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Every clip in `dir` (sounds/), as the manifest names them: `sounds//`. */ +export function listClips(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }) + .filter(d => d.isDirectory() && !d.name.startsWith('.')) + .flatMap(d => readdirSync(join(dir, d.name)).filter(f => !f.startsWith('.')).map(f => `sounds/${d.name}/${f}`)) + .sort() +} diff --git a/scripts/make-sounds.ts b/scripts/make-sounds.ts index b34ff98..9a46c57 100644 --- a/scripts/make-sounds.ts +++ b/scripts/make-sounds.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v181-doom-volleys +// REVISION: flow-v185-clips // // 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, @@ -18,6 +18,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync 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' const ROOT = join(import.meta.dirname, '..') const OUT = join(ROOT, 'sounds') @@ -1291,9 +1292,7 @@ for (const scene of Object.keys(LAYERS)) { writeFileSync(gainsFile, JSON.stringify(gains, null, 1)) // The manifest: every clip there is, and each mood's gain. -const files = readdirSync(OUT) - .flatMap(folder => readdirSync(join(OUT, folder)).map(f => `sounds/${folder}/${f}`)) - .sort() +const files = listClips(OUT) const sorted = Object.keys(gains).sort() writeFileSync( join(ROOT, 'hooks', 'sound-files.ts'), diff --git a/tests/flow.test.ts b/tests/flow.test.ts index ffc879c..a4dc0e7 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v183-pi-sound +// REVISION: flow-v185-hygiene-bugs import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' @@ -36,6 +36,7 @@ import { rampAt, rampStops, retain } from '../hooks/pixels' import { freshSeed } from '../hooks/cells' import { type Player, Soundscape } from '../hooks/soundscape' import { effortOf as ocEffortOf, linesWritten as ocLinesWritten, OpenCodeWork } from '../opencode/mapping' +import { colorCache } from '../opencode/colors' /** A test that walks every scene (each at several sizes or levels): a shared CI runner can take several times a laptop's 5 s default. */ const SLOW_MS = 30_000 @@ -4475,6 +4476,23 @@ test("opencode: a subagent's session deleted before its run ends is gone, not le expect(a.roster.dials()[0]!.ok).toBe(false) }) +test("opencode: each colour is made once while in use, but what's kept stays bounded however many a scene goes through", async () => { + let made = 0 + const color = colorCache(c => ({ c, n: made++ }), 8) + const first = color(0x102030) + expect(color(0x102030)).toBe(first) + expect(made).toBe(1) + // A breathing wait: a new colour every frame, without end. What's kept never passes the bound. + for (let c = 0; c < 1000; c++) color(c) + expect(made).toBe(1001) + const again = color(999) + expect(made).toBe(1001) + expect(again.c).toBe(999) + // The first went long ago (it started afresh past the bound): made again. + expect(color(0x102030)).not.toBe(first) + expect(made).toBe(1002) +}) + test('a host older than Flow is built against is told so (it still runs); as new, newer or unreadable, nothing', async () => { expect(olderThan('2.1.280', '2.1.289')).toBe(true) expect(olderThan('2.1.289', '2.1.289')).toBe(false)