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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# macOS Finder's folder settings
.DS_Store

# Written by the Claude Code engine on each load
.claude-plugin/types/

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 24 additions & 8 deletions hooks/surf.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions opencode/colors.ts
Original file line number Diff line number Diff line change
@@ -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<T>(make: (c: number) => T, max = COLOR_CACHE_MAX): (c: number) => T {
const kept = new Map<number, T>()
return c => {
let v = kept.get(c)
if (v === undefined) {
if (kept.size >= max) kept.clear()
kept.set(c, (v = make(c)))
}
return v
}
}
8 changes: 7 additions & 1 deletion opencode/mapping.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 22 additions & 6 deletions opencode/tui.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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, {
Expand Down
12 changes: 3 additions & 9 deletions opencode/view.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<number, RGBA>()
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(
Expand Down
12 changes: 9 additions & 3 deletions pi/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 3 additions & 5 deletions scripts/check.ts
Original file line number Diff line number Diff line change
@@ -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/,
Expand All @@ -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'

Expand Down Expand Up @@ -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))
Expand Down
17 changes: 17 additions & 0 deletions scripts/clips.ts
Original file line number Diff line number Diff line change
@@ -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/<folder>/<file>`. */
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()
}
7 changes: 3 additions & 4 deletions scripts/make-sounds.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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')
Expand Down Expand Up @@ -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'),
Expand Down
20 changes: 19 additions & 1 deletion tests/flow.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading