diff --git a/AGENTS.md b/AGENTS.md index fdabbda..acb1ef4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,8 +42,10 @@ 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 - player.ts the soundscape's player: afplay run in the background (macOS) 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 + (macOS). Node-only, shared by those two adapters, never by the Claude Code mod pi/ index.ts pi adapter (widget above the editor, /flow, ~/.pi/agent/flow.json) picture.ts a scene's `picture` as a kitty image: whether the terminal draws them, its size, the escape sequence @@ -71,7 +73,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - `tint`: `'smoke'` (a failed command or compaction) or `'blue'` (context nearly full). Must be clearly visible in every scene, at every level, in both layouts, and to colour-blind eyes: move lightness or blue-yellow, not only red-green (white to cyan, say, is a change of red alone, and red-green colour blindness can't see it). `npm run cvd` measures it. - `night`: for scenes with a night version. Ease a `kNight` toward it with `easeNight` rather than switching in one frame. Use `night.ts` so every night matches, and falls at the same pace. - `waiting`: Claude waits on the person (a dialog is up for them: a permission, its question, a plan to approve; auto mode only, never in pi). Any loop's dialog counts (a background subagent's too); the main turn's 30 s clock stops for the main loop's own waits and for a dialog up, not for a subagent's ask the mode is still deciding, and the main turn ending forgets only its own. A wait is its session's: the adapter forgets them when the session ends, so a /clear or a resume never carries one over. The level drops to a calm 2 by itself (`WAIT_LEVEL`). Every scene eases a `kWait` toward it (`easeWait`) and ends `grid()` with `waitTone(out, kWait, t)`: the frame turns sepia and breathes, the same in every scene, so it reads at a glance and never like smoke or blue (`PixelScene` does both; `d.wait` says how far). Then settle and hold your own way, never as if working: a place the level stands for holds (the balloon hovers, a rocket keeps its stage and doesn't launch), what moves comes to rest where that's natural (the skier stops, the engine runs down, the stars stop, the train draws up at a signal or a platform). A light can shine through the sepia, only breathing (`waitTone`'s `lamps`; in a `PixelScene`, paint it with `px.lamp`), as the train's signals do. A warm scene can skip the sepia (`waitTone(..., 0)`) but must breathe, as the fire's coals do. -- **Sound** (`hooks/sound.ts`, see *Soundscapes* below): a scene's bed is layers of clips (`LAYERS`) at gains from the level and its `ambience()`, mixed ahead for each of its moods (`MOODS`); its events (`sounds`, an array the scene pushes `{ kind, v }` onto as things happen on screen, through `hear`, which caps it, so an adapter that never takes them stays bounded; pi takes and drops them each frame) play a clip (`EVENTS`) or, small and dense ones, a synthesized burst (`VOICES`). A scene without layers is silent. +- **Sound** (`hooks/sound.ts`, see *Soundscapes* below): a scene's bed is layers of clips (`LAYERS`) at gains from the level and its `ambience()`, mixed ahead for each of its moods (`MOODS`); its events (`sounds`, an array the scene pushes `{ kind, v }` onto as things happen on screen, through `hear`, which caps it, so an adapter that never takes them stays bounded; every adapter's `Soundscape` takes them each frame, heard or not) play a clip (`EVENTS`) or, small and dense ones, a synthesized burst (`VOICES`). A scene without layers is silent. - **Sizes**: the band is 5 rows × 80–250 columns, above the prompt. The spine is a pane about 13–22 columns × 30–60 rows. Every scene must look right in both. A scene with a view taller than the band's (`Scene.taller`: doom3d, the marine's own eyes) is given more above the prompt: in pi flow.json's `rows` (14 unless it says, at most half the terminal), in Claude Code as many as the prompt leaves, up to 14 (`TALL_ROWS`); `npm run check` times it at 250 × 14. It must still look right at 5 (a short terminal, the picker's thumbnail). Its first-person view, wider than tall, is a panorama (each column its own ray, about 140° across a wide band) rather than the spine's flat projection, so the far edges don't stretch. - **Drawing**: - Most scenes paint a pixel layer at 2 × 2 pixels per cell, then fold it into quadrant glyphs with `fitQuad`. A cell holds only two colors; give `fitQuad` a sprite's pixels as its `keep` so a small figure never drops out of its own cell. @@ -85,7 +87,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` ## Soundscapes -`audio.play` is macOS `afplay`: a clip at a gain fixed when it starts. `hooks/soundscape.ts` runs it a frame at a time over a `Player`, so every host plays it alike: Claude Code's is `audio.play`, opencode's (`opencode/player.ts`) runs `/usr/bin/afplay` itself, one process a clip, killed to stop it, held to the same four at once. Nothing loops or changes volume while it plays, and Claude Code plays at most four at once for a plugin (`MAX_PLAYS`): it refuses a fifth ("4 plays are going at once"), and each holds its place for its length plus almost a second (afplay starting and draining). So: +`audio.play` is macOS `afplay`: a clip at a gain fixed when it starts. `hooks/soundscape.ts` runs it a frame at a time over a `Player`, so every host plays it alike: Claude Code's is `audio.play`, opencode's and pi's (`node/player.ts`) run `/usr/bin/afplay` themselves, one process a clip, killed to stop it, held to the same four at once. pi tells an extension nothing of a wait on the person, so it never chimes. Nothing loops or changes volume while it plays, and Claude Code plays at most four at once for a plugin (`MAX_PLAYS`): it refuses a fifth ("4 plays are going at once"), and each holds its place for its length plus almost a second (afplay starting and draining). So: - **Beds**: one stream a scene. Its layers (`LAYERS`) are mixed ahead by the build into one bed for each mood the scene reaches (`MOODS`: a band of the level, the balloon's burner, a rocket's phase; the build runs each scene to find them), each at its layers' gains, limited when it would need more than 1.3 to play as loud, and its gain back to the layers' level kept in the manifest (`BED_GAINS`). Takes are 14 s (`BED_MS`) faded at both ends (3 s, `BED_FADE_MS`), the next starting as the last begins to fade, up to half a second sooner at random (`bedGap`). `bedStep` plans it a frame at a time: when the mood changes or the gain moves by more than 3 dB, a fresh take crossfades in and the old one stops once it's in (sooner when the level fell), but only once the take before has left the player (its length plus afplay's drain, `PLAYER_DRAIN_MS`), so a bed never holds more than two plays (a test checks); silent, it stops at once. A mood has three takes (`BED_TAKES`, each a different rotation of its layers' takes, the layers' takes at one loudness), never the one before again. Nothing in a bed may repeat in a rhythm: no tremolo, flanger or phaser. Use `turbulence` (a random smooth swell), `wander` (a resonance drifting at random) and `hum` (narrow resonators on noise, in place of pure tones, which cancel and beat when two takes crossfade). Rhythm comes only from the picture: the engine's chuffs follow its crank, a wave breaks when the scene throws one (at random), a ski swishes on each turn (each turn's pace drawn at random). To check, simulate a scene's mix and look for peaks in the autocorrelation of its loudness, at lags up to half a minute. - **Events**: big ones play a clip at once, every one, but no nearer than 120 ms to the last of their kind (a closer one waits that long: any nearer, two booms fuse into one), and short enough at the front that a second right behind is heard; a clip finding the player full stops the oldest event clip first (its tail, or afplay's silent drain), and a refused one tries again 50 ms on. Anything scheduled for later (a staggered event, a retry) checks it's still current when it fires: the soundscape not stopped since (`gen`), the scene and the sound setting unchanged; small dense ones (pops, cracks) are drawn in `VOICES` and gathered a second at a time into one clip (shorter, they'd fill the player), each placed at random within its frame (a frame's all at once would buzz at the frame rate) and, past `BURST_MAX`, sampled from across the window (`gather`). afplay takes about a third of a second to start (`PLAYER_LEAD_MS`): a scene that can see a moment coming sends its sound that far ahead (`leadFrames`), as the colony's rocks do, or it lands late on its picture. Seeds come from a counter: scatter them (`unit`) before they pick or seed anything, or neighbouring seeds pick in step. @@ -131,7 +133,7 @@ The engine validates the module before it runs (`claude plugin validate .`): - Only a scene's own name is ever written to `/config` (Claude Code refuses a value outside a row's `options` there). A row still holding one from an older Flow (`colony` before it was `avalon`, the dropped `river` and `lava`) reads as its default to Claude Code, which says so at each load before the module runs: in the debug log, or in the transcript while the session hot-reloads a plugin folder. `readDefaults` reads it as the scene meant (`readConfig`; the default for a scene that's gone), so a renamed scene keeps its old name as an alias. It isn't written back (only `/flow save` writes `/config`); `staleRows` finding one only tells the tips Flow was here before. (Since the rename to `flow-scenes` every row started afresh, so none holds an old name yet.) - The plugin was `flow` before it was `flow-scenes` (a directory connector has the name). Its `/config` rows, store and state are the plugin's, so they started afresh under the new name; nothing from before is read or moved. - The balloon's altitude is in `state` (the session's, surviving a reload); the one-time tips stay per user, in `store` under `tips`. -- pi keeps a session's own settings in the session: a `flow` custom entry (`pi.appendEntry`), read back from `getBranch()` at `session_start` and `session_tree`, so a resume or a fork brings them back. `~/.pi/agent/flow.json` holds the defaults, written by `/flow save`, and read afresh before every `/flow` and every 30 s, as in Claude Code (`pi/session.ts`). A pi without session entries keeps one set for every session in flow.json, as before. +- pi keeps a session's own settings in the session: a `flow` custom entry (`pi.appendEntry`), read back from `getBranch()` at `session_start` and `session_tree`, so a resume or a fork brings them back. `~/.pi/agent/flow.json` holds the defaults, written by `/flow save`, and Flow's own keys beside them (`tips`, `warned`; every write goes through `keepStored`, which carries an older `vista.json`'s or `ascii-fire.json`'s settings over on the first), and read afresh before every `/flow` and every 30 s, as in Claude Code (`pi/session.ts`). A pi without session entries keeps one set for every session in flow.json, as before. - The band and the spine's pane show only while the scene does (`SceneDriver.isShown()`): `/flow off`, or auto's dark idle between turns, gives their rows back. The pane is closed by the plugin then (so the layout stays spine; only you closing it means you'd rather have the band) and reopens with the scene. - `/flow` is the only command. Don't add aliases. - One-time tips (`nextTip` in `settings.ts`, kept in `store` under `tips`): at the first chance (a `/flow`, or an interactive session starting, as a toast) while it's still the fire and no other scene has been on, the other scenes; three chances later, if the sound has never been on, the sound (a session starting without a prompt is no chance, but notes what's on: `noteTips`). The record starts at `session.start` (`firstTips`), before that session's `/flow` can store anything: empty for someone new, both told for someone who had Flow before tips were kept (any other key in the store, such as a session's record or old overrides; a stale `/config` row; the balloon's altitude this session; or a `/config` row off the manifest's default). diff --git a/README.md b/README.md index 915691c..94b164e 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ https://github.com/user-attachments/assets/2e9e4247-8f1f-42b6-b0d1-60598e6e4957 https://github.com/user-attachments/assets/20196ad3-8dae-4e6e-99db-83fcd560b20e -`/flow pick` shows them all at once, each one live, to choose from with the arrow keys and Enter (in pi, a list). +`/flow pick` shows them all at once, each one live, to choose from with the arrow keys and Enter (in opencode and pi, a list). Some scenes also answer to other names: `inferno` and `flame` (fire), `stars` (warp), `colony` and `interstellar` (avalon), `mechanism` (engine), `rocket` (falcon), `spaceship` (starship), `ocean` and `sea` (surf), `snow` (ski), `rail` and `railway` (train). @@ -113,13 +113,15 @@ Balloon, falcon, starship, surf, ski and train also have a **night** version: st | near a full context (≥85%) | blue: a blue-white flame, a storm, dusk on the slopes, a blue gas flame, rain driving past the train, cold blue light over the moon, the techbase flooded with cold blue light | | waiting on you: a permission to grant, its question, a plan to approve | settles to a calm 2 and holds, the whole scene turned a warm sepia that breathes slowly, about every 4 s: the fire banks to a bed of glowing coals, the stars stop, the colony ship comes to rest, the balloon hovers with its pilot light glowing, the engine runs down and lets off steam, a rocket holds its stage with amber lights up the tower, the surfer sits up waiting for a wave, the skier stops, the bubbles slow, the train draws up at a red signal or a platform, its headlight and the signal glowing with each breath, the Earth stops turning, the marine stops and lowers his gun as the monsters slink back into the dark, the computer panels glowing with each breath. With sound on, a soft chime as the wait begins | +The table is in Claude Code's terms; opencode and pi move it the same way from their own events (see *opencode* and *pi* below), except that pi tells Flow nothing of subagents or of a wait on you. + A plain answer sits around 5, edit-and-test loops reach about 8, and 10 takes several subagents editing in parallel, or a long turn: it climbs a level every 30 s it keeps going. Waiting on you shows only while a dialog is actually up for you: a permission ask that auto mode's classifier settles by itself, or that a hook answers, isn't one. It ends when you answer (once the call is over, or as soon as a long command shows it's running). Like the tints, it shows in auto mode only, and only in the session that waits. ### Subagents, one by one -In fire, surf, ski, balloon, falcon, starship, engine, train, avalon, doom and doom3d, each running subagent has a companion of its own, in its own colors or its own place, so you can tell at a glance which are busy, which have gone quiet or are waiting on you, and when each finishes: +In Claude Code and opencode (pi has no subagents), in fire, surf, ski, balloon, falcon, starship, engine, train, avalon, doom and doom3d, each running subagent has a companion of its own, in its own colors or its own place, so you can tell at a glance which are busy, which have gone quiet or are waiting on you, and when each finishes: | Scene | Arrives | Working | Quiet (nothing for 20 s) | Waiting on you (a permission dialog, its question) | Done | |---|---|---|---|---|---| @@ -133,19 +135,19 @@ In fire, surf, ski, balloon, falcon, starship, engine, train, avalon, doom and d | `doom`, `doom3d` | another marine in co-op colors (indigo, brown, red, grey) runs in from behind (through his eyes, past him) and falls in with the squad | fires with him at whatever comes | drops back a little, gun lowered | a lamp blinks amber over its head | teleports out in a shimmer of green fog (failed: falls where it stood, and the squad walks on) | | `avalon` | a probe launches from a bay on the ship's hull (the hatch glowing in its color) and flies up beside it | holds station by the ship on its own flickering drive | drive off, falls back along the ship, a running light blinking | an amber beacon flashes | turns about and burns away aft, off toward home (failed: drive dead, tumbles away dark) | -A scene has room for a few (the fire 4 to 6 small fires across the band, 1 or 2 in the spine, surf 5, ski 4, balloon 4, the rockets 3 mini rockets (one of them behind the big launch site in the spine; 1 in a spine narrower than 19 columns), the engine 6 lamp groups, the train up to 4 trains, avalon 4 probes, doom 4 marines (2 in a spine narrower than 14 columns); fewer in the narrow spine or a narrow band: only as many as it shows whole); more wait for a place to come free. Warp and bubbles keep the plain company above: they're a stream of stars, a glass of anonymous bubbles, with nothing that stays put long enough to be someone. In the Claude desktop app, put the pointer over a companion to see its subagent's task, what it's doing and for how long. +A scene has room for a few (the fire 4 to 6 small fires across the band, 1 or 2 in the spine, surf 5, ski 4, balloon 4, the rockets 3 mini rockets (one of them behind the big launch site in the spine; 1 in a spine narrower than 19 columns), the engine 6 lamp groups, the train up to 4 trains, avalon 4 probes, doom 4 marines (2 in a spine narrower than 14 columns); fewer in the narrow spine or a narrow band: only as many as it shows whole); more wait for a place to come free. Warp and bubbles keep the plain company above: they're a stream of stars, a glass of anonymous bubbles, with nothing that stays put long enough to be someone. In the Claude desktop app, put the pointer over a companion to see its subagent's task, what it's doing and for how long; in opencode, click it and the same shows as a notice. ## The command `/flow` changes things at once, in the session you run it in. Each session keeps its own scene and settings, so two sessions side by side can show different scenes, and resuming one (`claude --resume`, `claude --continue`, or opening it again in the Claude desktop app) brings its settings back. A `/clear` keeps the scene you had. -A new session starts on your defaults: the rows in `/config` (*Scene mode*, *Scene*, *Scene while idle*, *Manual level*, *Scene layout*, *Day or night*, *Sound*, *Sound volume*). `/flow save` makes the current session's settings your default, and `/flow reset` puts a session back on it. Changing a row in `/config` changes the default and the session you're in. A session already running keeps what it shows when the default changes elsewhere (another session's `/flow save`, say), and `/flow` on its own says when the session differs from your default, and how. `/flow save` saves exactly what the session shows. +A new session starts on your defaults: the rows in `/config` (*Scene mode*, *Scene*, *Scene while idle*, *Manual level*, *Scene layout*, *Day or night*, *Sound*, *Sound volume*). `/flow save` makes the current session's settings your default, and `/flow reset` puts a session back on it. Changing a row in `/config` changes the default and the session you're in. A session already running keeps what it shows when the default changes elsewhere (another session's `/flow save`, say), and `/flow` on its own says when the session differs from your default, and how. `/flow save` saves exactly what the session shows. In opencode and pi there are no `/config` rows: the defaults are kept in opencode's storage for the plugin, or in pi's `~/.pi/agent/flow.json`, and `/flow save` writes them there (see *opencode* and *pi* below). | Command | Effect | |---|---| | `/flow` | show the scene, mode, level and time of day | | `/flow ` | pick a scene by name or alias (`/flow surf`, `/flow sea`) | -| `/flow pick` | every scene at once, live, side by side in a pane: the arrows (or Tab) move, Enter (or the scene's number) picks it for this session, as `/flow ` does, Esc closes; on desktop, click one | +| `/flow pick` | every scene at once, live, side by side in a pane: the arrows (or Tab) move, Enter (or the scene's number) picks it for this session, as `/flow ` does, Esc closes; on desktop, click one. In opencode and pi, a list to choose from | | `/flow next` | the next scene | | `/flow day` / `night` | pin the time of day | | `/flow clock` | day or night by your local clock (the default) | @@ -155,8 +157,8 @@ A new session starts on your defaults: the rows in `/config` (*Scene mode*, *Sce | `/flow idle glow` / `dark` | in auto mode while idle: a low glow (the default), or nothing (the band gives its rows back) | | `/flow sound` | toggle a soundscape for the scene, swelling with the work (macOS; off by default); `/flow sound on` / `off` to set it | | `/flow sound 1`–`10` | the soundscape's volume, turning it on (7 is the default; each step below it 3 dB quieter); `/flow sound 0` turns it off. `/flow volume 1`–`10` does the same | -| `/flow band` / `spine` | a 5-row band above the prompt (the default; also `horizontal`, `bar` or `flat`), or a tall pane docked beside the transcript (also `portrait`, `vertical` or `side`) | -| `/flow save` | make this session's settings your default, the one new sessions start with (it writes them to `/config`) | +| `/flow band` / `spine` | a 5-row band above the prompt (the default; also `horizontal`, `bar` or `flat`), or a tall pane docked beside the transcript (also `portrait`, `vertical` or `side`); in opencode, the sidebar. pi has no panes, so there it's always the band | +| `/flow save` | make this session's settings your default, the one new sessions start with (it writes them to `/config`; in opencode, its storage; in pi, `flow.json`) | | `/flow reset` | put this session back on your default | | `/flow help` | list all of this | @@ -169,17 +171,17 @@ The background is long takes that blend one into the next at random moments, so `/flow sound 1` to `10` turns it on at that volume in the session you run it in (*Sound volume* in `/config` is the volume new sessions start with, and `/flow save` makes this session's it). 7, the default, is the loudness the scenes were tuned to; each step below it is 3 dB quieter, so 1 is 18 dB down. Above 7 there's little room left: the loudest sounds already play near full scale, and any louder they'd distort. So 8, 9 and 10 lift the quieter sounds by up to 2, 4 and 6 dB, the louder ones by less and the loudest not at all, and a busier moment still sounds busier than a calm one. `/flow sound 0` turns it off, and `/flow sound` turns it back on at the volume it had. -When Claude starts waiting on you (a permission, its question, a plan to approve), a soft chime: two struck tubes rising a fourth, about as loud as a scene at 6. Once a wait, and not for one right behind another (within 20 s): you're there already. It's part of the soundscape, so it follows the session's sound setting: `/flow sound` turns it on and off with the rest. For an alert without the ambience, there's Claude Code's own notification setting in `/config`. +When your agent starts waiting on you (a permission, its question, a plan to approve; in Claude Code and opencode, as pi doesn't say when it waits), a soft chime: two struck tubes rising a fourth, about as loud as a scene at 6. Once a wait, and not for one right behind another (within 20 s): you're there already. It's part of the soundscape, so it follows the session's sound setting: `/flow sound` turns it on and off with the rest. For an alert without the ambience, there's Claude Code's own notification setting in `/config` (opencode has its own notifications too). -It plays only while the scene is on screen, and stops with the session. Claude Code plays the clips with `afplay`, and in opencode Flow runs `afplay` itself, so it's macOS only; elsewhere the setting does nothing. It's set per session like everything else, so you can have it on in one session and not another, or quieter in one; several sessions with it on each play their own. +It plays only while the scene is on screen, and stops with the session. Claude Code plays the clips with `afplay`, and in opencode and pi Flow runs `afplay` itself, so it's macOS only; elsewhere the setting does nothing. It's set per session like everything else, so you can have it on in one session and not another, or quieter in one; several sessions with it on each play their own. ## Turning it off -Run `/flow off`, or `/flow idle dark` to stay in auto mode but show nothing while idle. Either applies to the session you run it in; follow it with `/flow save` to make it the default for new sessions too. You can also disable the plugin. Flow draws in the terminal and in the Claude desktop app; not in VS Code or on mobile. +Run `/flow off`, or `/flow idle dark` to stay in auto mode but show nothing while idle. Either applies to the session you run it in; follow it with `/flow save` to make it the default for new sessions too. You can also disable the plugin (in opencode, `opencode plugin remove github:robdmac/flow`; in pi, `pi remove git:github.com/robdmac/flow`). In Claude Code, Flow draws in the terminal and in the Claude desktop app; not in VS Code or on mobile. ## pi -The same scenes run as a pi extension (`pi/index.ts`): a widget above pi's editor, drawn as 24-bit ANSI lines. It shares the scenes, the activity model and the `/flow` command with the Claude Code version. pi has no side panes, so there is no spine there, and no built-in subagents (so no companions). pi's events drive it: +The same scenes run as a pi extension (`pi/index.ts`): a widget above pi's editor, drawn as 24-bit ANSI lines. It shares the scenes, the activity model, the soundscapes and the `/flow` command with the Claude Code version. pi has no side panes, so there is no spine there, and no built-in subagents (so no companions). pi's events drive it: | pi event | The scene | |---|---| @@ -195,7 +197,7 @@ In a terminal that draws images (Ghostty, kitty, WezTerm, Warp; not under tmux), `doom3d` takes the same height in characters (`"rows"` sets it too), for the view through the marine's eyes. In Claude Code its band is as tall as the prompt leaves room for, up to 14 rows. -pi tells an extension nothing of a wait on you, so there the scenes never show one. Nor has it a player, so the scenes are silent there: `/flow sound` says so. +pi tells an extension nothing of a wait on you, so there the scenes never show one (nor chime). It has no player for an extension's sounds either, so for the soundscape Flow runs `afplay` (macOS) in the background itself, as in opencode, once a turn has run or `/flow` has been used: `/flow sound` turns it on. The one-time tips are the same as in Claude Code and opencode, kept in `flow.json`. As in Claude Code, each session keeps its own settings, kept in the session itself (an entry the model never sees), so resuming or forking it brings them back. `~/.pi/agent/flow.json` holds the defaults new sessions start with; `/flow save` writes it. To try it without installing: `pi --extension ./pi/index.ts`. @@ -220,15 +222,15 @@ Each session keeps its own settings, kept in Flow's own opencode storage under t **Privacy:** Flow collects nothing. It has no servers, sends no data anywhere, and keeps what little it stores (below) on your machine: in Claude Code's or opencode's own storage for the plugin, or in pi under `~/.pi/agent/flow.json` and `~/.cache/flow`. -**Network:** none. Flow sends nothing anywhere and contacts no hosts. Everything it draws and plays is computed locally from the plugin's own files. The one call that takes anything out of the plugin is `$.audio.play`, which hands one of the plugin's own clips from `sounds/` (and a volume) to the local audio player, `afplay`: nothing Flow reads (settings, the conversation, your files) goes into it, and it reaches no address. In opencode, which has no such call, Flow starts `/usr/bin/afplay` itself for each clip (macOS only, and only with the sound on): its arguments are a volume and the path of one of the plugin's own clips, or of a short sound it made (written to a file of its own in the system's temporary folder, and deleted once played). It runs nothing else. +**Network:** none. Flow sends nothing anywhere and contacts no hosts. Everything it draws and plays is computed locally from the plugin's own files. The one call that takes anything out of the plugin is `$.audio.play`, which hands one of the plugin's own clips from `sounds/` (and a volume) to the local audio player, `afplay`: nothing Flow reads (settings, the conversation, your files) goes into it, and it reaches no address. In opencode and pi, which have no such call, Flow starts `/usr/bin/afplay` itself for each clip (macOS only, and only with the sound on): its arguments are a volume and the path of one of the plugin's own clips, or of a short sound it made (written to a file of its own in the system's temporary folder, and deleted once played). It runs nothing else. **Your settings:** Flow writes its own `/config` rows (the `flow-scenes` plugin's options: scene, mode, sound and the rest) only when you run `/flow save`, which writes the settings your session shows as the defaults for new sessions. It reads them (`$.config.list()`) to start each session on your defaults. -It writes no other settings, no files and no environment variables. (`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS`, under *Not loading?*, is a variable you set yourself, if you need it.) +In Claude Code it writes no other settings, no files and no environment variables. (`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS`, under *Not loading?*, is a variable you set yourself, if you need it.) -**What it keeps:** in Claude Code's per-plugin store, each session's own `/flow` settings (the 100 most recently used sessions, none unused for 60 days) and which one-time tips you've seen. In the session's own plugin state, the balloon's altitude and whether someone has been at the session. In opencode, each session's own `/flow` settings (the 100 most recently used), the defaults `/flow save` keeps and which one-time tips you've seen, in opencode's storage for the plugin. In pi, a session's settings go in the session as an entry the model never sees, and `/flow save` writes `~/.pi/agent/flow.json`. +**What it keeps:** in Claude Code's per-plugin store, each session's own `/flow` settings (the 100 most recently used sessions, none unused for 60 days) and which one-time tips you've seen. In the session's own plugin state, the balloon's altitude and whether someone has been at the session. In opencode, each session's own `/flow` settings (the 100 most recently used), the defaults `/flow save` keeps and which one-time tips you've seen, in opencode's storage for the plugin. In pi, a session's settings go in the session as an entry the model never sees, and `~/.pi/agent/flow.json` holds the defaults `/flow save` writes, which one-time tips you've seen and the pi version a too-old warning was given for (Claude Code and opencode keep those last two in their stores too); `~/.cache/flow` keeps earthrise's picture for the last few sizes. With the sound on in opencode or pi, a short sound Flow makes is written to the system's temporary folder while it plays, then deleted. -**Sound:** off unless you turn it on. Claude Code plays the plugin's own clips (`sounds/`) through `afplay`, only after someone has typed or run a turn in the session; in opencode, Flow plays them through `afplay` itself, once a turn has run or `/flow` has been used in the session on screen. +**Sound:** off unless you turn it on. Claude Code plays the plugin's own clips (`sounds/`) through `afplay`, only after someone has typed or run a turn in the session; in opencode and pi, Flow plays them through `afplay` itself, once a turn has run or `/flow` has been used (in opencode, in the session on screen). **What its hooks see, and what they decide:** nothing. Every hook passes the event on unchanged (the one Flow answers is its own `/flow` command); Flow never blocks, allows, rewrites or answers a tool call, a permission, a prompt or a setting. It only watches, to set how busy the scene looks: diff --git a/opencode/player.ts b/node/player.ts similarity index 90% rename from opencode/player.ts rename to node/player.ts index 4373396..bd957ab 100644 --- a/opencode/player.ts +++ b/node/player.ts @@ -1,7 +1,8 @@ -// REVISION: flow-v1-opencode +// REVISION: flow-v2-shared-player // -// opencode has no player for a plugin's clips (its own notification sounds -// only), so Flow plays them itself: each clip is macOS's `afplay` run in the +// The soundscape's player where the host has none for a plugin's clips +// (opencode, whose sounds are its own notifications only; pi, none at all), +// so Flow plays them itself: each clip is macOS's `afplay` run in the // background at its gain (`-v`), stopped by killing it, exactly as Claude // Code's `audio.play` does it. A synthesized burst (base64 WAV) is written to // a file of its own first, removed once played. Only the plugin's own clips diff --git a/opencode/tui.ts b/opencode/tui.ts index 6a29d7d..79978b1 100644 --- a/opencode/tui.ts +++ b/opencode/tui.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v4-opencode-built-against +// REVISION: flow-v5-shared-player // // 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 @@ -13,7 +13,7 @@ // command shows as smoke), its subagents (a companion each), what it asks you (it settles // and breathes), compaction, how full its context is // /flow the same command as in Claude Code and pi; `/flow pick` is opencode's own list -// sound the same soundscapes, played by afplay in the background (macOS; player.ts) +// sound the same soundscapes, played by afplay in the background (macOS; node/player.ts) // tips the same one-time tips (the other scenes, then the sound), a toast as a session first shows // or under a `/flow` reply, kept in the store beside the settings // @@ -35,7 +35,7 @@ import { SCENES } from '../hooks/styles' import { FLOW_ENTRY } from '../pi/mapping' import { PiSettings, type SessionEntries } from '../pi/session' import { OpenCodeWork } from './mapping' -import { afplayPlayer } from './player' +import { afplayPlayer } from '../node/player' import type { OcContext } from './types' import type { SceneView } from './view' import { sceneViewClass } from './view' diff --git a/pi/index.ts b/pi/index.ts index 40877db..e58d533 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v176-built-against +// REVISION: flow-v177-pi-sound // // 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 @@ -25,7 +25,11 @@ // file, as before (session.ts). pi has no built-in subagents, so they never add to the scene here, // and no side panes, so there is no spine, and `/flow pick` is a plain list (pi's own select) // rather than thumbnails, its choice going the way `/flow ` goes. Nor has it a player, so -// there is no soundscape: `/flow sound` says so, and the help and the status leave it out. +// the soundscape (hooks/soundscape.ts, as in Claude Code and opencode) plays through afplay run in +// the background (node/player.ts, macOS; elsewhere `/flow sound` says there's none), once a turn has +// run or `/flow` has been used. pi tells an extension nothing of a wait on the person, so no chime. +// The one-time tips are Claude Code's and opencode's (the other scenes, then the sound), as a session +// starts or under a `/flow` reply, kept in flow.json (`tips`). // // In a terminal that draws kitty images (Ghostty, kitty, WezTerm, Warp), a // scene that can draw itself at real pixels (earthrise) shows as a picture @@ -39,12 +43,15 @@ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { Activity } from '../hooks/activity' import { FRAME_MS, SceneDriver } from '../hooks/scene' import { pickBlurb } from '../hooks/picker' -import { type Host, parseFlowArgs, readConfig, replyTo, storedValue, type FlowConfig, versionNote } from '../hooks/settings' +import { firstTips, type Host, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type FlowConfig, versionNote } from '../hooks/settings' +import { Soundscape } from '../hooks/soundscape' import { SCENES, type Scene } from '../hooks/styles' +import { afplayPlayer } from '../node/player' import { gridToAnsi } from './ansi' import { cellPixels, kittyImages, kittyLines, pictureId, pictureSize } from './picture' import { COMMAND_TOOLS, effortOf, FLOW_ENTRY, piLinesWritten, READ_TOOLS } from './mapping' @@ -57,8 +64,8 @@ const CONTEXT_EVERY_MS = 5000 /** How often flow.json is read afresh while the scene runs: another session's save shows here as this one's own. */ const DEFAULTS_EVERY_MS = 30_000 const SETTINGS = join(homedir(), '.pi', 'agent', 'flow.json') -/** What pi has of what `/flow` speaks of: no side panes, no player. */ -const PI: Host = { panes: false, sound: false } +/** The plugin's folder: its sounds/ are played from there. */ +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') /** Where the settings lived before, newest first: as vista, then as ascii-fire. */ const OLD_SETTINGS = [join(homedir(), '.pi', 'agent', 'vista.json'), join(homedir(), '.pi', 'agent', 'ascii-fire.json')] /** A picture's rows (and a taller scene's, `Scene.taller`) unless flow.json says (`rows`), and at most this share of the terminal's. */ @@ -98,40 +105,65 @@ async function loadPicturePrefs(): Promise<{ pictures: boolean; rows: number }> return { pictures: raw.pictures !== false, rows: Math.max(3, Math.min(60, rows)) } } +/** The defaults new sessions start with: as kept (readStored), else Flow's own. */ async function loadSettings(): Promise { - for (const file of [SETTINGS, ...OLD_SETTINGS]) { - try { - return readConfig(JSON.parse(await readFile(file, 'utf8')) as Record) - } catch { - // Not there (or unreadable): try the next, then the defaults. - } - } - return readConfig(undefined) + return readConfig(await readStored()) } /** - * Write just these changes into the settings file, over what is there now: - * another pi session's changes to other settings survive. + * What's kept: flow.json, or before the first write since a rename, an old file (vista.json, + * ascii-fire.json), so a write never drops what that held. None kept yet (or unreadable): none. */ -async function saveSettings(changes: Partial): Promise { - // The settings so far: flow.json, or before the first save since a - // rename, an old file (else its settings would be dropped). - let stored: Record = {} +async function readStored(): Promise | undefined> { for (const file of [SETTINGS, ...OLD_SETTINGS]) { try { - stored = JSON.parse(await readFile(file, 'utf8')) as Record - break + return JSON.parse(await readFile(file, 'utf8')) as Record } catch { - // Not there (or unreadable): try the next, else start afresh. + // Not there (or unreadable): try the next. } } - for (const [k, v] of Object.entries(changes) as [keyof FlowConfig, FlowConfig[keyof FlowConfig]][]) { - stored[k] = storedValue(k, v) - } + return undefined +} + +/** + * Write these keys into flow.json, over what is kept now (readStored): another pi session's changes to + * other keys survive, and an old file's settings carry over into flow.json with the first write. + */ +async function keepStored(changes: Record): Promise { + const stored = { ...(await readStored()), ...changes } await mkdir(dirname(SETTINGS), { recursive: true }) await writeFile(SETTINGS, JSON.stringify(stored, null, 2) + '\n') } +/** 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) +} + +/** + * The one-time tips (settings.ts), the person's, kept in flow.json (`tips`, beside its own keys). Begun + * where none are kept yet, before the session's `/flow` can keep anything: someone who had Flow in pi before + * (settings kept already) is taken as told, someone new as told nothing (firstTips). + */ +async function beginTips(defaults: FlowConfig): Promise { + const stored = await readStored() + if (stored?.tips !== undefined) return + await keepStored({ tips: firstTips(stored !== undefined, defaults) }) +} + +/** A chance for a tip (tipChance): the tip, if one's due, without its `flow: ` (pi's notice says whose). None on any failure. */ +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: /, '') + } catch { + return undefined + } +} + /** pi's version, from its own package (named one way since 1.1, another before): none if it can't be read. */ async function piVersion(): Promise { for (const name of ['@earendil-works/pi-coding-agent', '@mariozechner/pi-coding-agent']) { @@ -153,16 +185,9 @@ async function versionWarning(): Promise { const version = await piVersion() const note = versionNote('pi', version) if (!note) return undefined - let stored: Record = {} + if ((await readStored())?.warned === version) return undefined try { - stored = JSON.parse(await readFile(SETTINGS, 'utf8')) as Record - } catch { - // None yet: one starts with the note. - } - if (stored.warned === version) return undefined - try { - await mkdir(dirname(SETTINGS), { recursive: true }) - await writeFile(SETTINGS, JSON.stringify({ ...stored, warned: version }, null, 2) + '\n') + await keepStored({ warned: version }) } catch { // Unkept: said again next time. } @@ -173,6 +198,11 @@ export default function flow(pi: PiApi) { const activity = new Activity() const driver = new SceneDriver(readConfig(undefined), activity) const cfg = driver.cfg + /** The soundscape, played by afplay (macOS); none elsewhere. */ + const player = afplayPlayer(ROOT) + const sound = new Soundscape(cfg, player) + /** What pi has of what `/flow` speaks of: no side panes; a player where afplay is. */ + const PI: Host = { panes: false, sound: player !== undefined } /** The defaults (flow.json) and this session's own settings over them. */ const settings = new PiSettings(cfg, { load: loadSettings, save: saveSettings }) @@ -368,10 +398,11 @@ export default function flow(pi: PiApi) { sync(ctx) } const f = dial() - if (isMounted && width > 0 && tui) { - f.step() - // pi has no player: a scene's events (for Claude Code's soundscape) are taken and dropped each frame. - if (f.sounds) f.sounds.length = 0 + const shown = isMounted && width > 0 && tui !== undefined + if (shown) f.step() + // The soundscape: heard while the scene's on screen (its events taken each frame, heard or not). + sound.step(elapsed, { heard: shown, shown: shown ? f : undefined, scenes: [f], waiting: false, tint: driver.tint(), night: driver.isNight() }) + if (shown && tui) { sincePicture += elapsed const next = nextLines(f) if (next) { @@ -404,6 +435,10 @@ export default function flow(pi: PiApi) { isMounted = false sync(ctx) frame(FRAME_MS) + // A session starting is a chance for a one-time tip (the other scenes, then the sound). + await beginTips(settings.defaults).catch(() => {}) + const tip = await takeTip(cfg) + if (tip) ctx.ui.notify(say(tip), 'info') // On a pi older than Flow is built against: said once for that version (it runs all the same). const warning = await versionWarning() if (warning) ctx.ui.notify(warning, 'warning') @@ -420,12 +455,17 @@ export default function flow(pi: PiApi) { pi.on('session_shutdown', (_e, ctx) => { stop() + sound.stop() if (isMounted && ctx.hasUI) ctx.ui.setWidget(KEY, undefined) isMounted = false ctxRef = undefined }) - pi.on('agent_start', () => activity.turnStarted()) + pi.on('agent_start', () => { + activity.turnStarted() + // A turn: someone's there, so the soundscape may play. + sound.present = true + }) pi.on('agent_end', () => activity.turnEnded()) pi.on('turn_start', (_e, ctx) => activity.modelStep(effortOf(ctx.thinkingLevel))) @@ -461,7 +501,10 @@ export default function flow(pi: PiApi) { } const handler = async (args: string, ctx: PiContext) => { + sound.present = true let cmd = parseFlowArgs(args) + // (The tips begun before this `/flow` keeps anything: a first `/flow save` isn't someone who had Flow before.) + await beginTips(settings.defaults).catch(() => {}) readClock() const entries = entriesOf(ctx) // flow.json as it is now: what follows compares with it. @@ -490,8 +533,8 @@ export default function flow(pi: PiApi) { ctx.ui.notify('flow: pi has no side panes, so the scene stays in the band above the editor', 'warning') return } - if (cmd.kind === 'sound') { - ctx.ui.notify('flow: pi has no player for the soundscape, so the scenes are silent here', 'warning') + if (cmd.kind === 'sound' && !player) { + ctx.ui.notify('flow: the soundscapes play through afplay, which only macOS has', 'warning') return } const reply = await replyTo(cmd, { @@ -522,9 +565,14 @@ export default function flow(pi: PiApi) { return note }, }) + // 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), reply.level) + 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: 'Ambient scenes above the editor: /flow [ | pick | next | day | night | clock | auto | 1-10 | off | save | reset | help]', diff --git a/tests/flow.test.ts b/tests/flow.test.ts index d7bec1d..ffc879c 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v182-slow-tests +// REVISION: flow-v183-pi-sound import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' @@ -954,7 +954,7 @@ test('soundscapes: a bed take the player refuses for good is replaced at once, n await ui.unmount() }) -test('soundscapes: a scene holds only a few events for the adapter, so one that never takes them (pi) stays bounded', { timeoutMs: SLOW_MS }, () => { +test('soundscapes: a scene holds only a few events for the adapter, so one that never takes them (a host with no player) stays bounded', { timeoutMs: SLOW_MS }, () => { for (const style of STYLES) { const f = makeScene(style, 2) f.ensure(100, 5)