diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2361dc5..cb1fd6e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "flow-scenes", "displayName": "Flow Scenes", - "version": "0.8.0", + "version": "0.9.0", "description": "Ambient scenes above the prompt that move with your agent's work: a fire, bubbles, surf, ski, rockets, a hot-air balloon and more, by day or night. /flow to control it.", "author": { "name": "Rob Macrae" diff --git a/AGENTS.md b/AGENTS.md index 01375d7..fdabbda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Flow: ambient terminal scenes that move with a coding agent's work. One codebase runs as a Claude Code mod (a plugin of function hooks, early access) and as a [pi](https://github.com/badlogic/pi-mono) extension. This repo is both the plugin and its own marketplace. User-facing docs are in `README.md`; this file is for working on the code. +Flow: ambient terminal scenes that move with a coding agent's work. One codebase runs as a Claude Code mod (a plugin of function hooks, early access), as an [opencode](https://opencode.ai) terminal UI plugin (opencode 2.0) and as a [pi](https://github.com/badlogic/pi-mono) extension. This repo is both the plugin and its own marketplace. User-facing docs are in `README.md`; this file is for working on the code. ## Layout @@ -15,6 +15,7 @@ hooks/ svg.ts Claude desktop: a frame's cells as a PNG inside one Svg (pure, unit-tested) picker.ts /flow pick: the thumbnails' grid for a pane's size, the thumbnails, the picker's words (pure) sound.ts soundscapes: each scene's layers of clips and its events' clips, levels, small events synthesized (pure) + soundscape.ts the soundscape a frame at a time over a host's Player (Claude Code's audio.play, opencode's afplay) (pure) sound-files.ts every clip in sounds/ and each mood's bed gain (written by scripts/make-sounds.ts) scene.ts SceneDriver: shared by both adapters (scene per style, level, dials, pace) settings.ts FlowConfig, the /flow grammar, every reply's wording, and `replyTo`: /flow's reply over a @@ -36,6 +37,13 @@ hooks/ fire-palette.ts the fire scene's truecolor ramps .ts one file per scene: starfield (warp), colony, balloon + sky + clouds/, engine, rocket (falcon, starship), surf, ski, bubbles, train, doom (doom3d) +opencode/ + tui.ts opencode adapter (a terminal UI plugin: the band above the prompt, the spine in the sidebar, /flow, + 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) 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 @@ -47,7 +55,7 @@ scripts/ Node tools, not part of the mod: new-scene, preview, check, with companions, waiting, or over one of avalon's backdrops) types/index.d.ts the mod's declarations: its `state` (the balloon's altitude, whether someone's at the session) tests/flow.test.ts unit tests, plus some that need the mod engine -package.json the pi package ("pi": { "extensions": ["./pi/index.ts"] }) and the scripts +package.json the pi package ("pi": { "extensions": ["./pi/index.ts"] }), opencode's ("exports": { "./tui": … }) and the scripts ``` ## Scenes @@ -59,7 +67,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - **Dials**, set every frame by `SceneDriver.dial()`: - `strength` 0–10: 0 draws nothing, every cell blank; 1 is calm idle; 10 is the busiest. Ease changes; never jump. - `coverageBoost`: above 0 while subagents run (15 each; in manual mode too). Add company (more streams, thicker steam). - - `agents`: each running subagent on its own (`AgentDial`: a stable `id`, `state` `'working' | 'idle' | 'waiting' | 'done'`, `ok` once done, its `task` and `type`, `ms` run), in the order they started (in manual mode too: holding a level holds how busy it looks, not who's about); `[]` in pi. The adapter derives it (`Roster`, `agents.ts`) from `agent.list()` polls (teammates left out) and each agent's own events by `agentId`: a model step or streamed output or a tool call is working, a tool running keeps it working, nothing for `QUIET_MS` (20 s) or a held/between-turns status is idle, a dialog put to the person is waiting (a permission once `classic.PermissionRequest` shows its dialog, since auto mode's classifier settles most asks alone, matched to that loop's running call of the tool (by its input when the loop runs several), each noted as `tool.call` starts it, so another call ending never ends it; `AskUserQuestion`/`ExitPlanMode` from the start; it ends with the call, a refusal too since the permission prompt runs beneath `tool.call`, or its `ToolProgress` pill; one tracker, `Waits` in `waits.ts`, keeps every loop's waits for the roster and the `waiting` dial alike), its `turn.complete` (or its status ending, or its leaving the list) is done, kept `DONE_MS` (6 s) so its companion can leave. What's heard from a loop before a poll names it (tools running, waits) is kept and counts once it is. A scene that gives each agent a companion keeps a `Crew` (`crew.ts`): set `crew.room` to how many companions this layout can actually show (the spine fits fewer), `update(this.agents, this.coverageBoost)` once a frame, then draw `crew.mates`, every one of them on screen: each keeps its `slot` (its colors, its place; only slots below `room` are given, and a layout with less room sends the rest back to wait) while on screen, `here` eases 0 → 1 as it arrives and back to 0 as it leaves (then its slot frees for an agent waiting past `capacity`), `busy` eases 1 working / 0 resting, `waiting` and `leaving`/`ok` say the rest (`resting`, `beckon` for a waiting one's blink, `finished`, `failed`, `easeTo` and `crew.inSlot` say them alike in every scene). Arrive from an edge, work visibly, rest visibly (and show waiting on you), leave out of sight (failed, another way); never pop. An adapter with no list (only a count) still gets anonymous working companions from `coverageBoost`. Note each companion's cells with `crew.mark` while drawing and return `crew.marks` from `agentMarks()`: desktop puts the agent's hover card there. Fire, surf, ski, balloon, falcon, starship (a mini rocket each: a hosted `LaunchSite` flying the same code, a few frames late, in the host's world and pixel layer), engine, train, avalon (probes launched from the ship) and doom and doom3d (marines of a co-op squad) have companions (the fire's are small fires of their own beside the main one, which narrows to make room: one automaton, its burners shaped column by column); warp and bubbles keep plain coverage (a first-person stream, anonymous bubbles: nothing stays put to be someone). + - `agents`: each running subagent on its own (`AgentDial`: a stable `id`, `state` `'working' | 'idle' | 'waiting' | 'done'`, `ok` once done, its `task` and `type`, `ms` run), in the order they started (in manual mode too: holding a level holds how busy it looks, not who's about); `[]` in pi. In opencode a subagent is a child session (`session.created` with a `parentID`): `opencode/mapping.ts` lists them to the same `Roster`, done when their run ends. The adapter derives it (`Roster`, `agents.ts`) from `agent.list()` polls (teammates left out) and each agent's own events by `agentId`: a model step or streamed output or a tool call is working, a tool running keeps it working, nothing for `QUIET_MS` (20 s) or a held/between-turns status is idle, a dialog put to the person is waiting (a permission once `classic.PermissionRequest` shows its dialog, since auto mode's classifier settles most asks alone, matched to that loop's running call of the tool (by its input when the loop runs several), each noted as `tool.call` starts it, so another call ending never ends it; `AskUserQuestion`/`ExitPlanMode` from the start; it ends with the call, a refusal too since the permission prompt runs beneath `tool.call`, or its `ToolProgress` pill; one tracker, `Waits` in `waits.ts`, keeps every loop's waits for the roster and the `waiting` dial alike), its `turn.complete` (or its status ending, or its leaving the list) is done, kept `DONE_MS` (6 s) so its companion can leave. What's heard from a loop before a poll names it (tools running, waits) is kept and counts once it is. A scene that gives each agent a companion keeps a `Crew` (`crew.ts`): set `crew.room` to how many companions this layout can actually show (the spine fits fewer), `update(this.agents, this.coverageBoost)` once a frame, then draw `crew.mates`, every one of them on screen: each keeps its `slot` (its colors, its place; only slots below `room` are given, and a layout with less room sends the rest back to wait) while on screen, `here` eases 0 → 1 as it arrives and back to 0 as it leaves (then its slot frees for an agent waiting past `capacity`), `busy` eases 1 working / 0 resting, `waiting` and `leaving`/`ok` say the rest (`resting`, `beckon` for a waiting one's blink, `finished`, `failed`, `easeTo` and `crew.inSlot` say them alike in every scene). Arrive from an edge, work visibly, rest visibly (and show waiting on you), leave out of sight (failed, another way); never pop. An adapter with no list (only a count) still gets anonymous working companions from `coverageBoost`. Note each companion's cells with `crew.mark` while drawing and return `crew.marks` from `agentMarks()`: desktop puts the agent's hover card there. Fire, surf, ski, balloon, falcon, starship (a mini rocket each: a hosted `LaunchSite` flying the same code, a few frames late, in the host's world and pixel layer), engine, train, avalon (probes launched from the ship) and doom and doom3d (marines of a co-op squad) have companions (the fire's are small fires of their own beside the main one, which narrows to make room: one automaton, its burners shaped column by column); warp and bubbles keep plain coverage (a first-person stream, anonymous bubbles: nothing stays put to be someone). - `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. @@ -77,7 +85,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. 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 (`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: - **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. @@ -102,6 +110,15 @@ The engine validates the module before it runs (`claude plugin validate .`): - `/flow pick` opens a second pane (`flow-pick`) as a dialog (`focus`, `closeOnEscape`, `holdToasts`): every scene as a thumbnail of its own (`Thumbnails`, at level 6, never the scene on show), a Raster each in the terminal (blitted on the picker's own 100 ms timer, only while it's open) and an Svg each on desktop, with a Button under each that the focus ring walks; `ui.focus` says which to light. VS Code and mobile get the Buttons alone. A pick is `/flow ` exactly (`flowReply`: this session's own, kept under its id), its reply shown as a toast once the picker has closed; pi's `/flow pick` is pi's own select list, its choice going on as `/flow `. A plugin's own `ui.close` doesn't come back through its own `ui.close` hooks (so the test engine shows; the person's close, Esc, does), so whatever closes a pane from the code does the hook's bookkeeping itself first (`leftSpine`, `closePickerPane`). - Desktop's hover cards: over each companion (`agentMarks()`) the render puts a keyed, absolutely placed `Box` the companion's size, holding a hidden card (`display: 'none'`, `hover: { display: 'flex' }`) with the agent's task and state. The surface reveals it under the pointer; no hook runs, and it survives the 10-a-second redraws. An `Svg` itself can't take a press or a pointer (`isInteractive` only enables CSS `:hover` and `` inside a sandboxed frame, which a redraw replaces), and no API switches the transcript to an agent, so there's no click. +## opencode + +- A terminal UI plugin alone (`opencode/tui.ts`, found through `package.json`'s `exports["./tui"]`; `opencode plugin add github:robdmac/flow` installs it into opencode's own cache and `cli.json`): nothing runs in opencode's server. It claims two slots: `session.composer.top` (the band) and `sidebar.content` (the spine, about 37 columns wide and as tall as the terminal leaves, 12 to 40 rows). A view not in use is hidden (`visible`), not made 0 rows tall: a renderable takes a row however short. The view in use is stepped by Flow's own timer (`FRAME_MS`, `driver.pace()`) and asked to redraw; it paints the scene's cells in `renderSelf`. A press on it (`onMouseDown`) finds the companion under it (`agentMarks()`, a cell's slack around it) and toasts its `agentLine`: opencode's stand-in for desktop's hover cards. +- A plugin's own files can't import opencode's packages (only its entry file is rewritten to opencode's copies, and a second copy of OpenTUI wouldn't draw into its frame): `view.ts` takes OpenTUI's `Renderable` from the renderer's root and its `RGBA` from a colour of the theme. Import only types from `@opentui/core` (`opentui.d.ts` declares them). +- Its events (`data.listen`) are the server's whole stream: `OpenCodeWork` follows the session the slot says is on screen (`show`) and its subagents, and drops the rest. A tool's name comes before its input (`session.tool.input.started`, then `.called`); a shell that ran but exited non-zero is a `session.tool.success` with `metadata.exit`. `session.usage.updated` carries the session's tokens so far: a step's are the difference from the last. +- Settings: `storage.store('flow')`, durable and shared live by every opencode: `defaults` (what `/flow save` keeps, over the plugin's `options`), `sessions` (each one's own, by id, `at` for pruning, as in Claude Code) and `tips` (begun with `firstTips` at the first chance, before that chance's `/flow` keeps anything; each chance is `tipChance`, as Claude Code's are: a session first showing in this opencode, a moment after so the store has loaded, and every `/flow`). The store loads from disk after the plugin starts, so a session's settings are read again whenever its record changes. `PiSettings` (`pi/session.ts`) does the rest, as for pi. +- `/flow` is a keymap command with `slash: { name: 'flow', arguments: true }`: its input is the text after `/flow`. A command not yet registered sends the text to the model as a prompt, so when testing, type `/flow`, see it offered, then the arguments. +- Try it: `opencode plugin add` takes only npm and git specs, so for a checkout put a one-line `tui.ts` in a project's `.opencode/plugins/flow/` (`export { default } from '<checkout>/opencode/tui.ts'`; symlinks aren't followed), and run opencode there. `opencode --print-logs --log-level debug` logs each plugin's load (`plugin operation failed` says why). + ## Settings - Settings are per session. The `userConfig` rows in `/config` are the defaults every session starts from; Claude Code reloads the module when one changes, which would restart the scene. @@ -133,6 +150,7 @@ npm run cvd -- [scene] # how far apart the tints look to colour-blind eyes npm run sounds # rebuild the soundscapes' clips (needs sox and ffmpeg) ``` +- The oldest Claude Code, opencode and pi Flow is built against are `BUILT_AGAINST` (`settings.ts`, and the README's Compatibility): on an older one each adapter gives `versionNote` once for that version (Claude Code's `session.version()`, opencode's `app.version`, pi's own `VERSION`), kept where it keeps the rest (Claude Code's store `warned`, opencode's store, flow.json's `warned`), and runs all the same. Raise them when Flow starts needing something newer, and say so in the README. - No `package-lock.json` is committed (it's gitignored): Claude Code runs `npm ci` in a plugin's folder when it finds a lockfile beside `package.json`, and Flow needs none of its dev tools to run. `package.json` pins them to exact versions instead; bump them there. - Hot reload doesn't follow symlinks. To use a dev-mods folder, copy the plugin's files in rather than linking it. - Every file starts with a `// REVISION: flow-vNN-<what>` line; bump it when you change the file. On load the mod logs `[flow] REVISION: …`, the local time and the UTC offset to the debug log (`claude --debug`), so you can tell which version is running. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8410ae..dac01d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ CI runs the same on every pull request. For a change you can see, also run `npm Flow runs inside people's coding sessions, so it stays small and local. A pull request that crosses one of these lines needs a good reason, said up front: -- **No network.** Nothing in `hooks/` or `pi/` contacts a host, and nothing a hook reads (the conversation, files, settings) leaves the machine. +- **No network.** Nothing in `hooks/`, `opencode/` or `pi/` contacts a host, and nothing a hook reads (the conversation, files, settings) leaves the machine. - **No new runtime dependencies.** The mod runs on its own files alone. Dev tools go in `devDependencies`, pinned to an exact version (no lockfile is committed: see AGENTS.md). - **Hooks only watch.** Every hook passes its event on unchanged. Flow never blocks, allows or rewrites a tool call, a permission or a prompt, and it answers no command but `/flow`. - **What it stores stays as described** in the README's *What Flow does on your machine*. If a change reads or keeps something new, update that section in the same pull request. diff --git a/README.md b/README.md index 241fce2..915691c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ <img width="1200" height="384" alt="Flow's fire above the prompt: pilot lights at idle, climbing with each of Claude's tool calls, an inferno with three subagents, then back down when Claude is done" src="https://github.com/user-attachments/assets/0f0120cc-af23-44ec-9398-309cccd42764" /> -Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, a colony ship behind its shield, the Earth rising over the moon, and a space marine fighting through a 1993 techbase. They run above the prompt of Claude Code (in the terminal or the Claude desktop app) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript. +Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, a colony ship behind its shield, the Earth rising over the moon, and a space marine fighting through a 1993 techbase. They run above the prompt of Claude Code (in the terminal or the Claude desktop app), [opencode](https://opencode.ai) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript or in opencode's sidebar. Each scene is drawn in 24-bit color from Unicode block, quadrant and braille characters, so several pixels share each terminal cell. In the Claude desktop app, which has no character grid to draw into, the same frames are drawn as images. @@ -24,6 +24,12 @@ Installed it before as `flow@robdmac`? The plugin is now `flow-scenes` (another **Claude desktop**, in the Code tab: click **+** next to the prompt box, then **Plugins** → **Add plugin**, add the marketplace `robdmac/flow` and install **Flow** (`flow-scenes`). Manage it later from **+** → **Plugins** → **Manage plugins**. The desktop app and Claude Code share their plugins, so if you've installed Flow in the terminal it's already there. It runs in local sessions, not cloud ones. +**opencode** (2.0 or later), from your shell: + +``` +opencode plugin add github:robdmac/flow +``` + **pi**, from your shell: ``` @@ -165,7 +171,7 @@ The background is long takes that blend one into the next at random moments, so 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`. -It plays only while the scene is on screen, and stops with the session. Claude Code plays the clips with `afplay`, 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 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 @@ -193,19 +199,36 @@ pi tells an extension nothing of a wait on you, so there the scenes never show o 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`. +## opencode + +The same scenes run as an opencode terminal UI plugin (`opencode/tui.ts`, opencode 2.0 or later): above the prompt of the session on screen, or with `/flow spine` in its sidebar. They share the scenes, the activity model, the soundscapes and the `/flow` command with the Claude Code version; `/flow pick` is opencode's own list, `/flow help` a dialog. Click a subagent's companion to see what it's doing: its task, whether it's working, quiet, waiting on you or done, and how long it's run. The one-time tips are the same as in Claude Code: the other scenes, then the sound, each once, as a session first shows or under a `/flow` reply. opencode's events drive it: + +| opencode event | The scene | +|---|---| +| `session.execution.started` / ended | a turn lifts it (a level more for every 30 s it runs), then it settles to idle; a turn that fails shows as smoke | +| `session.step.started`, text and reasoning deltas | each model step, and streamed text and thinking, keep it going; the model's variant sets the floor (`low` 2 … `max` 6) | +| `session.tool.called` | `write`/`edit`/`apply_patch` push it by lines written, `shell` sparks, reads and other tools flicker | +| `session.tool.success`, `.failed` | a command that exits non-zero shows as smoke | +| `session.created` with a parent | a subagent: a companion of its own, there till its run ends (done well, or not) | +| `permission.asked`, `form.created` | a permission or a question put to you: the scene settles and breathes till it's answered | +| `session.compaction.ended` | smoke, as for a compaction in Claude Code | +| `session.usage.updated` | a context nearly full (the last step's tokens over the model's limit) shows as blue | + +Each session keeps its own settings, kept in Flow's own opencode storage under the session's id (the 100 used most recently), so reopening it brings them back; `/flow save` makes them the defaults new sessions start with, for every opencode on the machine. Options set for the plugin in `cli.json` (`{ "package": "github:robdmac/flow", "options": { "style": "surf" } }`) are defaults too, under what `/flow save` keeps. opencode has no player for a plugin's own sounds, so for the soundscape Flow runs `afplay` (macOS) in the background itself, one per clip, stopped with the scene or opencode. + ## What Flow does on your machine -**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 own storage for the plugin, or in pi under `~/.pi/agent/flow.json` and `~/.cache/flow`. +**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. +**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. **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.) -**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 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 `/flow save` writes `~/.pi/agent/flow.json`. -**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. +**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. **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: @@ -222,13 +245,15 @@ It writes no other settings, no files and no environment variables. (`CLAUDE_COD It also lists the session's running subagents (`$.agent.list()`: their state, task and type) to give each one a companion and a hover card. +In opencode, Flow is a terminal UI plugin only: nothing of it runs in opencode's server, and it changes nothing it hears. It reads opencode's events for the session on screen and its subagents, as in the table under *opencode*: tool names, the lines an edit writes (counted, not kept), a command's exit code, how much text streams (its length), that a permission or question is up and when it's answered, token counts. It draws in two places in opencode's screen (above the prompt, and the sidebar) and adds the `/flow` command. + ## Cost Flow repaints about 14 times a second while busy and 8 times when calm. It skips unchanged frames and doesn't repaint at all while off screen. In the desktop app it redraws at most 10 times a second. The heaviest scene takes under 2 ms per frame. ## Compatibility -Built against Claude Code 2.1.289 and pi 1.0.0. Mods (function-hook plugins) are early access: the API may change between Claude Code releases, so a newer release can break flow until it's updated. +Built against Claude Code 2.1.289, opencode 2.0.29 and pi 1.0.0, the oldest of each it's known to work on. On an older one Flow still runs, but says so once (a notice when a session starts, once for each version), since a scene may misbehave there. Mods (function-hook plugins) are early access: the API may change between Claude Code releases, so a newer release can break flow until it's updated. opencode's plugin API for its terminal UI is new with opencode 2, and may change between its releases too. ## Development @@ -253,6 +278,8 @@ npm run check # colour pairs and timing, before you open a pull r `new-scene` writes `hooks/aurora.ts`: a scene that already moves with the level, shows the tints, breathes while Claude waits on you and (with `--night`) has a night. Edit its `paint()`: it gets a grid of pixels (2 × 2 a terminal cell) and the dials (the level, eased; the frame count; night; the tint; how far into a wait on you; whether it's the tall spine). [AGENTS.md](AGENTS.md) has the rules a scene keeps to. +To try the opencode version from a checkout, put a one-line `tui.ts` in a project's `.opencode/plugins/flow/`: `export { default } from '/path/to/flow/opencode/tui.ts'`, then run `opencode` in that project. + Every hook file carries a `// REVISION:` marker. On startup the mod writes the loaded revision, the local time and the UTC offset to the debug log (`claude --debug`). ## License diff --git a/hooks/register.tsx b/hooks/register.tsx index 9d219af..4b19995 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,4 +1,4 @@ -// REVISION: flow-v179-doom-volleys +// REVISION: flow-v182-built-against // // 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 @@ -63,9 +63,8 @@ import { type FlowCommand, type FlowConfig, type FlowLayout, - nextTip, - noteTips, - readTips, + tipChance, + versionNote, staleRows, type StoredRow, } from './settings' @@ -85,7 +84,8 @@ import { } from './sessions' import { SCENES, type SceneName } from './styles' import { frameSvg } from './svg' -import { type BedTake, bedFailed, bedStep, burst, chimePlay, chimeStep, gather, MAX_PLAYS, newChimeState, unit, eventPlay, master, type SoundEvent, type SoundMood, volumeGain } from './sound' +import { MAX_PLAYS } from './sound' +import { Soundscape } from './soundscape' import { hiddenNote, hotkeyFor, @@ -129,14 +129,6 @@ const MAX_ROWS = 5 const TALL_ROWS = 14 /** A desktop site redraws at most 10 times a second (`$.ui.invalidate`'s limit there). */ const DESKTOP_MS = 100 -/** - * Small events are gathered this long (ms) and played together, each at its - * moment: each clip holds one of the player's few plays for its length and - * almost a second more (afplay starting and draining), so not much shorter. - */ -const SOUND_BURST_MS = 1000 -/** The nearest two events of a kind with clips play (nearer, the ear hears one). */ -const SOUND_STAGGER_MS = 120 /** A desktop site that hasn't rendered for this many ticks (about 3 s) is gone. */ const DESKTOP_STALE_TICKS = 45 /** A desktop cell in CSS pixels (its text's column and line), to size the frame's image. */ @@ -582,22 +574,38 @@ async function seedTips($: EngineInterface, usedBefore: boolean, settings: FlowC } /** - * A chance for a one-time tip (see nextTip), the store keeping which have been - * given: the tip, if one's due. `noteOnly`: no chance (no prompt to toast - * over), but what's on is noted all the same (see noteTips): the sound turned - * on in /config in such a session, and off again later, still counts as tried. + * A chance for a one-time tip (see tipChance), the store keeping which have been given: the tip, if one's due. + * `noteOnly`: no chance (no prompt to toast over), but what's on is noted all the same. */ async function takeTip($: EngineInterface, cfg: FlowConfig, noteOnly = false): Promise<string | undefined> { try { - const before = readTips(await $.store.get(TIPS)) - const { tip, tips } = noteOnly ? { tip: undefined, tips: noteTips(before, cfg) } : nextTip(before, cfg) - if (JSON.stringify(tips) !== JSON.stringify(before)) await $.store.set(TIPS, tips) + const { tip, tips, changed } = tipChance(await $.store.get(TIPS), cfg, noteOnly) + if (changed) await $.store.set(TIPS, tips) return tip } catch { return undefined } } +const WARNED = 'warned' + +/** + * On a Claude Code older than Flow is built against (see versionNote), the note to give: once for that + * version, the store keeping which it was said for. None where it can't tell (a Claude Code too old to say + * its version), or on any failure: it never stands in the way. + */ +async function versionWarning($: EngineInterface): Promise<string | undefined> { + try { + const { version, base } = await $.session.version() + const note = versionNote('Claude Code', base ?? version) + if (!note || (await $.store.get(WARNED)) === version) return undefined + await $.store.set(WARNED, version) + return note + } catch { + return undefined + } +} + /** `/flow`: show, help, or apply a change, keep it, and answer. */ export async function runScene($: EngineInterface, e: CommandRunInput, ctx: SceneCtx): Promise<CommandRunResult> { return withTip($, ctx, await sceneReply($, e, ctx)) @@ -731,61 +739,12 @@ export const register: Register = (on, options) => { /** A subagent's step or tool call arrived: count the running ones at the next poll. */ let subagentSeen = false /** - * The soundscape: the clips playing (each stoppable), its own clock (ms), - * each bed layer's take (`bedStep` keeps them) and their players by id, - * when the next burst of events is due, and the events gathered since the last. - */ - const sound = { - playing: new Set<AbortController>(), - clock: 0, - bed: [] as (BedTake | undefined)[], - takes: new Map<number, AbortController>(), - nextBurst: 0, - lastBurst: 0, - queue: [] as (SoundEvent & { at: number })[], - /** How many events the window has seen (more than the queue holds when it's busy). */ - seen: 0, - /** When each kind of event with a clip of its own may play next (a closer one waits till then). */ - nextOf: new Map<string, number>(), - /** Bumped whenever the soundscape stops (a new scene, sound off, hidden): what was scheduled before is stale. */ - gen: 0, - /** When the waits on the person began and ended, and the last chime (chimeStep keeps it). */ - chime: newChimeState(), - /** The event clips playing, oldest first (the first to give way when the player is full). */ - events: [] as AbortController[], - /** The last volley's clip (a fast gun's run of shots), stopped early when its gun falls silent (`cease`). */ - volley: undefined as AbortController | undefined, - seed: 1, - scene: '', - /** - * The session has ended for good (Claude Code quitting): the frames still running till the process goes - * start nothing, as a clip begun then outlives it and plays out in full. - */ - over: false, - /** - * Someone has been seen at the session (`keepPresent`). A spare session Claude Code warms in the - * background has a terminal and shows its scene, but no one hears it: it stays quiet till then. - */ - present: false, - } - /** - * Whether what was scheduled in generation `gen` may still play: the soundscape hasn't stopped since, and - * no change waits for the next frame to stop it (another scene picked, the sound turned off). + * The soundscape (soundscape.ts): its player comes with the session's engine (session.start). Silent till + * someone's at the session (`present`: a spare session Claude Code warms in the background has a terminal and + * shows its scene, but no one hears it), and for good once the session has ended (`over`: the frames still + * running till the process goes start nothing, as a clip begun then outlives it and plays out in full). */ - const current = (gen: number) => sound.gen === gen && cfg.sound === 'on' && cfg.style === sound.scene - const stopSound = () => { - // (A new generation: a delayed event or retry from the soundscape stopped here never plays.) - sound.gen++ - for (const c of sound.playing) c.abort() - sound.playing.clear() - sound.takes.clear() - sound.events.length = 0 - sound.volley = undefined - sound.bed = [] - sound.queue.length = 0 - sound.seen = 0 - sound.scene = '' - } + const sound = new Soundscape(cfg) /** The tick a blit went out on, -1 when none is in flight. */ let blitAt = -1 let lastCells = '' @@ -987,52 +946,20 @@ export const register: Register = (on, options) => { // A one-time tip, the settings now in: the other scenes, or the sound. With no prompt to toast over, what's on is noted. const tip = await takeTip($, cfg, !e.isInteractive) if (tip) $.ui.toast(tip, { timeoutMs: 12_000 }) - - // A clip: one of the plugin's own (`asset`) or synthesized here (base64 WAV). Claude Code plays at - // most MAX_PLAYS at once for a plugin: a clip finding them all going stops the oldest event clip - // (`take` undefined) first (a tail cut beats a strike unheard, or the bed dropping out); a refused - // one tries again a moment on (a bed's take stopping an event clip each time: busy events never - // keep the bed out). - const play = (clip: { asset: string } | { base64: string; mime: string }, gain = 1, take?: number, tries = 0, volley = false): void => { - const event = take === undefined - if ((tries === 0 || !event) && sound.playing.size >= MAX_PLAYS) sound.events.shift()?.abort() - const stop = new AbortController() - sound.playing.add(stop) - if (volley) sound.volley = stop - if (event) sound.events.push(stop) - else sound.takes.set(take, stop) - // No player (a Linux or Windows terminal), or refused for good: just silence. Refused because the - // player's full, it tries again a moment on: unless the soundscape has stopped since, or (a bed's - // take) the planner has stopped that take (it took its entry out of `takes`). - const gen = sound.gen - let retrying = false - // (Every clip at the sound's volume, as it is when it starts: never past the gain a clip clips at.) - void $.audio - .play(clip, { gain: volumeGain(gain, cfg.volume), signal: stop.signal }) - .catch((err: unknown) => { - if (!String(err).includes('at once') || stop.signal.aborted) return - if (tries >= 4) { - // A bed's take refused for good: its next is due now, so the bed comes back next frame. - if (take !== undefined && sound.takes.get(take) === stop && current(gen)) bedFailed(sound.bed, take, sound.clock) - return - } - retrying = true - $.clock.after(50, () => { - if (!current(gen) || (take !== undefined && sound.takes.get(take) !== stop)) return - play(clip, gain, take, tries + 1, volley) - }) - }) - .finally(() => { - sound.playing.delete(stop) - const i = sound.events.indexOf(stop) - if (i >= 0) sound.events.splice(i, 1) - if (!retrying && take !== undefined && sound.takes.get(take) === stop) sound.takes.delete(take) - }) + // On a Claude Code older than Flow is built against: said once for that version (it runs all the same). + const warning = e.isInteractive ? await versionWarning($) : undefined + if (warning) $.ui.toast(warning, { timeoutMs: 12_000 }) + + // Claude Code's player: at most MAX_PLAYS clips at once for a plugin, a fifth refused ("4 plays are going + // at once"). No player (a Linux or Windows terminal): every clip refused, just silence. + sound.player = { + maxPlays: MAX_PLAYS, + play: (clip, gain, signal) => $.audio.play(clip, { gain, signal }), + full: err => String(err).includes('at once'), + after: (ms, fn) => { + $.clock.after(ms, fn) + }, } - /** What the scene on show has to be heard this frame (one array, emptied each frame). */ - const events: SoundEvent[] = [] - /** What the bed's planner is told each heard frame (one object, its fields set afresh). */ - const mood: SoundMood = { scene: '', level: 0, tint: 'normal', night: false, amb: {}, volume: cfg.volume } // The frame loop: its own pace, rescheduled each tick. let wasShown = driver.isShown() @@ -1044,82 +971,14 @@ export const register: Register = (on, options) => { const deskSite = desktopSite() // The soundscape, while the scene is on screen: beds crossfading one // into the next, and what happens on screen heard as it happens. - sound.clock += elapsed - const heard = !sound.over && sound.present && cfg.sound === 'on' && (site || deskSite) && driver.isShown() - // A wait on the person beginning: a soft chime (once a wait; not for one right behind another). - const chime = chimeStep(sound.chime, driver.waiting(), sound.clock) - const shownScene = site ? driver.scene : deskSite ? desktopDriver.scene : undefined - events.length = 0 - for (const sc of [driver.scene, desktopDriver.scene]) { - if (!sc.sounds) continue - if (heard && sc === shownScene) events.push(...sc.sounds) - sc.sounds.length = 0 - } - if (!heard || !shownScene) { - if (sound.scene) stopSound() - } else { - // Another scene (or the first heard frame): what played before stops, before anything of this one starts. - if (cfg.style !== sound.scene) { - stopSound() - sound.scene = cfg.style - } - // Events with a clip of their own play now; the rest gather into a burst. - for (const e of events) { - // A gun fallen silent: the rest of its volley's clip, shots never fired, isn't heard. - if (e.kind === 'cease') { - sound.volley?.abort() - sound.volley = undefined - continue - } - const p = eventPlay(e, sound.seed++, cfg.style, shownScene.strength) - if (!p) { - // (Somewhere since the last frame, at random: a frame's pops all at once would buzz at the frame rate.) - const at = sound.clock - Math.min(elapsed, 150) * unit(sound.seed++) - gather(sound.queue, sound.seen++, { ...e, at }, sound.seed++) - continue - } - // Each is heard, however close: one of a kind at most every SOUND_STAGGER_MS, a close one put back - // a moment (two rocks striking together are two booms; any nearer, the ear hears one). - const at = Math.max(sound.clock, sound.nextOf.get(e.kind) ?? 0) - if (at - sound.clock > 4 * SOUND_STAGGER_MS) continue - sound.nextOf.set(e.kind, at + SOUND_STAGGER_MS) - if (at === sound.clock) play({ asset: p.asset }, p.gain, undefined, 0, p.volley) - else { - const gen = sound.gen - $.clock.after(at - sound.clock, () => current(gen) && play({ asset: p.asset }, p.gain, undefined, 0, p.volley)) - } - } - mood.scene = cfg.style - // (A rocket acts its level out a stage at a time: its own strength is the one to hear.) - mood.level = shownScene.strength - mood.tint = driver.tint() - mood.night = driver.isNight() - mood.amb = shownScene.ambience?.() ?? {} - // (The volume too: a change crossfades a fresh take in rather than wait for the next.) - mood.volume = cfg.volume - const beds = bedStep(sound.bed, mood, sound.clock, sound.seed++) - for (const id of beds.stop) { - sound.takes.get(id)?.abort() - sound.takes.delete(id) - } - for (const p of beds.play) play({ asset: p.asset }, p.gain, p.id) - if (chime) { - const c = chimePlay(sound.seed++) - play({ asset: c.asset }, c.gain) - } - if (sound.clock >= sound.nextBurst) { - // Each event at its moment in the window since the last burst. - const from = Math.max(sound.lastBurst, sound.clock - 2 * SOUND_BURST_MS) - sound.lastBurst = sound.clock - sound.nextBurst = sound.clock + SOUND_BURST_MS - if (sound.queue.length) { - const wav = burst(sound.queue.map(e => ({ ...e, offset: Math.max(0, (e.at - from) / 1000) })), sound.seed++) - if (wav) play({ base64: wav, mime: 'audio/wav' }, master(cfg.style, shownScene.strength)) - sound.queue.length = 0 - sound.seen = 0 - } - } - } + sound.step(elapsed, { + heard: (site !== null || deskSite !== undefined) && driver.isShown(), + shown: site ? driver.scene : deskSite ? desktopDriver.scene : undefined, + scenes: [driver.scene, desktopDriver.scene], + waiting: driver.waiting(), + tint: driver.tint(), + night: driver.isNight(), + }) // Every second or so, while it's the balloon, note its altitude if it moved, so a /config change made // from the menu (a reload) resumes it too. if (ticks % 15 === 0 && cfg.style === 'balloon') { @@ -1465,7 +1324,7 @@ export const register: Register = (on, options) => { // is written to /config (only `/flow save` does that). const goesOn = e.reason === 'clear' || e.reason === 'resume' if (!goesOn) sound.over = true - stopSound() + sound.stop() // What it waited on you for was its own: none carries over to the session the process moves to. activity.forgetWaits() // A /clear or a resume from inside the session: the process goes on under another id, with no diff --git a/hooks/settings.ts b/hooks/settings.ts index 26617ee..6d92d99 100644 --- a/hooks/settings.ts +++ b/hooks/settings.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v176-directory-lints +// REVISION: flow-v179-built-against // // 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 @@ -260,8 +260,40 @@ function differingItems(a: FlowConfig, b: FlowConfig): Item[] { const inWords = (cfg: FlowConfig, items: readonly Item[]) => items.map(i => itemText(cfg, i)).join(', ') -/** What the harness can do that `/flow` speaks of: side panes (the spine, the picker's thumbnails), and a player for the soundscape. */ -export type Host = { panes: boolean; sound: boolean } +/** + * What the harness can do that `/flow` speaks of: side panes (the spine, the picker's thumbnails), and a player + * for the soundscape. `thumbnails: false`: panes for the spine, but `/flow pick` is a list (opencode's). + */ +export type Host = { panes: boolean; sound: boolean; thumbnails?: boolean } +/** + * The release of each host Flow is built and tested against (README's Compatibility): the oldest it's known to + * work on. On an older one it runs all the same, and says so once for that version (`versionNote`). + */ +export const BUILT_AGAINST = { 'Claude Code': '2.1.289', opencode: '2.0.29', pi: '1.0.0' } as const +export type HostName = keyof typeof BUILT_AGAINST + +/** A version's numbers (`v2.0.29`, `2.1.280-dev.…`: the leading three), or none when it has none. */ +function versionParts(v: string): number[] | undefined { + const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(v) + return m ? [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)] : undefined +} + +/** Whether version `a` is older than `b`. One that can't be read is never older (no warning on a guess). */ +export function olderThan(a: string, b: string): boolean { + const x = versionParts(a) + const y = versionParts(b) + if (!x || !y) return false + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! + return false +} + +/** What to say on a host older than Flow is built against (none on one as new or newer, or unknown). */ +export function versionNote(host: HostName, version: string | undefined): string | undefined { + const min = BUILT_AGAINST[host] + if (!version || !olderThan(version, min)) return undefined + return `flow: built for ${host} ${min} or later, and this is ${version.replace(/^v/, '')}: it runs, but if a scene misbehaves, update ${host}.` +} + /** Claude Code: both. */ export const CLAUDE_CODE: Host = { panes: true, sound: true } @@ -325,7 +357,7 @@ export function helpText(agent = "Claude's", host: Host = CLAUDE_CODE): string { const lines = [ 'ambient scenes that move with the work; each session keeps its own settings', ` /flow <name> pick a scene: ${SCENES}`, - panes + panes && host.thumbnails !== false ? ' /flow pick every scene live, side by side: arrows move, Enter picks, Esc closes' : ' /flow pick choose a scene from a list', ' /flow next the next scene', @@ -506,6 +538,18 @@ export function firstTips(usedBefore: boolean, settings: FlowConfig): Tips { return usedBefore || changed ? { scenesTold: true, soundTold: true } : {} } +/** + * A chance for a one-time tip over the tips as they're kept (raw, as stored): the tip, if one's due, the tips + * to keep, and whether they changed (else there's nothing to write). `noteOnly`: no chance (no prompt to toast + * over), but what's on is noted all the same (see noteTips): the sound turned on and off again in such a + * session still counts as tried. Each adapter keeps the tips its own way; this is the rest. + */ +export function tipChance(kept: unknown, cfg: FlowConfig, noteOnly = false): { tip?: string; tips: Tips; changed: boolean } { + const before = readTips(kept) + const { tip, tips } = noteOnly ? { tip: undefined, tips: noteTips(before, cfg) } : nextTip(before, cfg) + return { tip, tips, changed: JSON.stringify(tips) !== JSON.stringify(before) } +} + /** * A chance to give a tip (a `/flow` just run, or a session starting), with the * settings as they now are: the tip to give, if any, and the tips to keep. diff --git a/hooks/soundscape.ts b/hooks/soundscape.ts new file mode 100644 index 0000000..5787f39 --- /dev/null +++ b/hooks/soundscape.ts @@ -0,0 +1,260 @@ +// REVISION: flow-v1-shared-soundscape +// +// The soundscape a frame at a time, for any host with a player: Claude Code's +// `audio.play` (register.tsx) and, in opencode, afplay run in the background +// (opencode/player.ts). What a frame brings: the scene's events (a clip each, +// staggered, or gathered into a burst a second at a time), its bed (bedStep's +// takes crossfading), the chime when a wait on the person begins. sound.ts +// plans it; this plays it, over a small `Player` each host implements, and +// keeps what's playing so it can be stopped. Pure: no engine, no Node. + +import type { FlowConfig } from './settings' +import { + type BedTake, + bedFailed, + bedStep, + burst, + chimePlay, + chimeStep, + eventPlay, + gather, + master, + newChimeState, + type SoundEvent, + type SoundMood, + unit, + volumeGain, +} from './sound' +import type { Scene, Tint } from './styles' + +/** + * Small events are gathered this long (ms) and played together, each at its moment: each clip holds one of + * the player's few plays for its length and almost a second more (afplay starting and draining), so not much shorter. + */ +export const SOUND_BURST_MS = 1000 +/** The nearest two events of a kind with clips play (nearer, the ear hears one). */ +export const SOUND_STAGGER_MS = 120 +/** A play refused because the player is full tries again this soon, this many times. */ +const RETRY_MS = 50 +const RETRIES = 4 + +/** A clip: one of the plugin's own (`asset`: its path in the plugin's folder, sounds/…) or synthesized here (base64 WAV). */ +export type Clip = { asset: string } | { base64: string; mime: string } + +/** What a host plays with. */ +export interface Player { + /** How many clips it plays at once: a clip finding them all going stops the oldest event clip first. */ + readonly maxPlays: number + /** Play a clip at a gain fixed as it starts: settles when it has played out or been stopped (`signal`), rejects when refused. */ + play(clip: Clip, gain: number, signal: AbortSignal): Promise<void> + /** Whether a refusal was the player being full (it's tried again a moment on), not for good. */ + full(err: unknown): boolean + /** Run `fn` in `ms`. */ + after(ms: number, fn: () => void): void +} + +/** What the soundscape hears of a frame. */ +export type SoundFrame = { + /** Whether it may be heard now (besides someone being there and the sound on): the scene is on screen. */ + heard: boolean + /** The scene on show (whose events and bed are heard), if any. */ + shown: Scene | undefined + /** Every scene stepped this frame: each one's events are taken, heard or not (only the shown one's are heard). */ + scenes: readonly Scene[] + /** Whether a wait on the person is on (the chime), the tint, the night. */ + waiting: boolean + tint: Tint + night: boolean +} + +export class Soundscape { + /** Someone has been seen at the session: nothing is heard till then. */ + present = false + /** The session has ended for good: the frames still running till the process goes start nothing (a clip begun then plays out in full). */ + over = false + + /** The clips playing (each stoppable), and the soundscape's own clock (ms). */ + private playing = new Set<AbortController>() + private clock = 0 + /** Each bed layer's take (`bedStep` keeps them), and their players by id. */ + private bed: (BedTake | undefined)[] = [] + private takes = new Map<number, AbortController>() + /** When the next burst of small events is due, and when the last went. */ + private nextBurst = 0 + private lastBurst = 0 + /** The small events gathered since the last burst, and how many the window has seen (more than it holds when busy). */ + private queue: (SoundEvent & { at: number })[] = [] + private seen = 0 + /** When each kind of event with a clip of its own may play next (a closer one waits till then). */ + private nextOf = new Map<string, number>() + /** Bumped whenever the soundscape stops (a new scene, sound off, hidden): what was scheduled before is stale. */ + private gen = 0 + /** When the waits on the person began and ended, and the last chime (chimeStep keeps it). */ + private chime = newChimeState() + /** The event clips playing, oldest first (the first to give way when the player is full). */ + private events: AbortController[] = [] + /** The last volley's clip (a fast gun's run of shots), stopped early when its gun falls silent (`cease`). */ + private volley: AbortController | undefined + private seed = 1 + /** The scene heard now ('' when none). */ + private scene = '' + /** This frame's events (one array, emptied each frame), and what the bed's planner is told (one object, set afresh). */ + private frameEvents: SoundEvent[] = [] + private mood: SoundMood = { scene: '', level: 0, tint: 'normal', night: false, amb: {} } + + constructor( + /** The settings shown (the scene, the sound, its volume), read each frame. */ + private readonly cfg: FlowConfig, + /** What plays the clips: none yet (Claude Code's comes with the session's engine), nothing is heard. */ + public player?: Player, + ) {} + + /** + * Whether what was scheduled in generation `gen` may still play: the soundscape hasn't stopped since, and + * no change waits for the next frame to stop it (another scene picked, the sound turned off). + */ + private current(gen: number): boolean { + return this.gen === gen && this.cfg.sound === 'on' && this.cfg.style === this.scene + } + + /** Stop everything playing now, and whatever was scheduled (a new generation: it never plays). */ + stop(): void { + this.gen++ + for (const c of this.playing) c.abort() + this.playing.clear() + this.takes.clear() + this.events.length = 0 + this.volley = undefined + this.bed = [] + this.queue.length = 0 + this.seen = 0 + this.scene = '' + } + + /** + * Play a clip. The player plays at most `maxPlays` at once: a clip finding them all going stops the oldest + * event clip (`take` undefined) first (a tail cut beats a strike unheard, or the bed dropping out); a refused + * one tries again a moment on (a bed's take stopping an event clip each time: busy events never keep the + * bed out). No player, or refused for good: just silence. + */ + private play(clip: Clip, gain = 1, take?: number, tries = 0, volley = false): void { + const player = this.player + if (!player) return + const event = take === undefined + if ((tries === 0 || !event) && this.playing.size >= player.maxPlays) this.events.shift()?.abort() + const stop = new AbortController() + this.playing.add(stop) + if (volley) this.volley = stop + if (event) this.events.push(stop) + else this.takes.set(take, stop) + // Refused because the player's full, it tries again a moment on: unless the soundscape has stopped since, or + // (a bed's take) the planner has stopped that take (it took its entry out of `takes`). + const gen = this.gen + let retrying = false + // (Every clip at the sound's volume, as it is when it starts: never past the gain a clip clips at.) + void player + .play(clip, volumeGain(gain, this.cfg.volume), stop.signal) + .catch((err: unknown) => { + if (!player.full(err) || stop.signal.aborted) return + if (tries >= RETRIES) { + // A bed's take refused for good: its next is due now, so the bed comes back next frame. + if (take !== undefined && this.takes.get(take) === stop && this.current(gen)) bedFailed(this.bed, take, this.clock) + return + } + retrying = true + player.after(RETRY_MS, () => { + if (!this.current(gen) || (take !== undefined && this.takes.get(take) !== stop)) return + this.play(clip, gain, take, tries + 1, volley) + }) + }) + .finally(() => { + this.playing.delete(stop) + const i = this.events.indexOf(stop) + if (i >= 0) this.events.splice(i, 1) + if (!retrying && take !== undefined && this.takes.get(take) === stop) this.takes.delete(take) + }) + } + + /** One frame, `elapsed` ms after the last: beds crossfading one into the next, and what happens on screen heard as it happens. */ + step(elapsed: number, f: SoundFrame): void { + this.clock += elapsed + const heard = f.heard && !this.over && this.present && this.cfg.sound === 'on' && this.player !== undefined + // A wait on the person beginning: a soft chime (once a wait; not for one right behind another). + const chime = chimeStep(this.chime, f.waiting, this.clock) + const shown = f.shown + const events = this.frameEvents + events.length = 0 + for (const sc of f.scenes) { + if (!sc.sounds) continue + if (heard && sc === shown) events.push(...sc.sounds) + sc.sounds.length = 0 + } + if (!heard || !shown) { + if (this.scene) this.stop() + return + } + // Another scene (or the first heard frame): what played before stops, before anything of this one starts. + const style = this.cfg.style + if (style !== this.scene) { + this.stop() + this.scene = style + } + // Events with a clip of their own play now; the rest gather into a burst. + for (const e of events) { + // A gun fallen silent: the rest of its volley's clip, shots never fired, isn't heard. + if (e.kind === 'cease') { + this.volley?.abort() + this.volley = undefined + continue + } + const p = eventPlay(e, this.seed++, style, shown.strength) + if (!p) { + // (Somewhere since the last frame, at random: a frame's pops all at once would buzz at the frame rate.) + const at = this.clock - Math.min(elapsed, 150) * unit(this.seed++) + gather(this.queue, this.seen++, { ...e, at }, this.seed++) + continue + } + // Each is heard, however close: one of a kind at most every SOUND_STAGGER_MS, a close one put back + // a moment (two rocks striking together are two booms; any nearer, the ear hears one). + const at = Math.max(this.clock, this.nextOf.get(e.kind) ?? 0) + if (at - this.clock > 4 * SOUND_STAGGER_MS) continue + this.nextOf.set(e.kind, at + SOUND_STAGGER_MS) + if (at === this.clock) this.play({ asset: p.asset }, p.gain, undefined, 0, p.volley) + else { + const gen = this.gen + this.player?.after(at - this.clock, () => this.current(gen) && this.play({ asset: p.asset }, p.gain, undefined, 0, p.volley)) + } + } + const mood = this.mood + mood.scene = style + // (A rocket acts its level out a stage at a time: its own strength is the one to hear.) + mood.level = shown.strength + mood.tint = f.tint + mood.night = f.night + mood.amb = shown.ambience?.() ?? {} + // (The volume too: a change crossfades a fresh take in rather than wait for the next.) + mood.volume = this.cfg.volume + const beds = bedStep(this.bed, mood, this.clock, this.seed++) + for (const id of beds.stop) { + this.takes.get(id)?.abort() + this.takes.delete(id) + } + for (const p of beds.play) this.play({ asset: p.asset }, p.gain, p.id) + if (chime) { + const c = chimePlay(this.seed++) + this.play({ asset: c.asset }, c.gain) + } + if (this.clock >= this.nextBurst) { + // Each event at its moment in the window since the last burst. + const from = Math.max(this.lastBurst, this.clock - 2 * SOUND_BURST_MS) + this.lastBurst = this.clock + this.nextBurst = this.clock + SOUND_BURST_MS + if (this.queue.length) { + const wav = burst(this.queue.map(e => ({ ...e, offset: Math.max(0, (e.at - from) / 1000) })), this.seed++) + if (wav) this.play({ base64: wav, mime: 'audio/wav' }, master(style, shown.strength)) + this.queue.length = 0 + this.seen = 0 + } + } + } +} diff --git a/opencode/mapping.ts b/opencode/mapping.ts new file mode 100644 index 0000000..7ab98e4 --- /dev/null +++ b/opencode/mapping.ts @@ -0,0 +1,310 @@ +// REVISION: flow-v3-opencode-deleted +// +// 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 +// opencode or Node imports), so the shared tests cover it. +// +// session.execution.started / ended a turn of the session on screen, or a subagent's run +// session.step.started a model step; the model's variant (its effort: low … max) sets the floor +// session.text.delta, reasoning.delta streamed text and thinking keep it going +// session.tool.input.started / called a tool call: its name, then its input (edits push it, commands spark) +// session.tool.success / failed it's over; a shell that failed (its exit code) shows as smoke +// session.created (with a parentID) a subagent: a companion of its own (its session deleted: it's gone) +// permission.asked, form.created a question put to the person: the scene settles and breathes +// session.compaction.ended smoke, as a compaction is in Claude Code +// session.usage.updated how full the context is (the step's tokens over the model's limit): blue + +import { type Activity, countLines, type Effort } from '../hooks/activity' +import type { ListedAgent } from '../hooks/agents' + +/** One of opencode's events, as far as Flow reads it: its type and its data. */ +export type OpenCodeEvent = { type: string; data?: unknown } + +/** Tools that run commands, read, and launch subagents, by opencode's names (an MCP server's or a plugin's are other work). */ +export const COMMAND_TOOLS = new Set(['shell', 'bash']) +export const READ_TOOLS = new Set(['read', 'grep', 'glob', 'list', 'ls', 'find', 'webfetch', 'websearch']) +export const AGENT_TOOLS = new Set(['subagent', 'task']) + +/** A model variant as the heat model's effort: opencode's names for how hard it thinks (a provider's own, or `default`, set no floor of their own). */ +export function effortOf(variant: string | undefined): Effort { + switch (variant) { + case 'minimal': + case 'low': + return 'low' + case 'medium': + case 'high': + case 'xhigh': + case 'max': + return variant + default: + return undefined + } +} + +/** Lines an opencode write or edit call writes: `content`, `newString`, or a patch's added lines. */ +export function linesWritten(tool: string, input: unknown): number | undefined { + const i = (input ?? {}) as Record<string, unknown> + switch (tool) { + case 'write': + return countLines(i.content) + case 'edit': + return countLines(i.newString) + case 'multiedit': + return Array.isArray(i.edits) ? i.edits.reduce((n: number, ed) => n + countLines((ed as Record<string, unknown>)?.newString), 0) : 0 + case 'patch': + case 'apply_patch': { + const text = typeof i.patch === 'string' ? i.patch : typeof i.patchText === 'string' ? i.patchText : '' + let n = 0 + for (const line of text.split('\n')) if (line.startsWith('+') && !line.startsWith('+++')) n++ + return n + } + default: + return undefined + } +} + +/** A tool that ended, as `session.tool.success` or `.failed` has it: whether it failed (a command, by its exit code). */ +function commandFailed(type: string, data: Record<string, unknown>): boolean { + if (type === 'session.tool.failed') return true + const exit = (data.metadata as Record<string, unknown> | undefined)?.exit + return typeof exit === 'number' && exit !== 0 +} + +/** A session's tokens so far: what its steps were sent (fresh and cached) and what they wrote. */ +type Tokens = { input?: number; output?: number; cache?: { read?: number; write?: number } } +const contextTokens = (t: Tokens) => (t.input ?? 0) + (t.output ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0) + +const ENDED = new Set(['session.execution.succeeded', 'session.execution.failed', 'session.execution.interrupted']) + +export class OpenCodeWork { + /** The session on screen (its id), whose work the scene follows. */ + private root: string | undefined + /** Each subagent's session and the session that started it. */ + private parents = new Map<string, string>() + /** The subagents of the session on screen, in the order they started, as the roster lists them. */ + private children = new Map<string, ListedAgent>() + /** Each tool call's name, by its id (its name comes before its input). */ + private calls = new Map<string, string>() + /** The tool calls of the session on screen running now. */ + private running = new Set<string>() + /** Each session's tokens so far, as its last usage said: a step's are the difference. */ + private used = new Map<string, number>() + /** Each session's model variant as its events said (created with, or selected since). */ + private variants = new Map<string, string | undefined>() + + constructor( + readonly activity: Activity, + /** The context limit of a session's model, if known. */ + private readonly contextLimit: (sessionID: string) => number | undefined = () => undefined, + /** A session's model variant as opencode has it, for one whose events haven't said (begun before Flow came up). */ + private readonly variantOf: (sessionID: string) => string | undefined = () => undefined, + ) {} + + /** The variant a session's steps run at: as its events said, else as opencode has it. */ + private variant(sessionID: string): string | undefined { + return this.variants.has(sessionID) ? this.variants.get(sessionID) : this.variantOf(sessionID) + } + + /** The session on screen, if any. */ + get session(): string | undefined { + return this.root + } + + /** + * Another session on screen (or none): the scene follows its work from now on (`running`: a turn of + * it is under way). What was going on in the last one (its turn, its tools, its subagents, what it + * waited on you for) is no longer this scene's. + */ + show(sessionID: string | undefined, running = false): void { + if (sessionID === this.root) return + this.root = sessionID + this.children.clear() + this.running.clear() + this.calls.clear() + const a = this.activity + a.turnEnded() + a.forgetWaits() + a.toolsInFlight = 0 + a.runningAgents = 0 + a.contextPercent = 0 + a.roster.listed([]) + if (running) a.turnStarted() + } + + /** Whose a session is: the main loop's, a subagent's (its id: a subagent's own subagents count as its work), or none of this scene's. */ + private loopOf(sessionID: unknown): { main: boolean; agent?: string } | undefined { + if (typeof sessionID !== 'string' || this.root === undefined) return undefined + if (sessionID === this.root) return { main: true } + let id = sessionID + for (let hops = 0; hops < 8; hops++) { + const parent = this.parents.get(id) + if (parent === undefined) return undefined + if (parent === this.root) return { main: false, agent: id } + id = parent + } + return undefined + } + + /** The roster told of the subagents as they are now, and how many are running. */ + private list(): void { + const all = [...this.children.values()] + this.activity.roster.listed(all) + this.activity.runningAgents = all.filter(c => c.status === 'running').length + } + + /** One of opencode's events. */ + event(e: OpenCodeEvent): void { + const data = (e.data ?? {}) as Record<string, unknown> + const a = this.activity + switch (e.type) { + case 'session.created': + case 'session.model.selected': { + const id = data.sessionID + if (typeof id === 'string') this.variants.set(id, (data.model as { variant?: string } | undefined)?.variant) + if (e.type === 'session.model.selected') return + const parent = data.parentID + if (typeof id !== 'string' || typeof parent !== 'string') return + this.parents.set(id, parent) + // A subagent of the session on screen (one of its subagents' own is their work, not a companion). + if (parent !== this.root) return + this.children.set(id, { + id, + status: 'running', + type: typeof data.agent === 'string' ? data.agent : '', + description: typeof data.title === 'string' ? data.title : '', + }) + this.list() + return + } + case 'session.deleted': { + // 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 + child.status = 'failed' + this.activity.roster.finished(child.id, false) + this.list() + return + } + case 'session.renamed': { + const child = typeof data.sessionID === 'string' ? this.children.get(data.sessionID) : undefined + if (child && typeof data.title === 'string') { + child.description = data.title + this.list() + } + return + } + case 'form.created': { + const form = data.form as { id?: unknown; sessionID?: unknown } | undefined + const loop = this.loopOf(form?.sessionID) + if (loop && typeof form?.id === 'string') a.waitingOn(form.id, true, undefined, loop.agent) + return + } + } + const loop = this.loopOf(data.sessionID) + if (!loop) return + const { main, agent } = loop + const sub = !main + // Whatever a subagent does, it's heard from: working. + if (agent !== undefined && !ENDED.has(e.type)) a.roster.heard(agent) + switch (e.type) { + case 'session.execution.started': { + if (main) a.turnStarted() + else { + const child = this.children.get(agent!) + if (child && child.status !== 'running') { + child.status = 'running' + this.list() + } + } + return + } + case 'session.execution.succeeded': + case 'session.execution.failed': + case 'session.execution.interrupted': { + const ok = e.type === 'session.execution.succeeded' + if (main) { + a.turnEnded() + this.running.clear() + a.toolsInFlight = 0 + if (e.type === 'session.execution.failed') a.failed() + return + } + const child = this.children.get(agent!) + if (!child || child.status !== 'running') return + child.status = ok ? 'completed' : 'failed' + a.roster.finished(agent!, ok) + this.list() + return + } + case 'session.step.started': + a.modelStep(main ? effortOf(this.variant(data.sessionID as string)) : undefined, sub) + return + case 'session.text.delta': + case 'session.reasoning.delta': { + const delta = data.delta + if (typeof delta === 'string' && delta) a.streamed(delta.length, e.type === 'session.text.delta' ? 'text' : 'thinking', sub) + return + } + case 'session.tool.input.started': + if (typeof data.id === 'string' && typeof data.name === 'string') this.calls.set(data.id, data.name) + return + case 'session.tool.called': { + const id = data.id + if (typeof id !== 'string') return + const name = this.calls.get(id) ?? '' + const lines = linesWritten(name, data.input) + if (lines !== undefined) a.edited(lines, sub) + else if (COMMAND_TOOLS.has(name)) a.ranCommand(sub) + else if (READ_TOOLS.has(name)) a.read(sub) + else if (AGENT_TOOLS.has(name)) { + if (main) a.spawnedAgent() + } else a.usedTool(sub) + a.called(id, name, agent, typeof data.input === 'object' && data.input !== null ? data.input : undefined) + if (main) { + this.running.add(id) + a.toolsInFlight = this.running.size + } else a.roster.toolStarted(agent!) + return + } + case 'session.tool.success': + case 'session.tool.failed': { + const id = data.id + if (typeof id !== 'string') return + const name = this.calls.get(id) ?? '' + this.calls.delete(id) + a.answered(id, name, agent) + if (main) { + this.running.delete(id) + a.toolsInFlight = this.running.size + if (COMMAND_TOOLS.has(name) && commandFailed(e.type, data)) a.failed() + } else a.roster.toolEnded(agent!) + return + } + case 'permission.asked': + if (typeof data.id === 'string') a.waitingOn(data.id, true, undefined, agent) + return + case 'permission.replied': + if (typeof data.requestID === 'string') a.answered(data.requestID, undefined, agent) + return + case 'form.replied': + case 'form.cancelled': + if (typeof data.id === 'string') a.answered(data.id, undefined, agent) + return + case 'session.compaction.ended': + if (main) a.compacted() + return + case 'session.usage.updated': { + if (!main) return + const sid = data.sessionID as string + const total = contextTokens((data.tokens ?? {}) as Tokens) + const before = this.used.get(sid) + this.used.set(sid, total) + // The first heard since Flow came up is the session's total so far, not a step's: the next one says. + if (before === undefined || total <= before) return + const limit = this.contextLimit(sid) + if (limit && limit > 0) a.contextPercent = Math.min(100, (100 * (total - before)) / limit) + return + } + } + } +} diff --git a/opencode/opentui.d.ts b/opencode/opentui.d.ts new file mode 100644 index 0000000..a676728 --- /dev/null +++ b/opencode/opentui.d.ts @@ -0,0 +1,43 @@ +// REVISION: flow-v1-opencode +// +// What Flow uses of OpenTUI (@opentui/core 0.5), which opencode provides to a +// plugin's terminal UI at run time: a renderable that paints its own cells. +// Declared here so the typecheck needs nothing installed. + +declare module '@opentui/core' { + export interface RGBA { + readonly r: number + } + export interface MouseEvent { + readonly x: number + readonly y: number + } + export interface OptimizedBuffer { + setCell(x: number, y: number, char: string, fg: RGBA, bg: RGBA, attributes?: number): void + setCellWithAlphaBlending(x: number, y: number, char: string, fg: RGBA, bg: RGBA, attributes?: number): void + } + export interface RenderContext { + readonly width: number + readonly height: number + } + export interface RenderableOptions { + width?: number | 'auto' | `${number}%` + height?: number | 'auto' | `${number}%` + flexShrink?: number + } + export abstract class Renderable { + constructor(ctx: RenderContext, options: RenderableOptions) + get x(): number + get y(): number + get width(): number + set width(value: number | 'auto' | `${number}%`) + get height(): number + set height(value: number | 'auto' | `${number}%`) + get isDestroyed(): boolean + get visible(): boolean + set visible(value: boolean) + requestRender(): void + set onMouseDown(handler: ((event: MouseEvent) => void) | undefined) + protected renderSelf(buffer: OptimizedBuffer, deltaTime: number): void + } +} diff --git a/opencode/player.ts b/opencode/player.ts new file mode 100644 index 0000000..4373396 --- /dev/null +++ b/opencode/player.ts @@ -0,0 +1,66 @@ +// REVISION: flow-v1-opencode +// +// 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 +// 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 +// (sounds/) and those bursts are ever played; nothing else is run. No afplay +// (not macOS): no player, and the scenes are silent. + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { Clip, Player } from '../hooks/soundscape' + +const AFPLAY = '/usr/bin/afplay' +/** As many at once as Claude Code allows a plugin: the soundscape's tuning (volleys, thinning) assumes it. */ +const MAX_PLAYS = 4 + +/** The player, where afplay is (macOS); none elsewhere. `root`: the plugin's folder (a clip's asset is its path there). */ +export function afplayPlayer(root: string): Player | undefined { + if (process.platform !== 'darwin' || !existsSync(AFPLAY)) return undefined + const bursts = join(tmpdir(), `flow-${process.pid}`) + let made = 0 + let playing = 0 + + /** Run afplay on a file at a gain till it ends or `signal` stops it. */ + const run = (file: string, gain: number, signal: AbortSignal) => + new Promise<void>(resolve => { + if (signal.aborted) return resolve() + const child = spawn(AFPLAY, ['-v', gain.toFixed(3), file], { stdio: 'ignore' }) + const stop = () => child.kill() + signal.addEventListener('abort', stop, { once: true }) + child.once('error', () => resolve()) + child.once('exit', () => { + signal.removeEventListener('abort', stop) + resolve() + }) + }) + + return { + maxPlays: MAX_PLAYS, + async play(clip: Clip, gain: number, signal: AbortSignal): Promise<void> { + // (The soundscape keeps to maxPlays; this holds it there should a stop not have landed yet.) + if (playing >= MAX_PLAYS + 2) throw new Error('flow: plays are going at once') + playing++ + try { + if ('asset' in clip) return await run(join(root, clip.asset), gain, signal) + await mkdir(bursts, { recursive: true }) + const file = join(bursts, `burst-${made++ % 64}.wav`) + await writeFile(file, Buffer.from(clip.base64, 'base64')) + await run(file, gain, signal) + await rm(file, { force: true }) + } finally { + playing-- + } + }, + full: err => String(err).includes('at once'), + after: (ms, fn) => { + setTimeout(fn, ms) + }, + } +} diff --git a/opencode/tui.ts b/opencode/tui.ts new file mode 100644 index 0000000..6a29d7d --- /dev/null +++ b/opencode/tui.ts @@ -0,0 +1,362 @@ +// REVISION: flow-v4-opencode-built-against +// +// 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 +// UI plugin alone: it draws, reads opencode's events and answers `/flow`; +// nothing runs in opencode's server, and nothing it hears is changed. +// +// the band above the prompt of the session on screen (`session.composer.top`), 5 rows +// (a taller scene's view, doom3d's, up to 14, never more than half the terminal) +// the spine `/flow spine`: in the sidebar beside the transcript (`sidebar.content`) +// its cards a press on a companion shows its subagent's task, state and time run (a toast) +// its events mapping.ts: the session's turns, steps (its model's variant sets the floor), streamed text, tool calls (edits push it, a failed +// 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) +// 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 +// +// Settings are per session, as elsewhere: `/flow` changes the session it runs in, kept in Flow's own +// opencode store under the session's id (the 100 used most recently), so reopening it brings them back; +// the defaults new sessions start with are the plugin's options (cli.json) and, over them, what +// `/flow save` kept in the same store, which every running opencode shares. + +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { Activity, FRAME_MS } from '../hooks/activity' +import { agentLine } from '../hooks/agents' +import { staleSessions } from '../hooks/sessions' +import { firstTips, type FlowConfig, type Host, parseFlowArgs, readConfig, replyTo, storedValue, tipChance, type Tips, versionNote } from '../hooks/settings' +import { SceneDriver } from '../hooks/scene' +import { Soundscape } from '../hooks/soundscape' +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 type { OcContext } from './types' +import type { SceneView } from './view' +import { sceneViewClass } from './view' + +const ROWS = 5 +/** A taller scene's rows (`Scene.taller`), and at most this share of the terminal's. */ +const TALL_ROWS = 14 +const TALL_SHARE = 0.5 +/** The spine's rows in the sidebar: what the terminal leaves under opencode's own (its title, context, files), within these. */ +const SPINE_ROWS = [12, 40] as const +const SPINE_LEFT = 18 +/** A session first showing is a chance for a tip this long after (the store has loaded from disk by then). */ +const TIP_AFTER_MS = 1500 +/** How often the defaults are read afresh (another opencode may have saved), and the clock. */ +const DEFAULTS_EVERY_MS = 30_000 +const CLOCK_EVERY_MS = 5_000 +/** Flow's key in opencode's storage for the plugin. */ +const STORE = 'flow' + +/** What Flow keeps in opencode's store: the defaults `/flow save` wrote, each session's own settings, and the one-time tips given. */ +type Kept = { + defaults: Record<string, string | number> + sessions: Record<string, { own: Record<string, string | number>; at: number }> + tips?: Tips + /** The opencode version the note that Flow is built for a newer one was given for (once each). */ + warned?: string +} + +/** The plugin's folder: its sounds/ are played from there. */ +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') + +function setup(ctx: OcContext): () => void { + const player = afplayPlayer(ROOT) + const HOST: Host = { panes: true, sound: player !== undefined, thumbnails: false } + const activity = new Activity() + const driver = new SceneDriver(readConfig(ctx.options), activity) + const cfg = driver.cfg + const sound = new Soundscape(cfg, player) + driver.seedBase = (Date.now() % 2147483647) || 1 + + const [kept, keep] = ctx.storage.store<Kept>(STORE, { initial: { defaults: {}, sessions: {} } }) + /** The defaults: the plugin's options, and what `/flow save` kept over them. */ + const settings = new PiSettings(cfg, { + load: async () => readConfig({ ...ctx.options, ...kept.defaults }), + save: changes => + keep(d => { + for (const [k, v] of Object.entries(changes) as [keyof FlowConfig, FlowConfig[keyof FlowConfig]][]) d.defaults[k] = storedValue(k, v) + }), + }) + /** A session's own settings, kept in the store under its id (as pi keeps them in the session itself). */ + const entriesOf = (sessionID: string): SessionEntries => ({ + branch: () => { + const record = kept.sessions[sessionID] + return record ? [{ type: 'custom', id: sessionID, customType: FLOW_ENTRY, data: { own: record.own } }] : [] + }, + keep: data => { + void keep(d => { + d.sessions[sessionID] = { own: data.own, at: Date.now() } + }) + }, + }) + // The sessions unused longest (past the 100 most recent, or 60 days) are dropped. + const stale = staleSessions( + Object.entries(kept.sessions).map(([key, r]) => ({ key, at: r.at })), + Date.now(), + ) + if (stale.length) void keep(d => stale.forEach(key => delete d.sessions[key])) + + /** + * The one-time tips (settings.ts), the person's, not a session's: begun at the first chance, before that + * chance's `/flow` keeps anything, as someone new (told nothing) or someone who'd had Flow here before (told + * both). Then each chance (a session first showing, a `/flow`) may give one. + */ + let tipsBegun: Tips | undefined + const beginTips = () => { + if (kept.tips !== undefined || tipsBegun) return + const usedBefore = Object.keys(kept.sessions).length > 0 || Object.keys(kept.defaults).length > 0 + tipsBegun = firstTips(usedBefore, readConfig({ ...ctx.options, ...kept.defaults })) + void keep(d => { + d.tips ??= tipsBegun + }) + } + const takeTip = (): string | undefined => { + beginTips() + const { tip, tips, changed } = tipChance(kept.tips ?? tipsBegun, cfg) + if (changed) { + tipsBegun = tips + void keep(d => { + d.tips = tips + }) + } + // (The toast is titled flow already.) + return tip?.replace(/^flow: /, '') + } + + /** The context limit of a session's model. */ + const contextLimit = (sessionID: string) => { + const model = ctx.data.session.get(sessionID)?.model + if (!model) return undefined + const info = ctx.data.location.model.list()?.find(m => m.providerID === model.providerID && (m.id === model.id || m.modelID === model.id)) + return info?.limit?.context + } + /** A session's model variant: its own, else the one the prompt has selected. */ + const variantOf = (sessionID: string) => ctx.data.session.get(sessionID)?.model?.variant ?? ctx.ui.model.current()?.variant + const work = new OpenCodeWork(activity, contextLimit, variantOf) + const View = sceneViewClass(ctx.renderer.root, ctx.theme) + + /** Where the scene is drawn: the band above the prompt, the spine in the sidebar (the one the layout says shows). */ + const views = { band: undefined as SceneView | undefined, spine: undefined as SceneView | undefined } + /** The session on screen, as its slot last said, and the one whose settings are shown. */ + let shownSession: { id: () => string } | undefined + let opened: string | undefined + + /** The rows a view takes now: none while the scene is off, or in the other layout. */ + const rowsFor = (layout: 'band' | 'spine'): number => { + if (!driver.isShown() || cfg.layout !== layout) return 0 + const terminal = ctx.renderer.height + if (layout === 'spine') return Math.max(SPINE_ROWS[0], Math.min(SPINE_ROWS[1], terminal - SPINE_LEFT)) + 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. */ + const frameAt = (columns: number, rows: number) => { + const f = driver.scene + if (!driver.isShown()) return undefined + f.ensure(columns, rows) + return f.grid() + } + /** + * A press on the scene: a companion there (or a cell beside it: they're small) shows its subagent's card, as + * hovering does in Claude desktop: its task, what it's doing, how long it's run. + */ + const pressed = (column: number, row: number) => { + const f = driver.scene + const near = (m: { col: number; row: number; w: number; h: number }) => + column >= m.col - 1 && column <= m.col + m.w && row >= m.row - 1 && row <= m.row + m.h + const mark = f.agentMarks?.().find(near) + const dial = mark && f.agents?.find(d => d.id === mark.id) + if (dial) ctx.ui.toast.show({ title: 'flow: a subagent', message: agentLine(dial) }) + } + const view = (layout: 'band' | 'spine') => (input: { readonly sessionID: string }) => { + const v = new View(ctx.renderer, frameAt, pressed) + const rows = rowsFor(layout) + v.visible = rows > 0 + v.height = Math.max(1, rows) + views[layout] = v + shownSession = { id: () => input.sessionID } + return v + } + const unclaim = [ + ctx.ui.slot({ prepend: 'session.composer.top', render: view('band') }), + ctx.ui.slot({ prepend: 'sidebar.content', render: view('spine') }), + ] + + /** + * The session on screen now: its settings, and the work the scene follows. Its settings are read again + * whenever its record in the store changes: the store loads from disk after Flow starts, and another + * opencode showing the same session may change them. + */ + let openedAt: number | undefined + /** 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 + if (!tipChanceTaken) { + tipChanceTaken = true + setTimeout(() => { + const tip = takeTip() + if (tip) ctx.ui.toast.show({ title: 'flow', message: tip, duration: 12_000 }) + // On an opencode older than Flow is built against: said once for that version (it runs all the same). + const version = ctx.app.version + const note = versionNote('opencode', version) + if (note && kept.warned !== version) { + void keep(d => { + d.warned = version + }) + ctx.ui.toast.show({ title: 'flow', message: note.replace(/^flow: /, ''), variant: 'warning', duration: 12_000 }) + } + }, TIP_AFTER_MS) + } + const at = kept.sessions[id]?.at + if (id === opened && at === openedAt) return + if (id !== opened) work.show(id, ctx.data.session.status(id) === 'running') + opened = id + openedAt = at + void settings.open(entriesOf(id)) + } + + let sinceDefaults = 0 + let sinceClock = CLOCK_EVERY_MS + let timer: ReturnType<typeof setTimeout> | undefined + const frame = (elapsed: number) => { + activity.tick(elapsed / FRAME_MS) + follow() + sinceClock += elapsed + if (sinceClock >= CLOCK_EVERY_MS) { + sinceClock = 0 + const d = new Date() + driver.clock = { hour: d.getHours(), minute: d.getMinutes() } + } + sinceDefaults += elapsed + if (sinceDefaults >= DEFAULTS_EVERY_MS && opened) { + sinceDefaults = 0 + void settings.refresh(entriesOf(opened)) + } + const f = driver.dial() + // The one view of the layout in use, live (a view replaced or gone draws nothing). + let live: SceneView | undefined + for (const layout of ['band', 'spine'] as const) { + const v = views[layout] + if (!v || v.isDestroyed) continue + // (Hidden rather than none tall: a renderable takes a row however short it's made.) + const rows = rowsFor(layout) + if (v.visible !== rows > 0) v.visible = rows > 0 + if (rows > 0 && v.height !== rows) v.height = rows + if (rows > 0 && v.width > 0) live = v + } + if (live) { + f.ensure(live.width, live.height) + f.step() + live.requestRender() + } + sound.step(elapsed, { + heard: live !== undefined, + shown: live ? f : undefined, + scenes: [f], + waiting: driver.waiting(), + tint: driver.tint(), + night: driver.isNight(), + }) + const pace = driver.pace() + timer = setTimeout(() => frame(pace), pace) + } + frame(FRAME_MS) + + const unlisten = ctx.data.listen(({ details }) => { + work.event(details) + // A turn begun on the session on screen: someone's there, so the soundscape may play. + if (details.type === 'session.execution.started' && (details.data as { sessionID?: string } | undefined)?.sessionID === work.session) sound.present = true + }) + + /** `/flow`: the same command as in Claude Code and pi, its reply a toast. */ + const flow = async (input: string | undefined) => { + sound.present = true + const args = (input ?? '').trim().replace(/^\/flow\b/, '') + let cmd = parseFlowArgs(args) + follow() + const entries = opened ? entriesOf(opened) : undefined + beginTips() + await settings.refresh(entries) + // The scene shows in a session: on the home screen there's none to change (the status and the help say all there is). + if (!entries && cmd.kind !== 'show' && cmd.kind !== 'help') { + ctx.ui.toast.show({ title: 'flow', message: 'the scene shows in a session: open one, then /flow there', variant: 'warning' }) + return + } + if (cmd.kind === 'pick') { + const name = await ctx.ui.dialog.select({ + title: 'flow: pick a scene', + options: SCENES.map(d => ({ title: d.name, value: d.name, description: d.blurb })), + current: cfg.style, + }) + if (!name) return + cmd = { kind: 'style', name } + } + if (cmd.kind === 'sound' && !player) { + ctx.ui.toast.show({ title: 'flow', message: 'the soundscapes play through afplay, which only macOS has', variant: 'warning' }) + return + } + const reply = await replyTo(cmd, { + host: HOST, + agent: "opencode's", + cfg, + get clock() { + return driver.clock + }, + get defaults() { + return settings.defaults + }, + level: () => driver.level(), + tint: () => driver.tint(), + save: async () => { + const { text, saved } = await settings.save(entries) + return { text, level: saved ? 'info' : 'warning' } + }, + reset: async () => settings.reset(entries), + change: changes => settings.change(changes, entries), + }) + // A one-time tip goes under the reply, as in Claude Code. + const tip = takeTip() + const text = (cmd.kind === 'error' ? reply.text.replace(/^! /, '') : reply.text) + (tip ? `\n\n${tip}` : '') + // (The help is long: a dialog to read at leisure. The rest is a line or two, a toast.) + if (cmd.kind === 'help') return void (await ctx.ui.dialog.alert({ title: 'flow', message: text })) + ctx.ui.toast.show({ title: 'flow', message: text, variant: reply.level === 'warning' ? 'warning' : 'info', duration: tip ? 12_000 : undefined }) + } + ctx.keymap.layer(() => ({ + mode: 'global', + commands: [ + { + id: 'flow-scenes.flow', + title: 'Flow', + description: 'Ambient scenes above the prompt: /flow [<scene> | pick | next | day | night | auto | 1-10 | off | band | spine | sound | save | reset | help]', + group: 'Flow', + slash: { name: 'flow', arguments: true }, + run: input => flow(input), + }, + ], + })) + + // opencode quitting: the clips playing stop with it (else each would play out, up to a bed's 14 s). + const quit = () => sound.stop() + process.once('exit', quit) + + return () => { + if (timer) clearTimeout(timer) + sound.over = true + sound.stop() + process.off('exit', quit) + unlisten() + for (const f of unclaim) f() + } +} + +export default { id: 'flow-scenes', setup } diff --git a/opencode/types.ts b/opencode/types.ts new file mode 100644 index 0000000..a401a60 --- /dev/null +++ b/opencode/types.ts @@ -0,0 +1,63 @@ +// REVISION: flow-v2-opencode-effort +// +// What Flow uses of opencode's terminal UI plugin API (@opencode/plugin/tui, +// opencode 2.0), declared here so the typecheck needs nothing installed. + +import type { Renderable, RenderContext } from '@opentui/core' + +/** opencode's event, as `data.listen` hands it over. */ +export type OcEvent = { type: string; data?: unknown } + +/** A slot's place in opencode's screen, and what its render is given. */ +export type OcSlot = + | { readonly append: 'session.composer.top' | 'sidebar.content'; readonly render: (input: { readonly sessionID: string }) => unknown } + | { readonly prepend: 'session.composer.top' | 'sidebar.content'; readonly render: (input: { readonly sessionID: string }) => unknown } + +export interface OcSelectOption<Value> { + readonly title: string + readonly value: Value + readonly description?: string +} + +export interface OcCommand { + readonly id?: string + readonly title?: string + readonly description?: string + readonly group?: string + readonly slash?: { readonly name: string; readonly arguments?: true } + readonly run: (input?: string) => void | false | Promise<void> +} + +/** A plugin's durable store: JSON kept on disk, live across running opencodes. */ +export type OcStore<Value> = readonly [Value, (mutation: (draft: Value) => void) => Promise<void>] + +export interface OcContext { + readonly options: Readonly<Record<string, unknown>> + readonly app: { readonly version: string } + readonly renderer: RenderContext & { readonly root: Renderable } + /** opencode's theme: its colours are OpenTUI's RGBA. */ + readonly theme: unknown + readonly data: { + listen(handler: (event: { details: OcEvent }) => void): () => void + readonly session: { + get(sessionID: string): { readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } } | undefined + status(sessionID: string): 'idle' | 'running' + } + readonly location: { + readonly model: { + list(): readonly { readonly id: string; readonly modelID?: string; readonly providerID: string; readonly limit?: { readonly context?: number } }[] | undefined + } + } + } + readonly keymap: { layer(factory: () => { readonly mode?: string; readonly commands?: readonly OcCommand[] }): void } + readonly storage: { store<Value extends object>(key: string, options: { readonly initial: Value }): OcStore<Value> } + readonly ui: { + slot(claim: OcSlot): () => void + readonly model: { current(): { readonly providerID: string; readonly modelID: string; readonly variant?: string } | undefined } + readonly toast: { show(options: { readonly title?: string; readonly message: string; readonly variant?: 'info' | 'warning'; readonly duration?: number }): void } + readonly dialog: { + alert(options: { readonly title: string; readonly message: string }): Promise<void> + select<Value>(options: { readonly title: string; readonly options: readonly OcSelectOption<Value>[]; readonly current?: Value }): Promise<Value | undefined> + } + } +} diff --git a/opencode/view.ts b/opencode/view.ts new file mode 100644 index 0000000..7eecd7e --- /dev/null +++ b/opencode/view.ts @@ -0,0 +1,84 @@ +// REVISION: flow-v2-opencode-cards +// +// 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 +// paints the scene's cells straight into the frame each time it's drawn. +// The terminal's own colour (DEFAULT_COLOR) is left transparent, so what's +// behind shows through, as it does in Claude Code and pi. A press on it is +// passed on at the cell it landed on (a companion there shows its card). +// +// OpenTUI's classes are the running opencode's own, taken from what it hands +// the plugin (its renderer's root is a Renderable; its theme's colours are +// RGBA), not imported: a plugin's own files can't import opencode's packages, +// and a second copy of OpenTUI wouldn't draw into its frame. + +import type { OptimizedBuffer, RenderContext, Renderable, RGBA } from '@opentui/core' + +import { type Cells, DEFAULT_COLOR } from '../hooks/cells' + +/** A scene's frame at a size, or none to draw nothing. */ +export type FrameAt = (columns: number, rows: number) => Cells | undefined + +export interface SceneView extends Renderable {} +/** A press on the view, at a cell (its column and row in the scene). */ +export type Pressed = (column: number, row: number) => void +export type SceneViewClass = new (ctx: RenderContext, frame: FrameAt, pressed: Pressed) => SceneView + +type RgbaClass = { fromInts(r: number, g: number, b: number, a?: number): RGBA } + +/** The first colour in opencode's theme: its class is OpenTUI's RGBA. */ +function rgbaIn(theme: unknown, depth = 0): RgbaClass | undefined { + if (typeof theme !== 'object' || theme === null || depth > 4) return undefined + const ctor = (theme as { constructor?: Partial<RgbaClass> }).constructor + if (typeof ctor?.fromInts === 'function') return ctor as RgbaClass + for (const v of Object.values(theme)) { + const found = rgbaIn(v, depth + 1) + if (found) return found + } + return undefined +} + +/** The view's class, on the running opencode's OpenTUI: from its renderer's root and a colour of its theme. */ +export function sceneViewClass(root: Renderable, theme: unknown): SceneViewClass { + const Base = Object.getPrototypeOf(root.constructor) as abstract new (ctx: RenderContext, options: object) => Renderable + 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 + } + + return class extends Base { + constructor( + ctx: RenderContext, + private readonly frame: FrameAt, + pressed: Pressed, + ) { + super(ctx, { width: '100%', height: 0, flexShrink: 0 }) + this.onMouseDown = event => pressed(event.x - this.x, event.y - this.y) + } + + protected override renderSelf(buffer: OptimizedBuffer): void { + const { width, height } = this + if (width <= 0 || height <= 0) return + const cells = this.frame(width, height) + if (!cells) return + const columns = Math.min(width, cells.columns) + const rows = Math.min(height, cells.rows) + for (let y = 0; y < rows; y++) { + for (let x = 0; x < columns; x++) { + const i = y * cells.columns + x + const bg = cells.background(i) + const char = String.fromCodePoint(cells.codePoint(i)) + if (bg === DEFAULT_COLOR) buffer.setCellWithAlphaBlending(this.x + x, this.y + y, char, color(cells.foreground(i)), clear) + else buffer.setCell(this.x + x, this.y + y, char, color(cells.foreground(i)), color(bg)) + } + } + } + } +} diff --git a/package.json b/package.json index e49dd34..2bd372e 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "flow", - "version": "0.8.0", + "version": "0.9.0", "private": true, - "description": "Ambient scenes that move with your coding agent's work (a fire, bubbles, surf, rockets and more), for Claude Code and pi", + "description": "Ambient scenes that move with your coding agent's work (a fire, bubbles, surf, rockets and more), for Claude Code, opencode and pi", "author": "Rob Macrae", "license": "MIT", "repository": "github:robdmac/flow", "keywords": [ "pi-package", "claude-code", + "opencode", "fun", "animation", "ambient", @@ -19,6 +20,9 @@ "./pi/index.ts" ] }, + "exports": { + "./tui": "./opencode/tui.ts" + }, "scripts": { "new-scene": "tsx scripts/new-scene.ts", "preview": "tsx scripts/preview.ts", diff --git a/pi/index.ts b/pi/index.ts index 01e6b59..40877db 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v175-plain-names +// REVISION: flow-v176-built-against // // 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 @@ -43,7 +43,7 @@ import { dirname, join } from 'node:path' 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 } from '../hooks/settings' +import { type Host, parseFlowArgs, readConfig, replyTo, storedValue, type FlowConfig, versionNote } from '../hooks/settings' import { SCENES, type Scene } from '../hooks/styles' import { gridToAnsi } from './ansi' import { cellPixels, kittyImages, kittyLines, pictureId, pictureSize } from './picture' @@ -132,6 +132,43 @@ async function saveSettings(changes: Partial<FlowConfig>): Promise<void> { await writeFile(SETTINGS, JSON.stringify(stored, null, 2) + '\n') } +/** 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<string | undefined> { + for (const name of ['@earendil-works/pi-coding-agent', '@mariozechner/pi-coding-agent']) { + try { + const v = ((await import(name)) as { VERSION?: unknown }).VERSION + if (typeof v === 'string') return v + } catch { + // Not under this name: the next. + } + } + return undefined +} + +/** + * On a pi older than Flow is built against (see versionNote), the note to give: once for that version, + * flow.json keeping which it was said for (`warned`, beside its own keys). None on any failure. + */ +async function versionWarning(): Promise<string | undefined> { + const version = await piVersion() + const note = versionNote('pi', version) + if (!note) return undefined + let stored: Record<string, unknown> = {} + try { + stored = JSON.parse(await readFile(SETTINGS, 'utf8')) as Record<string, unknown> + } 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') + } catch { + // Unkept: said again next time. + } + return note +} + export default function flow(pi: PiApi) { const activity = new Activity() const driver = new SceneDriver(readConfig(undefined), activity) @@ -367,6 +404,9 @@ export default function flow(pi: PiApi) { isMounted = false sync(ctx) frame(FRAME_MS) + // 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') }) // `/tree` to another branch: the settings that branch kept. diff --git a/tests/flow.test.ts b/tests/flow.test.ts index 60bfb91..d7bec1d 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,12 +1,12 @@ -// REVISION: flow-v179-doom-volleys +// REVISION: flow-v182-slow-tests import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' import { AsciiFire, colorFor, params } from '../hooks/fire' import { effortFloor, Activity, linesWritten, WAIT_LEVEL } from '../hooks/activity' -import { firstTips, nextTip, readTips, changedText, changesFor, helpText, isNightAt, ownHint, parseFlowArgs, readConfig, resetText, savedText, staleRows, statusText } from '../hooks/settings' -import { CLAUDE_CODE, type FlowAdapter, replyTo } from '../hooks/settings' +import { firstTips, nextTip, readTips, tipChance, changedText, changesFor, helpText, isNightAt, ownHint, parseFlowArgs, readConfig, resetText, savedText, staleRows, statusText } from '../hooks/settings' +import { BUILT_AGAINST, CLAUDE_CODE, type FlowAdapter, olderThan, replyTo, versionNote } from '../hooks/settings' import { differences, type Own, ownAfterSwitch, pinShown, readOwn, readRecord, SESSION_KEPT_MS, SESSIONS_KEPT, sessionKey, staleSessions, storedOwn, storedRecord, withOwn } from '../hooks/sessions' import { gridToAnsi } from '../pi/ansi' import { effortOf, ownInSession, piLinesWritten } from '../pi/mapping' @@ -34,6 +34,11 @@ import { beckon, easeTo, failed, finished, resting, type Mate } from '../hooks/c import { easeNight } from '../hooks/night' 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' + +/** 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 const BAND = { component: 'AbovePrompt', @@ -949,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', () => { +test('soundscapes: a scene holds only a few events for the adapter, so one that never takes them (pi) stays bounded', { timeoutMs: SLOW_MS }, () => { for (const style of STYLES) { const f = makeScene(style, 2) f.ensure(100, 5) @@ -1465,7 +1470,7 @@ test('on desktop the band is one Svg sized to its cells', async ($, on) => { await ui.unmount() }) -test('desktop: every scene, in every size, fits the Svg limit and decodes to its grid', async () => { +test('desktop: every scene, in every size, fits the Svg limit and decodes to its grid', { timeoutMs: SLOW_MS }, async () => { for (const style of STYLES) { for (const [columns, rows] of [[250, 5], [41, 45], [40, 120]] as const) { const s = makeScene(style, 7) @@ -2687,7 +2692,7 @@ function tone(g: Cells): { warm: number; light: number } { return { warm: n ? warm / n : 0, light: n ? light / n : 0 } } -test('every scene shows waiting on the person, by day and night, in the band and the spine: warm and breathing, unlike smoke or blue', () => { +test('every scene shows waiting on the person, by day and night, in the band and the spine: warm and breathing, unlike smoke or blue', { timeoutMs: SLOW_MS }, () => { for (const def of SCENES) for (const [columns, rows] of [ [90, 5], @@ -3071,7 +3076,7 @@ test('doom: heard as it happens: each gun its own shot (his sent ahead of its fl } }) -test('earthrise: heard as it happens: a streak for each shooting star (more with the work), a swell for a comet; none while it holds', () => { +test('earthrise: heard as it happens: a streak for each shooting star (more with the work), a swell for a comet; none while it holds', { timeoutMs: SLOW_MS }, () => { for (const [columns, rows] of [[120, 5], [22, 40]] as const) { const count = (level: number, waiting = false) => { const e = makeScene('earthrise', 5) @@ -4280,3 +4285,210 @@ test('companions alike in every scene: resting, beckoning out of step by slot, l expect(easeTo(Number.NaN, 5, 0.1)).toBe(5) expect(easeTo(0, 10, 0.5)).toBe(5) }) + +// ── opencode ───────────────────────────────────────────────────────────── + +/** opencode's events as it sends them (recorded from opencode 2.0.29: a write, an edit, a failing shell, a subagent's read). */ +const OC = 'ses_main' +const SUB = 'ses_sub' +const ocRun = (sessionID: string) => [ + { type: 'session.execution.started', data: { sessionID } }, + { type: 'session.step.started', data: { sessionID } }, +] + +test('opencode: a turn, its edits and a failing shell drive the level and the smoke', async () => { + const a = new Activity() + const work = new OpenCodeWork(a) + work.show(OC) + for (const e of ocRun(OC)) work.event(e) + expect(a.isTurnActive).toBe(true) + const heat0 = a.heat + work.event({ type: 'session.tool.input.started', data: { sessionID: OC, id: 'c1', name: 'write' } }) + work.event({ type: 'session.tool.called', data: { sessionID: OC, id: 'c1', input: { path: 'b.txt', content: 'one\ntwo\nthree' }, executed: false } }) + expect(a.heat).toBeGreaterThan(heat0) + expect(a.toolsInFlight).toBe(1) + work.event({ type: 'session.tool.success', data: { sessionID: OC, id: 'c1', content: [{ type: 'text', text: 'Created' }], metadata: { truncated: false } } }) + expect(a.toolsInFlight).toBe(0) + expect(a.tint).toBe('normal') + // A shell that exits 1 is a success to opencode (it ran), a failure to Flow: smoke. + work.event({ type: 'session.tool.input.started', data: { sessionID: OC, id: 'c2', name: 'shell' } }) + work.event({ type: 'session.tool.called', data: { sessionID: OC, id: 'c2', input: { command: 'false' } } }) + work.event({ type: 'session.tool.success', data: { sessionID: OC, id: 'c2', metadata: { status: 'completed', exit: 1 } } }) + expect(a.tint).toBe('smoke') + work.event({ type: 'session.execution.succeeded', data: { sessionID: OC } }) + expect(a.isTurnActive).toBe(false) + // Another session's work is none of this scene's. + work.event({ type: 'session.execution.started', data: { sessionID: 'ses_other' } }) + expect(a.isTurnActive).toBe(false) +}) + +test('opencode: lines written by its write, edit and patch tools', async () => { + expect(ocLinesWritten('write', { content: 'a\nb\nc' })).toBe(3) + expect(ocLinesWritten('edit', { newString: 'one\nTWO\nthree', oldString: 'x' })).toBe(3) + expect(ocLinesWritten('apply_patch', { patchText: '*** Begin\n+a\n+b\n-c\n+++ x' })).toBe(2) + expect(ocLinesWritten('shell', { command: 'ls' })).toBeUndefined() +}) + +test('opencode: a subagent (a child session) is a companion: working, then done well or not', async () => { + const a = new Activity() + const work = new OpenCodeWork(a) + work.show(OC) + for (const e of ocRun(OC)) work.event(e) + work.event({ type: 'session.created', data: { sessionID: SUB, parentID: OC, title: 'Read a.txt contents', agent: 'general' } }) + expect(a.runningAgents).toBe(1) + for (const e of ocRun(SUB)) work.event(e) + work.event({ type: 'session.tool.input.started', data: { sessionID: SUB, id: 's1', name: 'read' } }) + work.event({ type: 'session.tool.called', data: { sessionID: SUB, id: 's1', input: { path: 'a.txt' } } }) + let d = a.roster.dials() + expect(d.length).toBe(1) + expect(d[0]!.state).toBe('working') + expect(d[0]!.task).toBe('Read a.txt contents') + expect(d[0]!.type).toBe('general') + // A subagent's tools are its own: the main loop's in flight stay none. + expect(a.toolsInFlight).toBe(0) + work.event({ type: 'session.tool.success', data: { sessionID: SUB, id: 's1' } }) + work.event({ type: 'session.execution.succeeded', data: { sessionID: SUB } }) + d = a.roster.dials() + expect(d[0]!.state).toBe('done') + expect(d[0]!.ok).toBe(true) + expect(a.runningAgents).toBe(0) + // One interrupted is done, not well. + work.event({ type: 'session.created', data: { sessionID: 'ses_sub2', parentID: OC, title: 'Another' } }) + work.event({ type: 'session.execution.interrupted', data: { sessionID: 'ses_sub2', reason: 'user' } }) + expect(a.roster.dials().find(x => x.id === 'ses_sub2')!.ok).toBe(false) +}) + +test('opencode: a permission or a question put to the person settles the scene till answered, in any loop', async () => { + const a = new Activity() + const work = new OpenCodeWork(a) + work.show(OC) + for (const e of ocRun(OC)) work.event(e) + work.event({ type: 'permission.asked', data: { id: 'per_1', sessionID: OC, action: 'edit', resources: ['b.txt'] } }) + expect(a.isAwaitingPerson).toBe(true) + expect(a.strength(1)).toBeLessThanOrEqual(WAIT_LEVEL) + work.event({ type: 'permission.replied', data: { sessionID: OC, requestID: 'per_1', reply: 'once' } }) + expect(a.isAwaitingPerson).toBe(false) + work.event({ type: 'session.created', data: { sessionID: SUB, parentID: OC, title: 'Ask' } }) + work.event({ type: 'form.created', data: { form: { id: 'frm_1', sessionID: SUB, title: 'Which?', fields: [] } } }) + expect(a.isAwaitingPerson).toBe(true) + expect(a.roster.dials()[0]!.state).toBe('waiting') + work.event({ type: 'form.cancelled', data: { id: 'frm_1', sessionID: SUB } }) + expect(a.isAwaitingPerson).toBe(false) +}) + +test("opencode: the context fills by each step's tokens over the model's limit; another session on screen starts afresh", async () => { + const a = new Activity() + const work = new OpenCodeWork(a, () => 10_000) + work.show(OC) + const usage = (input: number, read: number) => ({ type: 'session.usage.updated', data: { sessionID: OC, cost: 0, tokens: { input, output: 0, reasoning: 0, cache: { read, write: 0 } } } }) + // The first is the session's total so far, not a step's. + work.event(usage(50_000, 0)) + expect(a.contextPercent).toBe(0) + work.event(usage(50_500, 8_500)) + expect(a.contextPercent).toBe(90) + expect(a.tint).toBe('blue') + work.show('ses_new') + expect(a.contextPercent).toBe(0) + expect(a.tint).toBe('normal') +}) + +test('the soundscape plays through any player: a bed, at most its plays at once, stopped when the sound goes off', async () => { + const cfg = readConfig({ style: 'fire', sound: 'on' }) + const plays: { asset: string; signal: AbortSignal }[] = [] + const later: (() => void)[] = [] + const player: Player = { + maxPlays: 4, + play: (clip, _gain, signal) => { + if ('asset' in clip) plays.push({ asset: clip.asset, signal }) + return new Promise(resolve => signal.addEventListener('abort', () => resolve())) + }, + full: () => false, + after: (_ms, fn) => void later.push(fn), + } + const sound = new Soundscape(cfg, player) + const scene = makeScene('fire', 3) + scene.ensure(80, 5) + scene.strength = 6 + const frame = (heard = true) => sound.step(70, { heard, shown: scene, scenes: [scene], waiting: false, tint: 'normal', night: false }) + // Nobody there yet: nothing. + frame() + expect(plays.length).toBe(0) + sound.present = true + for (let i = 0; i < 400; i++) { + scene.step() + frame() + } + expect(plays.some(p => p.asset.startsWith('sounds/fire/bed-'))).toBe(true) + expect(plays.filter(p => !p.signal.aborted).length).toBeLessThanOrEqual(4) + cfg.sound = 'off' + frame() + expect(plays.every(p => p.signal.aborted)).toBe(true) +}) + +test("opencode: the model's variant sets the turn's floor, as thinking effort does elsewhere; a subagent's never does", async () => { + expect(ocEffortOf('low')).toBe('low') + expect(ocEffortOf('max')).toBe('max') + expect(ocEffortOf('default')).toBeUndefined() + expect(ocEffortOf(undefined)).toBeUndefined() + const a = new Activity() + // A session begun before Flow came up: its variant as opencode has it. + const work = new OpenCodeWork(a, undefined, id => (id === OC ? 'max' : undefined)) + work.show(OC) + for (const e of ocRun(OC)) work.event(e) + expect(a.floor).toBe(effortFloor('max')) + // Another variant selected since: its events say. + work.event({ type: 'session.model.selected', data: { sessionID: OC, model: { id: 'm', providerID: 'p', variant: 'low' } } }) + work.event({ type: 'session.step.started', data: { sessionID: OC } }) + expect(a.floor).toBe(effortFloor('low')) + work.event({ type: 'session.created', data: { sessionID: SUB, parentID: OC, model: { id: 'm', providerID: 'p', variant: 'max' } } }) + work.event({ type: 'session.step.started', data: { sessionID: SUB } }) + expect(a.floor).toBe(effortFloor('low')) +}) + +test('a chance for a tip over the tips as kept: the tip, what to keep, and whether that changed (every adapter alike)', async () => { + const fire = readConfig(undefined) + // Someone new, on the fire: the scenes tip, and a record to keep. + const first = tipChance({}, fire) + expect(first.tip).toContain('/flow pick') + expect(first.changed).toBe(true) + // Nothing due and nothing new on: nothing to write. + const told = { scenesTold: true, soundTold: true } + expect(tipChance(told, fire)).toEqual({ tip: undefined, tips: told, changed: false }) + // No chance (no prompt to toast over), but the sound being on is noted all the same. + const noted = tipChance({ scenesTold: true }, { ...fire, sound: 'on' }, true) + expect(noted.tip).toBeUndefined() + expect(noted.tips.triedSound).toBe(true) + expect(noted.changed).toBe(true) + // Whatever was stored that isn't a tip is read as nothing. + expect(tipChance('junk', fire).tip).toContain('/flow pick') +}) + +test("opencode: a subagent's session deleted before its run ends is gone, not left working", async () => { + const a = new Activity() + const work = new OpenCodeWork(a) + work.show(OC) + work.event({ type: 'session.created', data: { sessionID: SUB, parentID: OC, title: 'Left behind' } }) + expect(a.runningAgents).toBe(1) + work.event({ type: 'session.deleted', data: { sessionID: SUB } }) + expect(a.runningAgents).toBe(0) + expect(a.roster.dials()[0]!.state).toBe('done') + expect(a.roster.dials()[0]!.ok).toBe(false) +}) + +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) + expect(olderThan('2.1.300', '2.1.289')).toBe(false) + expect(olderThan('2.0.9', '2.0.29')).toBe(true) + expect(olderThan('v2.0.12', '2.0.29')).toBe(true) + expect(olderThan('1.10.0', '1.9.0')).toBe(false) + expect(olderThan('2.1.280-dev.20260920.t101500.sha1a2b3c4', '2.1.289')).toBe(true) + expect(olderThan('nightly', '2.1.289')).toBe(false) + for (const host of Object.keys(BUILT_AGAINST) as (keyof typeof BUILT_AGAINST)[]) { + expect(versionNote(host, BUILT_AGAINST[host])).toBeUndefined() + expect(versionNote(host, undefined)).toBeUndefined() + } + const note = versionNote('opencode', 'v2.0.12')! + expect(note).toContain('opencode 2.0.29 or later') + expect(note).toContain('this is 2.0.12') +}) diff --git a/tsconfig.node.json b/tsconfig.node.json index b85dd64..a0a5609 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -10,5 +10,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["scripts", "pi"] + "include": ["scripts", "pi", "opencode"] }