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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 4 additions & 6 deletions hooks/crew.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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++
Expand Down
8 changes: 4 additions & 4 deletions hooks/engine.ts
Original file line number Diff line number Diff line change
@@ -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,
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, 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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
55 changes: 23 additions & 32 deletions hooks/fire-palette.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Stop[], Uint32Array>()

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). */
Expand Down
10 changes: 5 additions & 5 deletions hooks/fire.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// REVISION: flow-v143-fire-crew
// REVISION: flow-v188-dry
//
// A tiny Doom-fire–style cellular-automata fire, by Rob Macrae.
//
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
9 changes: 8 additions & 1 deletion hooks/pixels.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions hooks/register.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -52,14 +52,14 @@ import type { AgentMark } from './crew'
import { FRAME_MS, SceneDriver } from './scene'
import {
CLAUDE_CODE,
clockAt,
firstTips,
ownHint,
parseFlowArgs,
readConfig,
replyTo,
resetText,
savedText,
storedValue,
type FlowCommand,
type FlowConfig,
type FlowLayout,
Expand Down Expand Up @@ -507,9 +507,7 @@ export async function planSave($: EngineInterface, ctx: SceneCtx): Promise<SaveP
await refreshDefaults($, ctx)
const s = ctx.session
const changes = differences(ctx.driver.cfg, s.defaults)
const rows = (Object.entries(changes) as [keyof FlowConfig, FlowConfig[keyof FlowConfig]][]).map(
([field, value]) => [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 }
}

Expand Down Expand Up @@ -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 = () => {
Expand Down
6 changes: 3 additions & 3 deletions hooks/rocket.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion hooks/settings.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -410,6 +416,20 @@ export function changesFor(cmd: FlowCommand, cfg: FlowConfig): Partial<FlowConfi
/** A `/flow` reply: its text, and how pi shows it (a warning: a command not taken, a save that failed). */
export type FlowReply = { text: string; level?: 'info' | 'warning' }

/** `/flow sound` where there's no player (opencode, pi: afplay, which they run themselves, is macOS's). */
export const NO_PLAYER = 'the soundscapes play through afplay, which only macOS has'

/** A tip or a note without its `flow: `, where the host shows whose it is already (pi's notice, opencode's toast title). */
export const untitled = (text: string): string => 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
Expand Down
6 changes: 3 additions & 3 deletions hooks/sky.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions hooks/sound.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -418,8 +419,7 @@ const CLIP_HASH = new Map<string, number>()
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
Expand Down
Loading
Loading