SLOPSHOPPER

squishys

A companion mod for Claude Code's agents: every agent gets its own tiny pixel-art squishy you can watch, stop, redirect and collect.

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-06Rahat-ch/squishys
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · squishys
│ ┃ Squishys ✕ › fix the failing auth test and add an audit log call │ ┃ Squishydex │ ┃ Species 0/48 Shinies 0 Legendaries 0/5 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ⏺ Bash(bun test) │ ┃ #001 #002 #003 #004 ⎿ 3 pass, 1 fail │ ┃ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▄▄ │ ┃ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀▀▀ │ ┃ #006 #007 #008 #009 ✻ Worked for 42s · done 4:20 PM │ ┃ ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ │ ┃ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ › /squishys │ ┃ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ │ ┃ #011 #012 #013 #014 │ ┃ ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ │ ┃ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ │ ┃ ▀▀▀▀▀ ▀ ▀ ▀ │ ┃ #016 #017 #018 #019 │ ┃ ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ │ ┃ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ │ ┃ ▀ ▀ ▀ ▀ │ ┃ #021 #022 #023 #024 │ ┃ ▄▀▄ ▄▀▄ ▄▀▄ ▄▀▄ │ ┃ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ✻ Squishing… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Squishys
Squishydex Species 0/48 Shinies 0 Legendaries 0/5 ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ #001 #002 #003 #004 #005 ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ▄▄▄ ▄▄▄ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ #006 #007 #008 #009 #010 ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ #011 #012 #013 #014 #015 ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀ #016 #017 #018 #019 #020 ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ▄▀▄ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ #021 #022 #023 #024 #025 ▄▀▄ ▄▀▄ ▄▀▄ ▄▀▄ ▄▀▄ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ #026 #027 #028 #029 #030 p: Prev n: Next 1/2 r: Roster b: Back m: Make partner c: Partner palette x: Share
README

squishys

License: MIT

A companion mod for Claude Code's agents. Every agent Claude starts gets its own tiny pixel-art squishy, so you can watch your agents work and stop or redirect them right from their squishys.

What you get

  • A squishy per agent: each subagent, fork or background agent gets its own squishy with a generated Name, which stays with that agent for its whole life.
  • States and animation: a squishy shows whether its agent is Working, Thinking, Needs you, Asleep (finished) or Squished (failed or stopped).
  • Focus view: pick a squishy to follow its agent's live activity.
  • Stop: stop a running agent from its focus view.
  • Redirect (i): type a message to an agent. A running agent reads it at its next step, and a finished one resumes with it.
  • Next-run model and effort (m/e): pick the model and effort an agent's next run uses.
  • The Squishydex: a lasting collection of every squishy you've met, across sessions, with your own partner squishy for Claude.
  • Shiny and legendary moments: rare squishys turn up with a sparkle, a toast and an optional chime.
  • Share: turn a squishy into a picture card and post it to X yourself.

Install

From your shell:

claude plugin marketplace add Rahat-ch/squishys
claude plugin install squishys@squishys

Or inside a Claude Code session:

/plugin marketplace add Rahat-ch/squishys
/plugin install squishys@squishys

Updating

Run claude plugin update squishys@squishys and restart Claude Code, or turn on auto-update for the squishys marketplace (/plugin → Marketplaces → Enable auto-update).

Requirements

  • Claude Code 2.1.287 or later (tested on 2.1.289), in a terminal.
  • Fullscreen rendering is recommended (/tui fullscreen): it gives you clicks, hover and, at 110 columns or wider, the pane docked beside the transcript.
  • The classic renderer works too, with hotkeys and a compact pane inline above the prompt.
  • v0.1 draws in the terminal only: nothing shows in the VS Code panel, the desktop Code tab, -p (print) runs or cloud sessions.
  • The chime plays on macOS only.

Using it

The pane opens by itself when Claude starts its first agent, if the terminal is 144 columns or wider. /squishys opens or closes it, and /squishydex opens the Squishydex. Opened that way, the pane takes the keyboard, so its hotkeys work at once. Esc hands the keyboard back to the prompt, and Ctrl+X Tab (Ctrl+X, then Tab) gives it to the pane again. Under fullscreen rendering you can also click a squishy.

When the terminal is too narrow for the pane, a band of mini squishys sits above the prompt instead. It has no hotkeys, so typing in the prompt never presses it; under fullscreen rendering, click a squishy there to open its focus view.

Keys

Every control in the pane has a hotkey, shown before its label (i: Redirect). Hotkeys work while the pane has the keyboard.

WhereKeys
First run1–3 pick your starter partner
Roster1–9 pick a squishy (your partner is 1; letters after the digits), m the +N list of agents with no slot, o settings, d Squishydex
An agent's viewi type in the Redirect box (Enter sends, Esc leaves), s stop (press twice), x share, m model and e effort for its next run, 1 or r back to the roster
Settingsm model for new agents, s roster slots, c chime, r back
Squishydex1–9 open a squishy's card (letters after the digits), n/p next and previous page, r roster; on a card m make partner, c palette, x share, b back

Controls with a few choices (model, effort, slots, chime, palette) step to the next choice on each press. The label shows the current choice and the next one.

Held controls: a control that doesn't apply right now stays on screen, dimmed, with a few words of why, such as s: Stop (finished). Pressing it tells you why instead of typing the key into the prompt.

Next-run model and effort

An agent's model and effort apply from its next run, so you usually pick them while it's Asleep, and they take effect when it's resumed. A redirect to a running agent joins its current run, which keeps its current model. If you redirect an Asleep agent that has a pick, the message goes through Claude, which resumes the agent with SendMessage so the pick applies; that takes a moment, since Claude passes it on once it's free. The model control offers the models this session already runs on: Claude's own and the other agents'.

Security

squishys runs inside your Claude Code session, with your permissions. Here is what it reads and what it can do.

It reads:

  • Your agents' tool calls, conversation rows and permission requests, to show their state and live activity.
  • Claude Code's agent list, with each agent's description.
  • A few of Claude Code's settings and session details: the model allowlist (availableModels), reduced motion, and the models the session runs on.
  • Its own saved data in Claude Code's plugin store: your settings, your partner, the Squishydex, which squishy each agent has and which agents each session started.
  • The SQUISHYS_FORCE_ROLL environment variable, a development convenience that forces shiny or legendary rolls.
  • Whether /usr/bin/afplay exists, to offer the chime only where it can play.

It can:

  • Stop an agent, with Claude Code's TaskStop tool, when you press Stop. If TaskStop hasn't stopped it within about 2 seconds, squishys holds the agent back: it refuses the agent's tool calls and answers its next model request itself ("Stopped by the user") without calling the model.
  • Redirect an agent with your message: into its current run ($.session.append), or by resuming a finished one ($.session.send). When a next-run pick is pending, squishys instead submits a prompt in your name to the main conversation ($.prompt.submit with asUser: true), asking Claude to relay your message to the agent with SendMessage.
  • Rewrite the model and effort of an agent's next run when you pick them, and the model new agents start on when you set one in Settings.
  • Play the chime through Claude Code's audio ($.audio), when you turn it on.
  • Run local commands for Share, all through sh. It saves the card with uname, base64, mktemp and find. On macOS it copies the card to the clipboard with osascript (or reveals it with open -R if that fails), then opens the compose page with open. On Linux it copies with wl-copy or xclip (or opens the card's folder with xdg-open if that fails), then opens the compose page with xdg-open.
  • Write card PNGs to a private folder of your own ($TMPDIR/squishys-share on macOS, ${XDG_CACHE_HOME:-~/.cache}/squishys/share on Linux). Cards older than a day are cleared.

Nothing is posted automatically. Share saves the card, copies it to the clipboard where it can, and opens X's compose page in your browser with the text filled in. You attach the picture and post it yourself; squishys never sees your X account. The text for a finished agent includes the start of the agent's description, which can hold private project details, so review it in the compose box before you post.

Development

Want to load the mod from a clone, run the tests or contribute? See CONTRIBUTING.md.

License

MIT

Source 31 files
hooks/register.tsx 39 lines
1// The squishys hooks module: wires each feature's hooks into Claude Code.
2// Features live in src/ and each exports a register function taking `on`.
3
4import type { Register } from 'claude-code'
5
6import { registerAgentTracking } from '../src/agents'
7import { registerBand } from '../src/band'
8import { registerFocus } from '../src/focus'
9import { registerHeld } from '../src/held'
10import { registerModelSwitch } from '../src/model-switch'
11import { registerPane } from '../src/pane'
12import { registerPartner } from '../src/partner'
13import { registerRebuild } from '../src/rebuild'
14import { registerSettings } from '../src/settings'
15import { registerShare } from '../src/share'
16import { registerSpinner } from '../src/spinner'
17import { registerSquishydex } from '../src/squishydex'
18
19export const register: Register = on => {
20  // The order matters: a plugin's registrations nest in order, first
21  // outermost. The agent tracker records an agent before the focus view's
22  // feed hooks look it up, and the pane's render hook wraps every other
23  // mode's, so it can animate the squishys they draw. The next run's
24  // turn.step hook sits inside the tracker's, which sees requests as the
25  // engine made them.
26  registerAgentTracking(on)
27  registerPane(on)
28  registerHeld(on)
29  registerRebuild(on)
30  registerSettings(on)
31  registerPartner(on)
32  registerSquishydex(on)
33  registerFocus(on)
34  registerShare(on)
35  registerModelSwitch(on)
36  registerBand(on)
37  registerSpinner(on)
38}
39
src/agents.ts 448 lines
1// The agent tracker: turns Claude Code's events into the session's agents,
2// each with the squishy that stands for it and the state its squishy shows.
3// The state rules themselves live in states.ts.
4
5import { atom, read, update } from 'claude-code'
6import type { AgentInfo, AgentStatus, EngineInterface, On, Timer } from 'claude-code'
7
8import type { Agent, Squishy, SquishyState } from '../types'
9import { KIT } from './kit'
10import { OPEN_PANE, PANE_ID, notePaneOpened, squishysOnScreen } from './pane'
11import { PARTNER_KEY, partnerFrom } from './partner'
12import { REMEMBERED_KEY, forcedMoment, rememberSquishys, rememberedFrom, sessionOfSpawn, takeFreshRolls } from './rebuild'
13import type { Rolled } from './rebuild'
14import { momentToast, sparkleUntil, withSparklesTidied } from './moments'
15import { cryptoRandom, forcedOdds, roll, squishyOf } from './roller'
16import { carriesRedirect, forgetResumed, forgetResumes, isResumedByRedirect } from './resumes'
17import { recordMet } from './squishydex-record'
18import { SETTINGS_KEY, settingsFrom, withModelDefault } from './settings'
19import { liveSquishys } from './slots'
20import { answered, endedState, isEnded, stateAfterRun, stateAtRow, stateAtStop } from './states'
21import { STOPPED_BY_USER, disarmed, entryNamed, forgetStops, isHeldBack, refusalOf, resumed, runEnded, wasStoppedByUser } from './stop'
22
23/**
24 * How often, in milliseconds, the agent list is checked for agents that
25 * failed or were stopped without a stop event, while any agent runs.
26 */
27export const AGENT_CHECK_MS = 5000
28
29/** Every agent seen this session, in the order they were first seen. */
30const agents = atom({ plugin: 'squishys', key: 'agents' } as const, [])
31const stopControl = atom({ plugin: 'squishys', key: 'stopControl' } as const, null)
32const runChoices = atom({ plugin: 'squishys', key: 'runChoices' } as const, {})
33
34/**
35 * Ids a tool call carried that `$.agent.list()` didn't name (workflow agents,
36 * Claude Code's own forks), so each is looked up only once.
37 */
38const notAgents = new Set<string>()
39
40export function registerAgentTracking(on: On): void {
41  // A hot reload keeps $.state but drops this module's timers, and starts
42  // the session again. The pane's hook on session.start is the unmatched
43  // one, and `$` can't be handed across files, so the agent tracker's hook
44  // matches every session instead.
45  on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
46    agentCheck?.cancel()
47    agentCheck = undefined
48    if ((await read($, agents)).some(agent => !isEnded(agent.state))) keepChecking($)
49    // and the timer ending a sparkle: a sparkle that passed meanwhile is cleared now
50    sparkleEnd?.cancel()
51    sparkleEnd = undefined
52    await tidySparkles($)
53    return next(e)
54  })
55
56  // After /clear, /resume, a branch or compaction, rebuild.ts's hook rebuilds
57  // the roster before passing the event on, and this one checks after it:
58  // the agent list is checked while any rebuilt agent runs, and a fresh
59  // roll that came up shiny or legendary is announced, as from a spawn.
60  // Stop forgets every agent but after compaction, which keeps the session,
61  // and so do the marks of runs a redirect resumed.
62  on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
63    if (e.source !== 'compact') {
64      forgetStops()
65      forgetResumes()
66    }
67    const started = await next(e)
68    if ((await read($, agents)).some(agent => !isEnded(agent.state))) keepChecking($)
69    for (const { agent } of takeFreshRolls()) await announce($, agent)
70    return started
71  })
72
73  // Subagents, forks and background agents all start through agent.spawn,
74  // on the user's default model when one is set.
75  on('agent.spawn', async ($, e, next) => {
76    // A store that can't be read means no default, never a lost squishy.
77    let settings = settingsFrom(undefined)
78    try {
79      settings = settingsFrom(await $.store.get(SETTINGS_KEY))
80    } catch {}
81    const started = await next(withModelDefault(e, settings))
82    if (started.agentId === undefined) return started
83    const rolled = await assignSquishy($, started.agentId, e.description, started.model)
84    // Met: the Squishydex records it, unless the roll was forced
85    if (rolled !== undefined && !rolled.forced) await recordMet({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), now: () => $.clock.now() }, [rolled.agent.squishy])
86    // A shiny or legendary is announced once the agent has started, so it never holds up the spawn
87    if (rolled !== undefined) await announce($, rolled.agent)
88    return started
89  })
90
91  // An agent running a tool is Working, an ended one that a message resumed
92  // too. And the fallback for agents that start without agent.spawn
93  // (in-process teammates may): an agent first seen through its tool call.
94  // Only ids `$.agent.list()` names count, which leaves out workflow agents
95  // and Claude Code's own forks.
96  //
97  // An agent Stop holds back (src/stop.ts) has its tool calls refused,
98  // before anything else sees them.
99  on('tool.call', async ($, e, next) => {
100    const { agentId } = e
101    if (agentId !== undefined && isHeldBack(agentId)) return { deny: STOPPED_BY_USER }
102    let rolled: Rolled | undefined
103    if (agentId !== undefined && !notAgents.has(agentId) && !hasSquishy(await read($, agents), agentId)) {
104      const listed = (await $.agent.list()).find(agent => agent.id === agentId)
105      if (listed !== undefined) rolled = await assignSquishy($, agentId, listed.description)
106      else notAgents.add(agentId)
107    }
108    if (agentId !== undefined) await setState($, agentId, 'working')
109    const result = await next(e)
110    // Met: the Squishydex records it once the call has gone on, so recording never holds the call up
111    if (rolled !== undefined && !rolled.forced) await recordMet({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), now: () => $.clock.now() }, [rolled.agent.squishy])
112    // and a shiny or legendary is announced, as from a spawn
113    if (rolled !== undefined) await announce($, rolled.agent)
114    // A prompt the call raised has been answered once its result is in. This
115    // dispatch's reads predate the prompt, so `asking` says whether one came.
116    if (agentId !== undefined && asking.has(agentId)) await setState($, agentId, answered, { current: true })
117    // The squishy of an agent TaskStop stopped (the focus view's Stop, or the
118    // orchestrator itself) is Squished, once the agent list shows it ended.
119    // TaskStop names a teammate by its address or name, any other agent by id.
120    if (e.tool === 'TaskStop' && e.task_id !== undefined && refusalOf(result) === undefined) {
121      let entry: AgentInfo | undefined
122      try {
123        entry = entryNamed(e.task_id, await $.agent.list())
124      } catch {}
125      if (entry !== undefined && endedState(entry.status) !== undefined) {
126        const { id, status } = entry
127        await setState($, id, state => stateAtStop(state, { status, stoppedByUser: wasStoppedByUser(id) }))
128      }
129    }
130    return result
131  })
132
133  // A squishy is Thinking from the first piece of its agent's response to
134  // the last, then Working while the agent runs the tools the response
135  // called, or until the turn completes.
136  //
137  // An agent Stop holds back gets an answer that ends its run instead of a
138  // model request: no model is called. A request from one whose squishy
139  // is Asleep or Squished is a resume, though, and goes through.
140  on('turn.step', async function* ($, e, next) {
141    const { agentId } = e
142    if (agentId !== undefined && isHeldBack(agentId)) {
143      const resuming = (await read($, agents)).some(agent => agent.id === agentId && isEnded(agent.state))
144      if (resuming) {
145        resumed(agentId)
146      } else {
147        yield { kind: 'text', index: 0, text: STOPPED_BY_USER } as const
148        return { turnId: e.turnId, index: e.index, answer: STOPPED_BY_USER, toolUses: [], stopReason: 'end_turn', usage: null }
149      }
150    }
151    const response = next(e)
152    if (agentId === undefined) return yield* response
153    await signOfLife($, agentId)
154    let streaming = false
155    try {
156      for await (const chunk of response) {
157        if (!streaming) {
158          streaming = true
159          await setState($, agentId, 'thinking')
160        }
161        yield chunk
162      }
163    } finally {
164      // Only a squishy still Thinking goes back to Working: an agent that
165      // ended while its response streamed stays ended
166      if (streaming) await setState($, agentId, 'working', { from: 'thinking' })
167    }
168    return await response.result
169  })
170
171  // Each run of an agent's loop is one turn, and how it ended says whether
172  // the agent finished, failed or was stopped. A run the user stopped was
173  // stopped, whatever it answered.
174  on('turn.complete', async ($, e, next) => {
175    const completed = await next(e)
176    if (e.agentId !== undefined) await setState($, e.agentId, stateAfterRun(e.reason, wasStoppedByUser(e.agentId)))
177    return completed
178  })
179
180  // An agent whose tool call asks the user for permission Needs you, unless
181  // a settings hook decided the request (`decision`, or `block` to refuse
182  // it), so no prompt shows.
183  // A prompt from the orchestrator carries no agent_id and marks nothing.
184  on('classic.PermissionRequest', async ($, e, next) => {
185    const asked = await next(e)
186    if (e.agent_id !== undefined && asked.decision === undefined && asked.block === undefined) {
187      await setState($, e.agent_id, 'needsYou')
188    }
189    return asked
190  })
191
192  // Nothing fires when the user answers a prompt; the agent's next sign of
193  // life says they did.
194  on('classic.PostToolUse', async ($, e, next) => {
195    await signOfLife($, e.agent_id)
196    return next(e)
197  })
198  on('classic.PermissionDenied', async ($, e, next) => {
199    await signOfLife($, e.agent_id)
200    return next(e)
201  })
202
203  // How a run the focus view's redirect resumed wakes its squishy, since that
204  // run raises its turn.step, tool.call and SubagentStop under the mod's own
205  // origin, which skips the mod's hooks (AGENTS.md, "The run a redirect
206  // resumes"): a row of it landing in the ended agent's conversation, while
207  // the agent is marked as resumed by a redirect or the row carries one.
208  // Any other row, as one after Stop, changes nothing, so setState (which
209  // also lets Stop and Needs you go) runs only for a genuine resume. Read
210  // only, on the row's way down: the row goes on as it came.
211  on('session.append', { agentId: /./, door: ['prompt', 'response'] }, async ($, e, next) => {
212    const { agentId } = e
213    const isRedirectRun = agentId !== undefined && (isResumedByRedirect(agentId) || (e.door === 'prompt' && carriesRedirect(e.message.content)))
214    const wakes = (agent: Agent) => agent.id === agentId && stateAtRow(agent.state, { isRedirectRun }) !== agent.state
215    if (agentId !== undefined && (await read($, agents)).some(wakes)) {
216      await setState($, agentId, state => stateAtRow(state, { isRedirectRun }))
217    }
218    return next(e)
219  })
220
221  // An agent that stopped: the agent list says how (see stateAtStop).
222  on('classic.SubagentStop', async ($, e, next) => {
223    const result = await next(e)
224    let status: AgentStatus | undefined
225    try {
226      status = (await $.agent.list()).find(agent => agent.id === e.agent_id)?.status
227    } catch {}
228    await setState($, e.agent_id, state => stateAtStop(state, { ...(status === undefined ? {} : { status }), stoppedByUser: wasStoppedByUser(e.agent_id) }))
229    return result
230  })
231}
232
233/**
234 * Puts an agent's squishy in a state, or the state `to` makes of its
235 * current one; agents without a squishy are left out, and with `from`, so is
236 * a squishy in any other state than that.
237 *
238 * It reads first, so a state that doesn't change redraws nothing. Every read
239 * in one dispatch sees the moment the dispatch began, so `current` skips
240 * that read, for a change another event made since.
241 */
242async function setState(
243  $: EngineInterface,
244  agentId: string,
245  to: SquishyState | ((state: SquishyState) => SquishyState),
246  { from, current = false }: { from?: SquishyState; current?: boolean } = {},
247): Promise<void> {
248  const stateFrom = typeof to === 'function' ? to : () => to
249  const changes = (agent: Agent) => agent.id === agentId && stateFrom(agent.state) !== agent.state && (from === undefined || agent.state === from)
250  // Any other state means the agent has moved on from its prompt
251  if (to !== 'needsYou') asking.delete(agentId)
252  if (!current) {
253    const known = await read($, agents)
254    if (to === 'needsYou' && hasSquishy(known, agentId)) asking.add(agentId)
255    if (!known.some(changes)) return
256  }
257  let before: SquishyState | undefined
258  const written = await update($, agents, known => {
259    before = known.find(agent => agent.id === agentId)?.state
260    return known.map(agent => (changes(agent) ? { ...agent, state: stateFrom(agent.state) } : agent))
261  })
262  const state = written.find(agent => agent.id === agentId)?.state
263  if (state !== undefined && !isEnded(state)) keepChecking($)
264  // An agent that ends or resumes: Stop no longer holds it back, forgets it
265  // once it resumes, and disarms for it either way. However it ended (its
266  // turn.complete, SubagentStop, the agent list check, TaskStop), a run a
267  // redirect resumed is over, so a later run's tool calls show only once,
268  // and the run's choice of model and effort (src/model-switch.ts) goes, so
269  // no later run shows it.
270  if (state === undefined || before === undefined || isEnded(state) === isEnded(before)) return
271  if (isEnded(state)) {
272    runEnded(agentId)
273    forgetResumed(agentId)
274    if ((await read($, runChoices))[agentId] !== undefined) await update($, runChoices, ({ [agentId]: _, ...rest }) => rest)
275  } else {
276    resumed(agentId)
277  }
278  await update($, stopControl, control => disarmed(control, agentId))
279}
280
281/**
282 * The agents a permission prompt put in Needs you, until they move on. The
283 * tool.call hook reads it after `await next(e)`, where its `$.state` reads
284 * still show the moment before the prompt. A reload empties it; the agent's
285 * next sign of life in a dispatch of its own clears Needs you then.
286 */
287const asking = new Set<string>()
288
289/**
290 * A sign of life from an agent, in a dispatch of its own: a prompt it was
291 * on has been answered, so it moves on from Needs you by the rule in
292 * states.ts.
293 */
294async function signOfLife($: EngineInterface, agentId: string | undefined): Promise<void> {
295  if (agentId !== undefined) await setState($, agentId, answered)
296}
297
298/** The agent list check's timer, set while any agent is running. */
299let agentCheck: Timer | undefined
300
301/**
302 * Checks the agent list every AGENT_CHECK_MS while any agent is running,
303 * for agents that ended without a stop event or a turn of their own ending.
304 */
305function keepChecking($: EngineInterface): void {
306  agentCheck ??= $.clock.every(AGENT_CHECK_MS, () => void checkAgentList($))
307}
308
309async function checkAgentList($: EngineInterface): Promise<void> {
310  const running = (await read($, agents)).filter(agent => !isEnded(agent.state))
311  if (running.length === 0) {
312    agentCheck?.cancel()
313    agentCheck = undefined
314    return
315  }
316  let listed: { id: string; status: AgentStatus }[]
317  try {
318    listed = await $.agent.list()
319  } catch {
320    return // checked again next time
321  }
322  for (const agent of running) {
323    const status = listed.find(each => each.id === agent.id)?.status
324    if (status === undefined || endedState(status) === undefined) continue
325    await setState($, agent.id, state => stateAtStop(state, { status, stoppedByUser: wasStoppedByUser(agent.id) }))
326  }
327}
328
329/**
330 * Opens the pane at the session's first agent, unless it's open already,
331 * never asking for the keyboard (OPEN_PANE). Unasked, Claude Code places it
332 * only on a wide terminal (144 columns, 110 once the user has opened it
333 * before) and leaves it unplaced otherwise, while the band (src/band.tsx)
334 * shows instead.
335 */
336async function openPaneUnasked($: EngineInterface): Promise<void> {
337  try {
338    if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) return
339    await $.ui.open(OPEN_PANE)
340    notePaneOpened(OPEN_PANE)
341  } catch {} // a refused open leaves the agent its squishy all the same
342}
343
344function hasSquishy(known: readonly Agent[], agentId: string): boolean {
345  return known.some(agent => agent.id === agentId)
346}
347
348/**
349 * Gives an agent a freshly rolled squishy, unless it already has one. The
350 * roll leaves out every running agent's squishy and every one in the
351 * roster's slots: an Asleep squishy stays on screen in its slot until a new
352 * agent takes it (see liveSquishys). An ended agent that wakes
353 * keeps its own squishy, even if another agent has rolled it since: an
354 * agent's identity wins over keeping squishys apart. Nor does it repeat
355 * the partner's. An agent the store kept a squishy for gets it back, as
356 * from a rebuild: one of another session that /clear or /resume left out,
357 * first seen again through its tool call. Returns the agent it rolled a
358 * squishy for, if any, and whether SQUISHYS_FORCE_ROLL forced it to come up
359 * shiny or legendary (Rolled); never one whose squishy came back.
360 */
361async function assignSquishy($: EngineInterface, agentId: string, description: string, model?: string): Promise<Rolled | undefined> {
362  let partner: Squishy | undefined
363  let kept: Squishy | undefined
364  try {
365    partner = partnerFrom(await $.store.get(PARTNER_KEY))
366    kept = squishyOf(KIT, new Map(rememberedFrom(await $.store.get(REMEMBERED_KEY))).get(agentId) ?? '')
367  } catch {}
368  // SQUISHYS_FORCE_ROLL forces what the roll is (src/roller.ts): how the
369  // tests, and a person trying the mod out, see a Moment
370  let odds: ReturnType<typeof forcedOdds>
371  try {
372    odds = forcedOdds(await $.env.get('SQUISHYS_FORCE_ROLL'))
373  } catch {}
374  let assigned: Agent | undefined
375  let first = false
376  await update($, agents, known => {
377    if (hasSquishy(known, agentId)) return known
378    const squishy = kept ?? roll(KIT, { live: liveSquishys(known, squishysOnScreen(), partner), rng: cryptoRandom, ...(odds !== undefined ? { odds } : {}) })
379    assigned = { id: agentId, description, squishy, state: 'working', ...(model !== undefined ? { model } : {}) }
380    first = known.length === 0
381    return [...known, assigned]
382  })
383  keepChecking($)
384  if (first) await openPaneUnasked($)
385  // Kept in the store too, with the session it started in, so it comes back
386  // after /resume of that session even once the agent list drops it
387  if (assigned === undefined) return undefined
388  // `$.session.id()` lags a session start a while (sessionOfSpawn)
389  let answered: string | undefined
390  try {
391    answered = await $.session.id()
392  } catch {}
393  await rememberSquishys({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) }, [assigned], sessionOfSpawn(answered))
394  return kept !== undefined ? undefined : { agent: assigned, forced: forcedMoment(odds, assigned.squishy) }
395}
396
397/**
398 * Announces an agent's squishy just rolled when it's a Moment
399 * (src/moments.ts): a toast names a shiny or legendary, its slot sparkles
400 * for SPARKLE_MS from now, and with the chime setting on, the chime plays.
401 * A clock that can't be read means no sparkle, and a store that can't be
402 * read no chime. The chime isn't awaited, so it never holds the hook while
403 * it plays, and one that can't play is let be.
404 */
405async function announce($: EngineInterface, { id, squishy }: Agent): Promise<void> {
406  const toast = momentToast(squishy)
407  if (toast === undefined) return
408  $.ui.toast(toast)
409  try {
410    const until = sparkleUntil(squishy, await $.clock.now())
411    if (until !== undefined) await update($, agents, known => known.map(agent => (agent.id === id ? { ...agent, sparkleUntil: until } : agent)))
412    await tidySparkles($)
413  } catch {}
414  let chime = false
415  try {
416    chime = settingsFrom(await $.store.get(SETTINGS_KEY)).chime === true
417  } catch {}
418  if (chime) $.audio.play({ asset: 'sounds/chime.wav' }).catch(() => {})
419}
420
421/** The timer that clears the next sparkle to end, while one is still to come. */
422let sparkleEnd: Timer | undefined
423
424/**
425 * Clears every sparkle that has passed from `agents`, so the pane stops
426 * reading the clock for it, and sets the timer for the next one to end.
427 * Called as a sparkle starts, by that timer, and at session start, which a
428 * hot reload (dropping the timer) fires again. A clock that can't be read
429 * leaves the sparkles be.
430 */
431async function tidySparkles($: EngineInterface): Promise<void> {
432  if (!(await read($, agents)).some(agent => agent.sparkleUntil !== undefined)) return
433  let now: number
434  try {
435    now = await $.clock.now()
436  } catch {
437    return
438  }
439  let nextEnd: number | undefined
440  await update($, agents, known => {
441    const tidied = withSparklesTidied(known, now)
442    nextEnd = tidied.nextEnd
443    return tidied.agents === known ? known : [...tidied.agents]
444  })
445  sparkleEnd?.cancel()
446  sparkleEnd = nextEnd === undefined ? undefined : $.clock.after(nextEnd - now, () => void tidySparkles($))
447}
448
src/band.tsx 60 lines
1// The band: while the pane is open but unplaced (opened unasked on a narrow
2// terminal; the agent tracker opens it at the first agent), the band above
3// the prompt shows mini squishys instead, with the overflow count and a
4// hint for opening the pane. Which squishys show is layoutBand in slots.ts.
5
6import { atom, read } from 'claude-code'
7import type { EngineInterface, On } from 'claude-code'
8
9import { PANE_ID, PICK_PREFIX, SLOT_HOVER, animatedPicture, pictureKey } from './pane'
10import { BAND_GAP, layoutBand, nameCut } from './slots'
11
12/** What the band says about opening the pane. */
13export const OPEN_HINT = 'Run /squishys to open the pane'
14
15// The engine reads each $.state reference off the file that uses it, so
16// every file declares its own atom for the values it reads or writes.
17const agents = atom({ plugin: 'squishys', key: 'agents' } as const, [])
18
19export function registerBand(on: On): void {
20  // Its Buttons pick a squishy by click alone: they carry no hotkeys, since
21  // a digit typed into an empty prompt would press one and swallow the first
22  // keystroke of a message. Their presses are keyed for the pick in
23  // src/focus.tsx, which opens the pane, and the pane's hook animates the
24  // minis drawn here.
25  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
26    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
27    const known = await read($, agents)
28    if (known.length === 0 || !(await paneUnplaced($))) return next(e)
29    const { Box, Button, Raster, Text } = $.ui.resolve(e)
30    const { bodyColumns, maxRows } = e.props
31    const band = layoutBand({ agents: known, bodyColumns, maxRows, hintColumns: OPEN_HINT.length })
32    return (
33      <Box flexDirection="row" columnGap={BAND_GAP}>
34        {band.shown.map(agent => (
35          <Box key={`band-${agent.id}`} flexDirection="column" alignItems="center" width={band.placeColumns} hover={SLOT_HOVER}>
36            {band.pictured ? <Raster key={pictureKey(agent.id, 'mini')} {...animatedPicture(agent, 'mini', e.requestId)} /> : null}
37            <Button key={`${PICK_PREFIX}${agent.id}`} plain label={nameCut(agent.squishy.name)} onPress={() => {}} />
38          </Box>
39        ))}
40        <Box key="band-hint" flexDirection={band.pictured ? 'column' : 'row'} columnGap={BAND_GAP}>
41          {band.overflow.length > 0 ? <Text>{`+${band.overflow.length}`}</Text> : null}
42          <Text dimColor wrap="truncate-end">
43            {OPEN_HINT}
44          </Text>
45        </Box>
46      </Box>
47    )
48  })
49}
50
51/** Whether the pane is open but unplaced, as an unasked open on a narrow terminal leaves it. */
52async function paneUnplaced($: EngineInterface): Promise<boolean> {
53  try {
54    return (await $.ui.panes()).some(pane => pane.id === PANE_ID && !pane.isPlaced)
55  } catch {
56    return false
57  }
58}
59
60
src/focus.tsx 840 lines
1// The focus view: the pane mode given over to one agent, reached by picking
2// its squishy. It shows the squishy at 2×, who the agent is, and the agent's
3// live activity, and lets the user redirect it with a message.
4
5import { atom, read, update } from 'claude-code'
6import type { AgentInfo, EngineInterface, On, Timer } from 'claude-code'
7
8import type { ActivityRow, Agent, Delivery, Model, NextRun, RedirectOutcome, Squishy, SquishyState } from '../types'
9import {
10  AS_STARTED,
11  EFFORT_CONTROL_NAME,
12  EFFORT_SWITCH_PREFIX,
13  MODEL_CONTROL_NAME,
14  MODEL_SWITCH_PREFIX,
15  NEXT_RUN_NOTE_ENDED,
16  NEXT_RUN_NOTE_RUNNING,
17  NO_EFFORT_TAKEN,
18  NO_OTHER_MODEL,
19  effortControlLabel,
20  effortStep,
21  modelControlLabel,
22  modelSources,
23  modelStep,
24  noteEffortStep,
25  noteModelStep,
26  offeredModels,
27} from './model-switch'
28import { controlColumns, heldButton } from './held'
29import type { Hold } from './held'
30import { OPEN_PANE_ASKED, PANE_ID, PICK_PREFIX, animatedPicture, notePaneOpened, openRefused, pictureKey } from './pane'
31import { PARTNER_BUTTON, PARTNER_KEY, partnerFrom } from './partner'
32import { SHARE_HOTKEY, SHARE_LINK_LABEL, agentShareKey, unopenedShare } from './share'
33import { linedUp } from './slots'
34import { canShare, endedState, isEnded } from './states'
35import {
36  STOP_CONFIRM_MS,
37  STOP_WAIT_MS,
38  askingTaskStop,
39  disarmed,
40  failureOf,
41  holdBack,
42  isArmed,
43  refusalOf,
44  stopUnderWay,
45  taskIdOf,
46  wasStoppedByUser,
47} from './stop'
48import { forgetResumed, fromUser, isResumedByRedirect, markResumed } from './resumes'
49import { printable } from './text'
50
51// The engine reads each $.state reference off the file that uses it, so
52// every file declares its own atom for the values it reads or writes.
53const agents = atom({ plugin: 'squishys', key: 'agents' } as const, [])
54const mode = atom({ plugin: 'squishys', key: 'mode' } as const, 'roster')
55const focusedAgentId = atom({ plugin: 'squishys', key: 'focusedAgentId' } as const, null)
56const fedAgentIds = atom({ plugin: 'squishys', key: 'fedAgentIds' } as const, [])
57const nextRuns = atom({ plugin: 'squishys', key: 'nextRuns' } as const, {})
58const runChoices = atom({ plugin: 'squishys', key: 'runChoices' } as const, {})
59const seenModels = atom({ plugin: 'squishys', key: 'seenModels' } as const, {})
60const stopControl = atom({ plugin: 'squishys', key: 'stopControl' } as const, null)
61const delivery = atom({ plugin: 'squishys', key: 'delivery' } as const, null)
62/** The feeds, one member per agent id, each read as `atom({ ...activity, id }, [])`. */
63const activity = { plugin: 'squishys', key: 'activity' } as const
64
65/** How many of its latest rows each agent's feed keeps. */
66export const FEED_ROWS = 50
67
68/** The most characters a Markdown draws. */
69export const MARKDOWN_LIMIT = 10_000
70
71/** What ends an answer cut to fit a Markdown. */
72const CUT_SHORT = '\n\n… (cut short)'
73
74/** What the focus view's Back button says. */
75const BACK_LABEL = 'Back to the roster'
76
77/** The columns between the focus view's controls. */
78const CONTROL_GAP = 2
79
80/** The longest a tool call's summary runs, in characters. */
81const SUMMARY_LIMIT = 80
82
83/** The longest a redirect's row in the feed runs, in characters. */
84const REDIRECT_ROW_LIMIT = 500
85
86/**
87 * Claude Code's refusal of an append to an agent with no running loop
88 * (`no running loop is <agent id>`), the one refusal a send can stand in for.
89 */
90const NO_RUNNING_LOOP = /no running loop/
91
92/** The arguments that say most about a tool call, the most telling first. */
93const TELLING_ARGUMENTS = ['command', 'file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt', 'skill']
94
95/** The fields of a tool call's input that aren't the tool's arguments. */
96const ENVELOPE_FIELDS = ['tool', 'tool_use_id', 'agentId']
97
98/** The longest TaskStop's refusal runs in the focus view, in characters. */
99const REFUSAL_LIMIT = 200
100
101/** The timer that redraws an armed Stop's note away once no second press can come. */
102let disarm: Timer | undefined
103
104/** The Redirect box's element key, which the redirect control moves the focus onto. */
105const REDIRECT_BOX_KEY = 'redirect'
106
107/** The redirect control's element key. */
108const REDIRECT_CONTROL_KEY = 'focus-redirect'
109
110/** The redirect control's hotkey, as `i` starts typing in vi. */
111const REDIRECT_HOTKEY = 'i'
112
113/** The model control's hotkey, which steps the model of an agent's next run. */
114const MODEL_HOTKEY = 'm'
115
116/** The effort control's hotkey, which steps the effort of an agent's next run. */
117const EFFORT_HOTKEY = 'e'
118
119/** What the Redirect box says while the pane has the keyboard but the box hasn't. */
120export const REDIRECT_HINT = 'Press i to redirect'
121
122/** What the Redirect box says otherwise. */
123const REDIRECT_PLACEHOLDER = 'a message for this agent'
124
125/**
126 * The element of the pane the focus ring is on, by the `ui.focus` moves
127 * that landed. Claude Code says nothing as the pane hands the keyboard
128 * back (Esc), and gives it back with the ring on nothing (seen in a tmux
129 * session for #49), so a drawing of a pane without the keyboard forgets
130 * it. Kept here, never in $.state, which a drawing never writes.
131 */
132let ringOn: string | undefined
133
134/** How each state reads in the focus view. */
135const STATE_NAMES: Record<SquishyState, string> = {
136  working: 'Working',
137  thinking: 'Thinking',
138  needsYou: 'Needs you',
139  asleep: 'Asleep',
140  squished: 'Squished',
141}
142
143/** How each run that ended without an answer reads in the feed. */
144const ENDED_ROWS = { interrupted: 'Interrupted', failed: 'Failed', stopped: 'Stopped by you' } as const
145
146/** The first `length` UTF-16 units of `text`, never ending halfway through a surrogate pair. */
147function cut(text: string, length: number): string {
148  const head = text.slice(0, length)
149  return /[\ud800-\udbff]$/.test(head) ? head.slice(0, -1) : head
150}
151
152/**
153 * A short line of what a tool was called on: its most telling argument
154 * (a command, a path, a pattern), else its first string argument.
155 */
156function summaryOf(call: Record<string, unknown>): string {
157  const strings = Object.entries(call).filter(
158    (entry): entry is [string, string] => !ENVELOPE_FIELDS.includes(entry[0]) && typeof entry[1] === 'string',
159  )
160  const telling = TELLING_ARGUMENTS.map(name => strings.find(([key]) => key === name)).find(found => found !== undefined)
161  return shortened(printable((telling ?? strings[0])?.[1] ?? '').replace(/\s+/g, ' ').trim(), SUMMARY_LIMIT)
162}
163
164/** Text of at most `limit` characters, ending in … where it was cut. */
165function shortened(text: string, limit: number): string {
166  return text.length > limit ? `${cut(text, limit - 1)}…` : text
167}
168
169/** An answer as a Markdown can draw it: printable, and cut short, saying so, past its limit. */
170function drawable(answer: string): string {
171  const text = printable(answer)
172  return text.length > MARKDOWN_LIMIT ? cut(text, MARKDOWN_LIMIT - CUT_SHORT.length) + CUT_SHORT : text
173}
174
175/** What a run that ended adds to its agent's feed, by why it ended. */
176function rowAfterRun(reason: string, answer: string): ActivityRow | undefined {
177  if (reason === 'aborted') return { kind: 'interrupted' }
178  if (reason === 'error') return { kind: 'failed' }
179  return answer.trim() === '' ? undefined : { kind: 'answer', text: drawable(answer) }
180}
181
182/**
183 * The tool through which an agent may hand its report back (Claude Code's
184 * SubagentHandback, seen in a session for #60), in place of a final answer:
185 * its run's turn.complete then carries an empty one.
186 */
187const HANDBACK_TOOL = 'SubagentHandback'
188
189/**
190 * What a tool call adds to its agent's feed: the report a handback carries,
191 * as the agent's answer, or else the tool and what it was called on.
192 */
193function rowOfToolCall(tool: string, call: Record<string, unknown>): ActivityRow {
194  const report = call.message
195  if (tool === HANDBACK_TOOL && typeof report === 'string' && report.trim() !== '') return { kind: 'answer', text: drawable(report) }
196  return { kind: 'tool', tool: printable(tool), summary: summaryOf(call) }
197}
198
199
200/**
201 * A feed with a row added: only the latest answer kept, and only the latest
202 * FEED_ROWS rows. A run the user stopped reads Stopped by you, not also
203 * Interrupted, whichever of the two comes first.
204 */
205function withRow(feed: readonly ActivityRow[], row: ActivityRow): ActivityRow[] {
206  const last = feed.at(-1)?.kind
207  if (row.kind === 'interrupted' && last === 'stopped') return [...feed]
208  let kept: readonly ActivityRow[] = feed
209  if (row.kind === 'answer') kept = feed.filter(each => each.kind !== 'answer')
210  if (row.kind === 'stopped' && last === 'interrupted') kept = feed.slice(0, -1)
211  return [...kept, row].slice(-FEED_ROWS)
212}
213
214/** Whether a pick came from outside the pane, as from the band, which leaves the pane to open. */
215function pickedOutsidePane(press: { component: string }): boolean {
216  return press.component !== 'Pane'
217}
218
219/**
220 * What the focus view says while the pane lacks the keyboard, as after a
221 * pick from the band, whose press holds the keys. On the main screen
222 * (Claude Code's classic rendering) a click never reaches the pane.
223 */
224export function focusHint(isFullscreen: boolean): string {
225  return isFullscreen ? 'Click here or press Ctrl+X Tab' : 'Press Ctrl+X Tab'
226}
227
228export function registerFocus(on: On): void {
229  // The feed's hooks fit agents' events alone. They must run after the
230  // agent tracker's hooks on the same events, which record an agent first
231  // seen through this very tool call: hooks/register.tsx registers the
232  // tracker first, and a plugin's registrations nest in order, first
233  // outermost.
234  on('tool.call', { agentId: /./ }, async ($, e, next) => {
235    if (e.agentId !== undefined) await addActivity($, e.agentId, rowOfToolCall(e.tool, { ...e }))
236    return next(e)
237  })
238
239  // A run a redirect resumed raises no tool.call the mod sees (AGENTS.md, "The
240  // run a redirect resumes"), so its tool calls come from the blocks of its response
241  // as Claude Code appends them, read on their way down: the row goes on as
242  // it came. Any other run's tool calls reach the hook above.
243  on('session.append', { agentId: /./, door: 'response' }, async ($, e, next) => {
244    const { agentId } = e
245    if (agentId !== undefined && isResumedByRedirect(agentId)) {
246      for (const block of e.message.content) {
247        if (block.type !== 'tool_use' || typeof block.name !== 'string') continue
248        const input = typeof block.input === 'object' && block.input !== null ? { ...block.input } : {}
249        await addActivity($, agentId, rowOfToolCall(block.name, input))
250      }
251    }
252    return next(e)
253  })
254
255  // A run the user's Stop ended at its next step was stopped, whatever it
256  // answered. The tracker, outside this hook, lets the agent go after it.
257  on('turn.complete', { agentId: /./ }, async ($, e, next) => {
258    const completed = await next(e)
259    const { agentId } = e
260    if (agentId === undefined) return completed
261    const row = wasStoppedByUser(agentId) ? { kind: 'stopped' as const } : rowAfterRun(e.reason, e.answer)
262    if (row !== undefined) await addActivity($, agentId, row)
263    forgetResumed(agentId)
264    // A redirect's delivery is out of date once its agent ends
265    await update($, delivery, latest => (latest?.agentId === agentId ? null : latest))
266    return completed
267  })
268
269  // The pick: a press on any Button keyed `squishy-<agent id>` (PICK_PREFIX:
270  // a roster slot's, by click or digit) opens that agent's focus view. One
271  // handler for every place a squishy can be picked from. Picked outside the
272  // pane (from the band, while the pane is unplaced), it opens the pane too:
273  // asked for by the press, it's placed at any width and asks for the
274  // keyboard (OPEN_PANE_ASKED, likely refused while the band holds the keys),
275  // and the band is drawn again to step aside. Picked in a pane that lacks
276  // the keyboard (a click there presses without handing it over: seen in a
277  // tmux session for #49), it asks for the keyboard the same way, so the
278  // focus view's hotkeys work at once. It answers the press itself: the
279  // Buttons' own onPress is a no-op, and the redraw these writes bring
280  // drops the handler that next(e) would reach.
281  on('ui.press', { plugin: 'squishys', element: /^squishy-/ }, async ($, e) => {
282    const agentId = e.element.slice(PICK_PREFIX.length)
283    ringOn = undefined
284    await leaveStop($)
285    await update($, delivery, latest => (latest?.agentId === agentId ? latest : null))
286    await update($, focusedAgentId, () => agentId)
287    await update($, mode, () => 'focus')
288    if (pickedOutsidePane(e)) {
289      try {
290        await $.ui.open(OPEN_PANE_ASKED)
291        notePaneOpened(OPEN_PANE_ASKED)
292      } catch (error) {
293        $.ui.toast(openRefused(error))
294      }
295      $.ui.invalidate('ui.render')
296    } else {
297      await askForKeyboard($)
298    }
299    return { element: e.element }
300  })
301
302  // The redirect control (i) moves the focus into the Redirect box, awaited
303  // inside the press's own dispatch. The control's element key is spelled
304  // out, since the engine reads a matcher off this file alone.
305  on('ui.press', { plugin: 'squishys', element: 'focus-redirect' }, async ($, e, next) => {
306    await focusRedirect($)
307    return next(e)
308  })
309
310  // Where the pane's focus ring lands, so the Redirect box says to press
311  // i only while it lacks the focus
312  on('ui.focus', { component: 'Pane', requestId: 'squishys' }, async ($, e, next) => {
313    const moved = await next(e)
314    if (moved.deny === undefined) noteRing($, e.element)
315    return moved
316  })
317
318  // Stop takes two presses: the first arms it, and a second within
319  // STOP_CONFIRM_MS of the first stops the agent. Only a running agent that
320  // isn't already being stopped can be.
321  on('ui.press', { plugin: 'squishys', element: 'stop' }, async ($, e, next) => {
322    const pressed = await next(e)
323    const id = await read($, focusedAgentId)
324    const agent = (await read($, agents)).find(each => each.id === id)
325    if (agent === undefined || !canStop(agent)) return pressed
326    // Awaited, so the agent tracker's hook sees the TaskStop call: the press
327    // takes at most STOP_WAIT_MS more
328    if (isArmed(await read($, stopControl), agent.id, await $.clock.now())) await stop($, agent)
329    else await arm($, agent.id)
330    return pressed
331  })
332
333  // The focus view. Each mode's hook draws only while the pane is in that
334  // mode and passes the drawing on otherwise; the pane's id is spelled out,
335  // since the engine reads a matcher off this file alone.
336  on('ui.render', { component: 'Pane', requestId: 'squishys' }, async ($, e, next) => {
337    if (e.surface !== 'terminal' || (await read($, mode)) !== 'focus') return next(e)
338    const { Box, Button, Input, Link, Markdown, Raster, Text } = $.ui.resolve(e)
339    // Given the keyboard again, the pane's ring starts on nothing
340    if (!e.props.isFocused) ringOn = undefined
341    const id = await read($, focusedAgentId)
342    const agent = (await read($, agents)).find(each => each.id === id)
343    const back = <Button key="back" hotkey="r" plain label={BACK_LABEL} onPress={() => void leaveFocus($)} />
344    // The partner stands for the orchestrator: picking it returns to the
345    // roster, like the slot's digit there
346    let partner: Squishy | undefined
347    try {
348      partner = partnerFrom(await $.store.get(PARTNER_KEY))
349    } catch {}
350    // Every control is drawn in every state, so no hotkey reaches the prompt:
351    // one that doesn't apply is held (src/held.tsx answers its press). Each is
352    // budgeted at its widest, live or held, so the rows they're lined up in
353    // don't change between states.
354    const stopHold = agent === undefined ? GONE : canStop(agent) ? undefined : stopHoldOf(agent)
355    const shareHold = agent === undefined ? GONE : canShare(agent) ? undefined : shareHoldOf(agent)
356    const shareKey = agentShareKey(agent?.id ?? id ?? '')
357    // The compose page of a Share the browser didn't open
358    const shareLink = shareHold === undefined ? unopenedShare(shareKey) : undefined
359    const controls: { columns: number; drawn: JSX.Element }[] = [
360      { columns: controlColumns(BACK_LABEL, 'r'), drawn: back },
361      {
362        columns: controlColumns('Stop', 's', STOP_WHYS),
363        drawn: stopHold === undefined ? <Button key="stop" hotkey="s" plain label="Stop" onPress={() => {}} /> : heldButton(Button, 'stop', 's', 'Stop', stopHold),
364      },
365      {
366        columns: Math.max(controlColumns(partner?.name ?? PARTNER_LABEL, '1'), controlColumns(PARTNER_LABEL, '1', [NO_PARTNER_WHY])),
367        drawn:
368          partner !== undefined ? (
369            <Button key={PARTNER_BUTTON} hotkey="1" plain dimColor label={partner.name} onPress={() => void leaveFocus($)} />
370          ) : (
371            heldButton(Button, PARTNER_BUTTON, '1', PARTNER_LABEL, NO_PARTNER)
372          ),
373      },
374      // Answered by the ui.press hook in share.tsx
375      {
376        columns: controlColumns('Share', SHARE_HOTKEY, SHARE_WHYS),
377        drawn:
378          shareHold === undefined ? (
379            <Button key={shareKey} hotkey={SHARE_HOTKEY} plain label="Share" onPress={() => {}} />
380          ) : (
381            heldButton(Button, shareKey, SHARE_HOTKEY, 'Share', shareHold)
382          ),
383      },
384      ...(shareLink !== undefined ? [{ columns: SHARE_LINK_LABEL.length, drawn: <Link key="focus-share-link" href={shareLink} label={SHARE_LINK_LABEL} /> }] : []),
385    ]
386    // As many controls to a row as fit the pane
387    const controlRows = (
388      <Box key="controls" flexDirection="column">
389        {linedUp(
390          controls.map(control => control.columns),
391          e.props.bodyColumns,
392          CONTROL_GAP,
393        ).map((line, index) => (
394          <Box key={`controls-${index}`} flexDirection="row" columnGap={CONTROL_GAP}>
395            {line.map(at => controls[at]?.drawn)}
396          </Box>
397        ))}
398      </Box>
399    )
400    if (agent === undefined) {
401      return (
402        <Box flexDirection="column" rowGap={1}>
403          <Text dimColor>That agent is no longer here.</Text>
404          {controlRows}
405          {heldButton(Button, `${MODEL_SWITCH_PREFIX}${id ?? ''}`, MODEL_HOTKEY, MODEL_CONTROL_NAME, GONE)}
406          {heldButton(Button, `${EFFORT_SWITCH_PREFIX}${id ?? ''}`, EFFORT_HOTKEY, EFFORT_CONTROL_NAME, GONE)}
407          <Box key="redirect-row">{heldButton(Button, REDIRECT_CONTROL_KEY, REDIRECT_HOTKEY, 'Redirect', GONE)}</Box>
408        </Box>
409      )
410    }
411    // Only this agent's feed: another agent's activity doesn't redraw it
412    const feed = await read($, atom({ ...activity, id: agent.id }, []))
413    // The models the model control offers for the agent's next run: those
414    // the session's requests used that the allowlist names; none while it
415    // can't be read (src/model-switch.ts keeps the picks and answers the
416    // model and effort controls)
417    const seen = await read($, seenModels)
418    let main: string | undefined
419    try {
420      main = await $.session.model()
421    } catch {}
422    const sources = modelSources(await read($, agents), agent.id, main)
423    let offered: Model[] = []
424    try {
425      offered = offeredModels(sources, (await $.settings.read()).availableModels)
426    } catch {}
427    // The run going on, on a model picked for it, shows that model
428    const runModel = isEnded(agent.state) ? undefined : (await read($, runChoices))[agent.id]?.model
429    const shownModel = runModel !== undefined ? `${runModel} (picked)` : agent.model
430    // The controls step through the next run's choices, held while there's
431    // nothing to pick; src/model-switch.ts answers their presses with the
432    // steps they were drawn with
433    const picks = await read($, nextRuns)
434    const step = modelStep(offered, picks[agent.id]?.model)
435    noteModelStep(agent.id, step)
436    const effort = effortStep(agent.id, { nextRuns: picks, seenModels: seen, sources })
437    noteEffortStep(agent.id, effort)
438    const modelKey = `${MODEL_SWITCH_PREFIX}${agent.id}`
439    const effortKey = `${EFFORT_SWITCH_PREFIX}${agent.id}`
440    const control = await read($, stopControl)
441    const note = stopNote(agent, control?.agentId === agent.id && isArmed(control, agent.id, await $.clock.now()))
442    // The latest redirect: to a running agent, while it hasn't ended since; to
443    // an ended one, through the run it resumes (its turn.complete clears it)
444    const latest = await read($, delivery)
445    const shownDelivery = latest?.agentId === agent.id && (latest.wasEnded || !isEnded(agent.state)) ? latest : undefined
446    return (
447      <Box key="focus" flexDirection="column" rowGap={1}>
448        <Box key="focus-header" flexDirection="row" columnGap={2}>
449          <Raster key={pictureKey(agent.id, 'double')} {...animatedPicture(agent, 'double')} />
450          <Box flexDirection="column">
451            <Text bold>{agent.squishy.name}</Text>
452            <Text>{STATE_NAMES[agent.state]}</Text>
453            {shownModel !== undefined ? <Text dimColor>{shownModel}</Text> : null}
454            {step.on === AS_STARTED && step.next === AS_STARTED ? (
455              heldButton(Button, modelKey, MODEL_HOTKEY, MODEL_CONTROL_NAME, NO_OTHER_MODEL)
456            ) : (
457              <Button key={modelKey} hotkey={MODEL_HOTKEY} plain label={modelControlLabel(step)} onPress={() => {}} />
458            )}
459            {effort.isTaken ? (
460              <Button key={effortKey} hotkey={EFFORT_HOTKEY} plain label={effortControlLabel(effort)} onPress={() => {}} />
461            ) : (
462              heldButton(Button, effortKey, EFFORT_HOTKEY, EFFORT_CONTROL_NAME, NO_EFFORT_TAKEN)
463            )}
464            <Text>{agent.description}</Text>
465            {/* Beside the 2× picture, which is taller than this column, and cut short: it never adds a row */}
466            {e.props.isFocused ? null : (
467              <Text dimColor wrap="truncate-end">
468                {focusHint(e.viewport?.isFullscreen === true)}
469              </Text>
470            )}
471          </Box>
472        </Box>
473        {controlRows}
474        {/* Which runs a pick reaches: AGENTS.md, "Which runs a pick reaches" */}
475        {picks[agent.id] === undefined ? null : (
476          <Box key="next-run-note">
477            <Text dimColor>{isEnded(agent.state) ? NEXT_RUN_NOTE_ENDED : NEXT_RUN_NOTE_RUNNING}</Text>
478          </Box>
479        )}
480        {note === undefined ? null : (
481          <Box key="stop-note">
482            <Text color="yellow">{note}</Text>
483          </Box>
484        )}
485        {/* The redirect control (i) moves the focus into the Redirect box, and typing there never presses a hotkey: AGENTS.md, "While an Input has the focus" */}
486        <Box key="redirect-row" flexDirection="column">
487          <Box key="redirect-field" flexDirection="row" columnGap={1}>
488            {/* Answered by the ui.press hook on the redirect control's element key */}
489            <Button key={REDIRECT_CONTROL_KEY} hotkey={REDIRECT_HOTKEY} plain label="Redirect" onPress={() => {}} />
490            <Input
491              key={REDIRECT_BOX_KEY}
492              placeholder={e.props.isFocused && ringOn !== REDIRECT_BOX_KEY ? REDIRECT_HINT : REDIRECT_PLACEHOLDER}
493              submitLabel="send"
494              onSubmit={text => void redirect($, agent, text)}
495            />
496          </Box>
497          {shownDelivery === undefined ? null : shownDelivery.outcome === undefined ? (
498            <Text dimColor>Sending…</Text>
499          ) : shownDelivery.outcome.isDelivered ? (
500            <Text>
501              {shownDelivery.outcome.viaClaude !== undefined
502                ? `${viaClaudeNote(shownDelivery.outcome.viaClaude)} Claude passes it to ${agent.squishy.name} once it’s free.`
503                : shownDelivery.outcome.viaResume
504                  ? `Sent to ${agent.squishy.name}. It had finished; the message resumed it.`
505                  : `Sent to ${agent.squishy.name}`}
506            </Text>
507          ) : (
508            <Text color="red">Not sent: {printable(shownDelivery.outcome.reason)}</Text>
509          )}
510        </Box>
511        <Box key="activity" flexDirection="column">
512          {feed.length === 0 && agent.state !== 'thinking' ? <Text dimColor>No activity yet.</Text> : null}
513          {feed.map((row, index) => {
514            const key = `activity-${index}`
515            if (row.kind === 'answer') return <Markdown key={key} text={row.text} />
516            if (row.kind === 'redirect') {
517              return (
518                <Box key={key}>
519                  <Text bold>You: {shortened(row.text, REDIRECT_ROW_LIMIT)}</Text>
520                </Box>
521              )
522            }
523            if (row.kind !== 'tool') {
524              return (
525                <Box key={key}>
526                  <Text italic>{ENDED_ROWS[row.kind]}</Text>
527                </Box>
528              )
529            }
530            return (
531              <Box key={key} flexDirection="row" columnGap={1}>
532                <Text bold>{row.tool}</Text>
533                <Text dimColor wrap="truncate-end">
534                  {row.summary}
535                </Text>
536              </Box>
537            )
538          })}
539          {agent.state === 'thinking' ? (
540            <Text italic dimColor>
541              Thinking…
542            </Text>
543          ) : null}
544        </Box>
545      </Box>
546    )
547  })
548}
549
550/**
551 * The agents a redirect is being sent to, so a second Enter while one is on
552 * its way sends nothing. Kept here rather than in $.state, whose reads in a
553 * second dispatch may predate the first one's write.
554 */
555const sending = new Set<string>()
556
557/**
558 * Sends the user's message to an agent and records the delivery: shown in
559 * the focus view, and once delivered, added to the agent's feed.
560 */
561async function redirect($: EngineInterface, agent: Agent, typed: string): Promise<void> {
562  const text = typed.trim()
563  if (text === '' || sending.has(agent.id)) return
564  sending.add(agent.id)
565  try {
566    const wasEnded = isEnded(agent.state)
567    const pending: Delivery = { agentId: agent.id, wasEnded }
568    await update($, delivery, () => pending)
569    const outcome = await deliver($, agent, fromUser(text))
570    await update($, delivery, () => ({ ...pending, outcome }))
571    if (outcome.isDelivered) await addActivity($, agent.id, { kind: 'redirect', text: printable(text) })
572  } finally {
573    sending.delete(agent.id)
574  }
575}
576
577/**
578 * Delivers a message to an agent: a running one reads it, appended to its
579 * conversation, at the start of its next step; an ended one is sent it,
580 * which resumes it, and the tracker wakes its squishy as the message lands
581 * in its conversation (AGENTS.md, "The run a redirect resumes").
582 * A running agent's append refused for want of a running loop (it ended
583 * before its squishy showed it) is sent instead; any other refusal stands.
584 * The test kit can't append: AGENTS.md, "A redirect goes".
585 */
586async function deliver($: EngineInterface, agent: Agent, message: string): Promise<RedirectOutcome> {
587  if (!isEnded(agent.state)) {
588    let refusal: string
589    try {
590      const appended = await $.session.append({ agentId: agent.id, message: { type: 'user', content: [{ type: 'text', text: message }] } })
591      if (appended.deny === undefined) return { isDelivered: true, viaResume: false }
592      refusal = appended.deny
593    } catch (error) {
594      refusal = reasonOf(error)
595    }
596    if (!NO_RUNNING_LOOP.test(refusal)) return { isDelivered: false, reason: refusal }
597  }
598  // With a model or effort picked for its next run, through Claude: a run
599  // the mod's own send resumes never reaches its turn.step, so the pick
600  // couldn't apply (AGENTS.md, "Which runs a pick reaches")
601  const pick = (await read($, nextRuns))[agent.id]
602  if (pick !== undefined) return relayThroughClaude($, agent.id, message, pick)
603  // Marked before the send, which may start the run before it answers
604  markResumed(agent.id)
605  let outcome: RedirectOutcome
606  try {
607    const sent = await $.session.send({ to: { agentId: agent.id }, text: message })
608    outcome = sent.isDelivered ? { isDelivered: true, viaResume: true } : { isDelivered: false, reason: sent.reason }
609  } catch (error) {
610    outcome = { isDelivered: false, reason: reasonOf(error) }
611  }
612  if (!outcome.isDelivered) forgetResumed(agent.id)
613  return outcome
614}
615
616/**
617 * Hands a redirect to Claude, which sends it to the agent with SendMessage:
618 * a run the orchestrator resumes reaches the mod's turn.step, so the agent's
619 * pick applies. Submitted as the user's own words, since the user typed the
620 * redirect; Claude Code runs it once Claude is free.
621 */
622async function relayThroughClaude($: EngineInterface, agentId: string, message: string, pick: NextRun): Promise<RedirectOutcome> {
623  try {
624    const submitted = await $.prompt.submit({ text: relayPrompt(agentId, message), asUser: true })
625    if (submitted.drop !== undefined) return { isDelivered: false, reason: submitted.drop }
626  } catch (error) {
627    return { isDelivered: false, reason: reasonOf(error) }
628  }
629  $.ui.toast(`Squishys: ${viaClaudeNote(pick)}`)
630  return { isDelivered: true, viaResume: true, viaClaude: pick }
631}
632
633/** What Claude is asked to do with a redirect: send it on, word for word, and nothing more. */
634export function relayPrompt(agentId: string, message: string): string {
635  return `Use SendMessage to send this exact message to agent ${agentId}, then do nothing else:\n\n${message}`
636}
637
638/** What the focus view and toast say of a redirect sent through Claude. */
639export function viaClaudeNote(pick: NextRun): string {
640  const what = pick.model !== undefined && pick.effort !== undefined ? 'model and effort apply' : pick.model !== undefined ? 'model applies' : 'effort applies'
641  return `Sent via Claude so the new ${what}.`
642}
643
644function reasonOf(error: unknown): string {
645  return error instanceof Error ? error.message : String(error)
646}
647
648/**
649 * Adds a row to an agent's feed; agents without a squishy are left out.
650 * Feeds of agents the tracker no longer knows are emptied on the way, and
651 * while the pane shows this agent's focus view, it scrolls to the new row.
652 */
653async function addActivity($: EngineInterface, agentId: string, row: ActivityRow): Promise<void> {
654  const known = (await read($, agents)).map(agent => agent.id)
655  if (!known.includes(agentId)) return
656  const fed = await read($, fedAgentIds)
657  const gone = fed.filter(id => !known.includes(id))
658  for (const id of gone) await update($, atom({ ...activity, id }, []), () => [])
659  if (gone.length > 0 || !fed.includes(agentId)) {
660    await update($, fedAgentIds, ids => [...ids.filter(id => known.includes(id) && id !== agentId), agentId])
661  }
662  await update($, atom({ ...activity, id: agentId }, []), feed => withRow(feed, row))
663  if ((await read($, mode)) === 'focus' && (await read($, focusedAgentId)) === agentId) {
664    try {
665      await $.ui.scroll({ in: PANE_ID, to: 'end' })
666    } catch {} // a pane that can't scroll now shows the row once the user scrolls
667  }
668}
669
670/** Whether Stop is offered for this agent: it runs, and no stop of it is under way. */
671function canStop(agent: Agent): boolean {
672  return !isEnded(agent.state) && stopUnderWay(agent.id) === undefined
673}
674
675/** Why the controls of an agent the tracker no longer knows are held. */
676const GONE: Hold = { why: 'gone', reason: 'that agent is no longer here.' }
677
678/** Why Stop can be held, which its budget in the row counts: ended, a stop under way, or gone. */
679const STOP_FINISHED = 'finished'
680const STOP_STOPPING = 'stopping…'
681const STOP_WHYS = [STOP_FINISHED, STOP_STOPPING, 'gone']
682
683/** Why Stop is held for an agent it isn't offered for: it has ended, or a stop of it is under way. */
684function stopHoldOf(agent: Agent): Hold {
685  const { name } = agent.squishy
686  if (isEnded(agent.state)) return { why: STOP_FINISHED, reason: `${name} has finished, so there’s nothing to stop.` }
687  return { why: STOP_STOPPING, reason: `${name} is already being stopped.` }
688}
689
690/** Why Share can be held, which its budget in the row counts: still running, Squished, or gone. */
691const SHARE_RUNNING = 'once Asleep'
692const SHARE_WHYS = [SHARE_RUNNING, STATE_NAMES.squished, 'gone']
693
694/** Why Share is held for an agent it isn't offered for: it still runs, or it's Squished. */
695function shareHoldOf(agent: Agent): Hold {
696  const { name } = agent.squishy
697  if (!isEnded(agent.state)) return { why: SHARE_RUNNING, reason: `${name} is still running. Share works once it’s Asleep.` }
698  return { why: STATE_NAMES.squished, reason: `only an Asleep squishy can be shared, and ${name} is ${STATE_NAMES.squished}.` }
699}
700
701/** The partner's control while no partner is saved, held. */
702const PARTNER_LABEL = 'Partner'
703const NO_PARTNER_WHY = 'none yet'
704const NO_PARTNER: Hold = { why: NO_PARTNER_WHY, reason: 'no partner is saved yet. r goes back to the roster.' }
705
706/** What the Stop control says about this agent, if anything: armed, or a stop under way. */
707function stopNote(agent: Agent, armed: boolean): string | undefined {
708  const { name } = agent.squishy
709  if (armed) return `press s again to stop ${name}`
710  // Once the agent has ended, its state and feed say how it went
711  const underWay = isEnded(agent.state) ? undefined : stopUnderWay(agent.id)
712  if (underWay === undefined) return undefined
713  if (underWay.by === 'taskStop') return `Stopping ${name}…`
714  const why = underWay.refusal === undefined ? '' : ` TaskStop didn't stop it: ${refusalLine(underWay.refusal)}`
715  return `Stopping ${name} at its next step: its tool calls are refused.${why}`
716}
717
718/**
719 * Moves the pane's focus into the Redirect box, so the user's keys type
720 * there. Claude Code refuses it while the pane lacks the keyboard, as after
721 * a click on the redirect control, which presses it without handing the
722 * pane the keyboard, so it asks for the keyboard first. A refusal is toasted.
723 */
724async function focusRedirect($: EngineInterface): Promise<void> {
725  await askForKeyboard($)
726  let refusal: string | undefined
727  try {
728    refusal = (await $.ui.focus({ requestId: PANE_ID, key: REDIRECT_BOX_KEY })).deny
729  } catch (error) {
730    refusal = reasonOf(error)
731  }
732  if (refusal === undefined) noteRing($, REDIRECT_BOX_KEY)
733  else $.ui.toast(`Squishys: the Redirect box can’t take the keys: ${refusalLine(refusal)}`)
734}
735
736/**
737 * Asks for the keyboard while the pane is open without it, as an open the
738 * user's press asked for (`focus` is a request, granted only over an empty
739 * prompt). A pane list that can't be read leaves the pane as it is, and
740 * the focus view says how to give it the keyboard.
741 */
742async function askForKeyboard($: EngineInterface): Promise<void> {
743  try {
744    if ((await $.ui.panes()).some(pane => pane.id === PANE_ID && !pane.isFocused)) {
745      await $.ui.open(OPEN_PANE_ASKED)
746      notePaneOpened(OPEN_PANE_ASKED)
747    }
748  } catch {}
749}
750
751/** Notes where the pane's focus ring landed, redrawing the Redirect box's hint when it changes. */
752function noteRing($: EngineInterface, element: string | undefined): void {
753  const wasOnRedirect = ringOn === REDIRECT_BOX_KEY
754  ringOn = element
755  if (wasOnRedirect !== (element === REDIRECT_BOX_KEY)) $.ui.invalidate('ui.render')
756}
757
758/**
759 * Back to the roster, disarming Stop and clearing the latest redirect's
760 * delivery on the way. The mark of a redirect's run goes too once its agent
761 * shows ended, as when the run never started; one still running keeps
762 * filling its feed.
763 */
764async function leaveFocus($: EngineInterface): Promise<void> {
765  ringOn = undefined
766  const id = await read($, focusedAgentId)
767  if (id !== null && (await read($, agents)).some(agent => agent.id === id && isEnded(agent.state))) forgetResumed(id)
768  await leaveStop($)
769  await update($, delivery, () => null)
770  await update($, mode, () => 'roster')
771}
772
773/**
774 * Disarms Stop as the focus view changes agent or mode. A stop under way
775 * carries on, and says how it went if its agent's focus view opens again.
776 */
777async function leaveStop($: EngineInterface): Promise<void> {
778  disarm?.cancel()
779  await update($, stopControl, control => disarmed(control))
780}
781
782/** Arms Stop for an agent, from now; its note is drawn away once no second press can come. */
783async function arm($: EngineInterface, agentId: string): Promise<void> {
784  disarm?.cancel()
785  const armedAt = await $.clock.now()
786  await update($, stopControl, () => ({ agentId, armedAt }))
787  disarm = $.clock.after(STOP_CONFIRM_MS, () => $.ui.invalidate('ui.render'))
788}
789
790/**
791 * Stops an agent through TaskStop, whose call the agent tracker sees (its
792 * squishy is Squished once the agent list shows it ended). When TaskStop is
793 * refused, fails, leaves the agent running by the agent list, or gives no
794 * answer within STOP_WAIT_MS, the tracker holds the agent back instead.
795 */
796async function stop($: EngineInterface, agent: Agent): Promise<void> {
797  disarm?.cancel()
798  askingTaskStop(agent.id, true)
799  await update($, stopControl, control => disarmed(control))
800  const taskId = taskIdOf(agent.id, await agentList($))
801  let timer: Timer | undefined
802  const tooSlow = new Promise<undefined>(resolve => {
803    timer = $.clock.after(STOP_WAIT_MS, () => resolve(undefined))
804  })
805  const taskStop = $.tool.call({ tool: 'TaskStop', task_id: taskId }).then(
806    result => ({ refusal: refusalOf(result) }),
807    (error: unknown) => ({ refusal: failureOf(error) }),
808  )
809  const outcome = await Promise.race([taskStop, tooSlow])
810  timer?.cancel()
811  askingTaskStop(agent.id, false)
812  if (outcome !== undefined && outcome.refusal === undefined && hasEnded(agent.id, await agentList($))) {
813    await addActivity($, agent.id, { kind: 'stopped' })
814  } else {
815    holdBack(agent.id, outcome?.refusal)
816  }
817  $.ui.invalidate('ui.render')
818}
819
820/** A refusal (TaskStop's, a focus move's) as one printable line, cut short past REFUSAL_LIMIT. */
821function refusalLine(refusal: string): string {
822  const line = printable(refusal).replace(/\s+/g, ' ').trim()
823  return line.length > REFUSAL_LIMIT ? `${cut(line, REFUSAL_LIMIT - 1)}…` : line
824}
825
826/** The agent list, or none when it can't be read. */
827async function agentList($: EngineInterface): Promise<readonly AgentInfo[]> {
828  try {
829    return await $.agent.list()
830  } catch {
831    return []
832  }
833}
834
835/** Whether the agent list shows this agent ended. */
836function hasEnded(agentId: string, listed: readonly AgentInfo[]): boolean {
837  const status = listed.find(each => each.id === agentId)?.status
838  return status !== undefined && endedState(status) !== undefined
839}
840
src/held.tsx 66 lines
1// Held controls: a control a mode can show stays drawn while it doesn't
2// apply, dimmed, so its hotkey is still held. Without it, the pane hands
3// the key to the prompt, which types it there (AGENTS.md, "The keyboard in
4// a pane"). Its press toasts why and does nothing else: a held control is
5// keyed apart from the live one (HELD_PREFIX), so no hook that acts on the
6// live control ever sees it.
7
8import type { ButtonProps, ElementConstructor, On, RenderElement } from 'claude-code'
9
10import { buttonColumns } from './slots'
11
12/** What a held control's element key starts with, before the live control's key. */
13export const HELD_PREFIX = 'held-'
14
15/**
16 * Why a control is held: `why`, a few words after its label (none where
17 * the label keeps the live one's width), and `reason`, what its press toasts.
18 */
19export type Hold = { why?: string; reason: string }
20
21/** A held control's element key, for the live control keyed `key`. */
22export function heldKey(key: string): string {
23  return `${HELD_PREFIX}${key}`
24}
25
26/** A held control's label: the live control's, then why it's held. */
27export function heldLabel(label: string, why: string | undefined): string {
28  return why === undefined ? label : `${label} (${why})`
29}
30
31/**
32 * The columns a control is budgeted in a lined-up row: its widest, live or
33 * held for any of `whys`, so the rows don't change as it's held or let go.
34 */
35export function controlColumns(label: string, hotkey: string, whys: readonly string[] = []): number {
36  return Math.max(buttonColumns(label, hotkey), ...whys.map(why => buttonColumns(heldLabel(label, why), hotkey)))
37}
38
39/**
40 * Each held control's reason, by its element key, as it was last drawn:
41 * kept here (a drawing never writes $.state). A press that finds none (the
42 * module reloaded since) toasts FALLBACK.
43 */
44const reasons = new Map<string, string>()
45
46/** What a held control's press toasts when its reason isn't known. */
47const FALLBACK = 'that control doesn’t apply right now.'
48
49/**
50 * Draws the control keyed `key` held: its hotkey, dimmed, its label followed
51 * by why, keyed heldKey(key). Notes the reason its press toasts.
52 */
53export function heldButton(Button: ElementConstructor<ButtonProps>, key: string, hotkey: string, label: string, hold: Hold): RenderElement {
54  reasons.set(heldKey(key), hold.reason)
55  return <Button key={heldKey(key)} hotkey={hotkey} plain dimColor label={heldLabel(label, hold.why)} onPress={() => {}} />
56}
57
58export function registerHeld(on: On): void {
59  // A press of any held control says why it doesn't apply. Its own onPress
60  // does nothing.
61  on('ui.press', { plugin: 'squishys', element: /^held-/ }, async ($, e, next) => {
62    $.ui.toast(`Squishys: ${reasons.get(e.element) ?? FALLBACK}`)
63    return next(e)
64  })
65}
66
src/model-switch.ts 381 lines
1// The next run's model and effort: the focus view's model and effort
2// controls pick, for one agent, what its next run uses, typically while it's
3// Asleep, applied when it's resumed by rewriting that run's requests in
4// turn.step. A run never changes model or effort partway: a pick made while
5// the agent works waits for its next run. The focus view draws the controls;
6// this module keeps the picks.
7//
8// What the spike for #65 found (Claude Code 2.1.289): a resumed run's
9// requests come in on the model the agent started on, every run, so a pick
10// is applied to every request of every run after it. A request rewritten to
11// a bare alias fails (404 model_not_found, the agent Squished), and nothing
12// on $ resolves an alias, so a pick is sent as the full id the session's own
13// requests named for that model. A run a redirect resumes (the mod's own
14// $.session.send) never reaches the mod's turn.step, so a redirect to an
15// Asleep agent with a pick goes through Claude instead (src/focus.tsx).
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, ModelEffort, On, TurnStepInput } from 'claude-code'
19
20import type { Agent, Effort, Model, NextRun, RunChoice } from '../types'
21import type { Hold } from './held'
22import { cycleLabel, nextOf } from './keys'
23import { MODELS, isModel, modelCycle } from './settings'
24
25/** The model control's element key: this, then the agent id. */
26export const MODEL_SWITCH_PREFIX = 'model-switch-'
27
28/** The effort control's element key: this, then the agent id. */
29export const EFFORT_SWITCH_PREFIX = 'effort-switch-'
30
31/** The model and effort controls' choice of what the agent started on. */
32export const AS_STARTED = 'as-started'
33
34/** What the model control's label starts with, and the effort control's. */
35export const MODEL_CONTROL_NAME = 'next run'
36export const EFFORT_CONTROL_NAME = 'next run effort'
37
38/**
39 * What the focus view says while a pick is set, by whether the agent has
40 * ended. A redirect to a running agent joins the run it's on (an append),
41 * which keeps its model. A run the mod's own $.session.send resumes never
42 * reaches the mod's turn.step, so a redirect to an ended agent with a pick
43 * goes through Claude, whose SendMessage resumes it on the pick.
44 */
45export const NEXT_RUN_NOTE_RUNNING = 'Applies from its next run. A redirect now joins the run it’s on, on its current model.'
46export const NEXT_RUN_NOTE_ENDED = 'Applies from its next run. A redirect from here goes through Claude, so the pick applies.'
47
48/** Why the model control is held: nothing but as started to pick. */
49export const NO_OTHER_MODEL: Hold = {
50  why: 'nothing else yet',
51  reason:
52    'no other model to pick yet. A model can be picked once this session runs on it (Claude, or an agent), since only its full id works for a request, and only while your availableModels setting allows it.',
53}
54
55/** Why the effort control is held: the model the next run uses takes none. */
56export const NO_EFFORT_TAKEN: Hold = { why: 'n/a', reason: 'no effort to pick. The model its next run uses takes none.' }
57
58/** What the model control is on for an agent, whether the allowlist still names it, and what its next press picks. */
59export type ModelStep = { on: string; isAllowed: boolean; next: string }
60
61/**
62 * The model control's step for an agent with `picked` (none: as started),
63 * among the models `offered`: the label and the press both resolve it so.
64 * From a model no longer offered, the next is the first offered after it in
65 * MODELS order, else as started.
66 */
67export function modelStep(offered: readonly Model[], picked: Model | undefined): ModelStep {
68  const cycle = modelCycle(AS_STARTED, offered)
69  const on = picked ?? AS_STARTED
70  return { on, isAllowed: cycle.includes(on), next: nextOf(cycle, on, modelCycle(AS_STARTED)) ?? AS_STARTED }
71}
72
73/** The model control's label: the model it's on (said to be no longer allowed), then what the next press picks, if anything else. */
74export function modelControlLabel({ on, isAllowed, next }: ModelStep): string {
75  const now = isAllowed ? choiceName(on) : `${on} (not allowed)`
76  return next === on ? `${MODEL_CONTROL_NAME}  ${now}` : cycleLabel(MODEL_CONTROL_NAME, now, choiceName(next))
77}
78
79function choiceName(choice: string): string {
80  return choice === AS_STARTED ? 'as started' : choice
81}
82
83/** The efforts the effort control steps through, after as started: the levels `turn.step` names, least first. */
84export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies readonly Effort[]
85
86// `Effort` is written out in types/index.d.ts, which `claude plugin
87// validate` wants import-free, so it mirrors the engine's ModelEffort
88// rather than naming it: this fails to typecheck once the two drift.
89const EFFORT_MIRRORS_ENGINE: [Effort, ModelEffort] extends [ModelEffort, Effort] ? true : false = true
90void EFFORT_MIRRORS_ENGINE
91
92/** What the effort control steps through. */
93const EFFORT_CYCLE = [AS_STARTED, ...EFFORTS]
94
95function isEffort(value: unknown): value is Effort {
96  return EFFORTS.includes(value as Effort)
97}
98
99/**
100 * The models a request of this session has named, by id, and whether it
101 * carried an effort (`seenModels` in $.state).
102 */
103export type SeenModels = Readonly<Record<string, boolean>>
104
105/**
106 * Where an agent's pick finds a model's full id: the model the agent started
107 * on (`own`), the session's main model (`main`, `$.session.model()`), and the
108 * models the other agents started on, as agent.spawn's results named them.
109 * Never a request's model alone, which may be a fallback's.
110 */
111export type ModelSources = { own?: string; main?: string; others: readonly string[] }
112
113/** An agent's ModelSources, from the agents known and the session's main model. */
114export function modelSources(known: readonly Agent[], agentId: string, main: string | undefined): ModelSources {
115  return {
116    own: known.find(agent => agent.id === agentId)?.model,
117    main,
118    others: known.flatMap(agent => (agent.id === agentId || agent.model === undefined ? [] : [agent.model])),
119  }
120}
121
122/**
123 * The model alias a full model id is of: the one a whole segment of it names
124 * (`claude-opus-5-5[1m]` is opus), else the one it holds anywhere.
125 */
126export function familyOf(modelId: string): Model | undefined {
127  const name = modelId.toLowerCase()
128  const segments = name.split(/[^a-z0-9]+/)
129  return MODELS.find(model => segments.includes(model)) ?? MODELS.find(model => name.includes(model))
130}
131
132/**
133 * The full id an agent's pick of each model alias is sent as: the model the
134 * agent started on, when it's that alias, else the session's main model,
135 * else the latest other agent's.
136 */
137export function modelIds({ own, main, others }: ModelSources): Partial<Record<Model, string>> {
138  const ids: Partial<Record<Model, string>> = {}
139  // Least preferred first, so a preferred one takes its alias over
140  for (const id of [...others, ...(main === undefined ? [] : [main]), ...(own === undefined ? [] : [own])]) {
141    const family = familyOf(id)
142    if (family !== undefined) ids[family] = id
143  }
144  return ids
145}
146
147/**
148 * Whether Claude Code's `availableModels` setting lets an agent run on the
149 * model `family` by the full id `id`: always when it isn't set, else when it
150 * names the alias or the id.
151 */
152function isAllowed(availableModels: unknown, family: Model, id: string): boolean {
153  if (!Array.isArray(availableModels)) return true
154  const named = availableModels.filter((entry): entry is string => typeof entry === 'string').map(entry => entry.trim().toLowerCase())
155  return named.includes(family) || named.includes(id.toLowerCase())
156}
157
158/**
159 * The models the model control offers: each one with a full id from its
160 * sources, that `availableModels` allows, but the one the agent started on,
161 * which is as started.
162 */
163export function offeredModels(sources: ModelSources, availableModels: unknown): Model[] {
164  const ids = modelIds(sources)
165  const startedOn = sources.own === undefined ? undefined : familyOf(sources.own)
166  return MODELS.filter(family => {
167    const id = ids[family]
168    return family !== startedOn && id !== undefined && isAllowed(availableModels, family, id)
169  })
170}
171
172/**
173 * The effort control's step for an agent: what it's on (as started, or the
174 * effort picked) and what its next press picks, and whether the model its
175 * next run uses takes an effort (`isTaken`), without which the control is
176 * n/a.
177 */
178export type EffortStep = { isTaken: boolean; on: string; next: string }
179
180/** What the effort control's step is worked out from: `$.state` values and the agent's ModelSources. */
181export type EffortState = {
182  nextRuns: Readonly<Record<string, NextRun>>
183  seenModels: SeenModels
184  sources: ModelSources
185}
186
187/** An agent's effort step: the label and the press both resolve it so. */
188export function effortStep(agentId: string, { nextRuns, seenModels, sources }: EffortState): EffortStep {
189  const picked = nextRuns[agentId]
190  const on = picked?.effort ?? AS_STARTED
191  return { isTaken: takesEffort(picked?.model, seenModels, sources), on, next: nextOf(EFFORT_CYCLE, on) ?? AS_STARTED }
192}
193
194/** The effort control's label while the model its next run uses takes an effort: the effort it's on, then what the next press picks. */
195export function effortControlLabel({ on, next }: EffortStep): string {
196  return cycleLabel(EFFORT_CONTROL_NAME, choiceName(on), choiceName(next))
197}
198
199/**
200 * Whether the model an agent's next run uses takes an effort, as far as the
201 * session's requests tell: the engine leaves a request's effort out for a
202 * model that takes none. A model picked that no request has named yet
203 * counts as taking none. One the agent started on that no request has named
204 * yet counts as taking one; its own requests still decide (`withRunChoice`).
205 */
206function takesEffort(picked: Model | undefined, seen: SeenModels, sources: ModelSources): boolean {
207  const { own } = sources
208  if (picked !== undefined) {
209    const id = modelIds(sources)[picked]
210    return id !== undefined && seen[id] === true
211  }
212  return own === undefined || seen[own] !== false
213}
214
215/**
216 * A request of a run as its RunChoice has it: on the model picked, carrying
217 * the effort picked (else the request's own) only while that model takes
218 * one; on the model it started on, the effort picked only where the request
219 * carries one.
220 */
221function withRunChoice(e: TurnStepInput, run: RunChoice | undefined, seen: SeenModels): TurnStepInput {
222  if (run?.model !== undefined) {
223    const { effort: own, ...request } = e
224    const effort = run.effort ?? own
225    return seen[run.model] === true && effort !== undefined ? { ...request, model: run.model, effort } : { ...request, model: run.model }
226  }
227  if (run?.effort !== undefined && e.effort !== undefined) return { ...e, effort: run.effort }
228  return e
229}
230
231/**
232 * The step each agent's controls last drew, so a press does what its label
233 * said: kept here (a drawing never writes $.state), and used only while the
234 * agent is still on what the label was drawn from.
235 */
236const drawnModelSteps = new Map<string, ModelStep>()
237const drawnEffortSteps = new Map<string, EffortStep>()
238
239/** Notes the step an agent's model control was drawn with. */
240export function noteModelStep(agentId: string, step: ModelStep): void {
241  drawnModelSteps.set(agentId, step)
242}
243
244/** Notes the step an agent's effort control was drawn with. */
245export function noteEffortStep(agentId: string, step: EffortStep): void {
246  drawnEffortSteps.set(agentId, step)
247}
248
249// The engine reads each $.state reference off the file that uses it, so
250// every file declares its own atom for the values it reads or writes.
251const nextRuns = atom({ plugin: 'squishys', key: 'nextRuns' } as const, {})
252const runChoices = atom({ plugin: 'squishys', key: 'runChoices' } as const, {})
253const seenModels = atom({ plugin: 'squishys', key: 'seenModels' } as const, {})
254const agents = atom({ plugin: 'squishys', key: 'agents' } as const, [])
255
256export function registerModelSwitch(on: On): void {
257  // Every request (the matcher fits them all, the orchestrator's included)
258  // notes its model id and whether it carried an effort, written only as
259  // something new is learned. An agent's request goes as its run's choice
260  // has it, settled at the run's first request.
261  on('turn.step', { model: /./ }, async function* ($, e, next) {
262    const carries = e.effort !== undefined
263    const before = await read($, seenModels)
264    // Every read of one dispatch reads one moment, so later code here uses this, not a read
265    const seen = before[e.model] === carries ? before : { ...before, [e.model]: carries }
266    if (seen !== before) await update($, seenModels, all => ({ ...all, [e.model]: carries }))
267    const { agentId } = e
268    if (agentId === undefined) return yield* next(e)
269    return yield* next(withRunChoice(e, await runChoiceOf($, agentId, e.turnId), seen))
270  })
271
272  // A press of the focus view's model control (m) picks what its label
273  // offered for the agent's next run: the next model offered, after the
274  // last back to the one it started on. A model the allowlist stopped
275  // naming since is refused, saying why. Its own onPress does nothing.
276  on('ui.press', { plugin: 'squishys', element: /^model-switch-/ }, async ($, e, next) => {
277    const agentId = e.element.slice(MODEL_SWITCH_PREFIX.length)
278    let offered: Model[]
279    try {
280      offered = offeredModels(await sourcesOf($, agentId), (await $.settings.read()).availableModels)
281    } catch {
282      $.ui.toast('Squishys: nothing picked. Claude Code’s settings can’t be read, so the model allowlist can’t be checked.')
283      return next(e)
284    }
285    const step = modelStep(offered, (await read($, nextRuns))[agentId]?.model)
286    const drawn = drawnModelSteps.get(agentId)
287    const picked = drawn?.on === step.on ? drawn.next : step.next
288    if (picked === step.on) $.ui.toast(`Squishys: ${NO_OTHER_MODEL.reason}`)
289    else if (picked === AS_STARTED) await pickFor($, agentId, { model: undefined })
290    else if (isModel(picked)) {
291      if (offered.includes(picked)) await pickFor($, agentId, { model: picked })
292      else $.ui.toast(`Squishys: ${picked} not picked. Your availableModels setting doesn’t name it.`)
293    }
294    return next(e)
295  })
296
297  // A press of the focus view's effort control (e) picks the next effort
298  // for the agent's next run, after max back to the one it started on,
299  // while the model that run uses takes one. Its own onPress does nothing.
300  on('ui.press', { plugin: 'squishys', element: /^effort-switch-/ }, async ($, e, next) => {
301    const agentId = e.element.slice(EFFORT_SWITCH_PREFIX.length)
302    const current = effortStep(agentId, {
303      nextRuns: await read($, nextRuns),
304      seenModels: await read($, seenModels),
305      sources: await sourcesOf($, agentId),
306    })
307    const drawn = drawnEffortSteps.get(agentId)
308    const step = drawn?.on === current.on && drawn.isTaken === current.isTaken ? drawn : current
309    if (!step.isTaken) $.ui.toast(`Squishys: ${NO_EFFORT_TAKEN.reason}`)
310    else if (step.next === AS_STARTED) await pickFor($, agentId, { effort: undefined })
311    else if (isEffort(step.next)) await pickFor($, agentId, { effort: step.next })
312    return next(e)
313  })
314
315  // Picks last one session: /clear, /resume and a branch end them all.
316  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
317    await update($, nextRuns, () => ({}))
318    await update($, runChoices, () => ({}))
319    await update($, seenModels, () => ({}))
320    return next(e)
321  })
322}
323
324/**
325 * What the agent's run `turnId` uses: settled at its first request from the
326 * agent's pick and kept for the run, so a pick made meanwhile waits for the
327 * next run. A model the allowlist no longer names is dropped from the pick,
328 * saying why, and the run goes on the model it started on.
329 */
330async function runChoiceOf($: EngineInterface, agentId: string, turnId: string): Promise<RunChoice> {
331  const settled = (await read($, runChoices))[agentId]
332  if (settled?.turnId === turnId) return settled
333  const picked = (await read($, nextRuns))[agentId]
334  let model: string | undefined
335  if (picked?.model !== undefined) {
336    const id = modelIds(await sourcesOf($, agentId))[picked.model]
337    const refusal = await refusalOf($, picked.model, id)
338    if (refusal === undefined) model = id
339    else {
340      await pickFor($, agentId, { model: undefined })
341      $.ui.toast(`Squishys: this run is on the model it started on, not ${picked.model}. ${refusal}`)
342    }
343  }
344  const run: RunChoice = { turnId, ...(model !== undefined ? { model } : {}), ...(picked?.effort !== undefined ? { effort: picked.effort } : {}) }
345  await update($, runChoices, all => ({ ...all, [agentId]: run }))
346  return run
347}
348
349/** Why an agent's run can't be on `family` (by the full id `id`), or nothing when it can. */
350async function refusalOf($: EngineInterface, family: Model, id: string | undefined): Promise<string | undefined> {
351  if (id === undefined) return `This session no longer runs anything on ${family}.`
352  try {
353    if (!isAllowed((await $.settings.read()).availableModels, family, id)) return `Your availableModels setting doesn’t name ${family}.`
354  } catch {
355    return 'Claude Code’s settings can’t be read, so the model allowlist can’t be checked.'
356  }
357  return undefined
358}
359
360/** An agent's ModelSources: the agents known and the session's main model, when it can be read. */
361async function sourcesOf($: EngineInterface, agentId: string): Promise<ModelSources> {
362  let main: string | undefined
363  try {
364    main = await $.session.model()
365  } catch {}
366  return modelSources(await read($, agents), agentId, main)
367}
368
369/** Changes an agent's pick: a field set to undefined goes back to as started, and a pick left empty goes. */
370async function pickFor($: EngineInterface, agentId: string, change: { model?: Model | undefined; effort?: Effort | undefined }): Promise<void> {
371  await update($, nextRuns, all => {
372    const { [agentId]: was, ...rest } = all
373    const merged = { ...was, ...change }
374    const kept: NextRun = {
375      ...(merged.model !== undefined ? { model: merged.model } : {}),
376      ...(merged.effort !== undefined ? { effort: merged.effort } : {}),
377    }
378    return Object.keys(kept).length === 0 ? rest : { ...rest, [agentId]: kept }
379  })
380}
381
src/pane.tsx 611 lines
1// The pane: one Squishys panel beside the main view. It shows one mode at a
2// time (see PaneMode); this file draws the roster, as many slots as fit
3// with the overflow as "+N" (see slots.ts), and animates the squishys every
4// mode, and the band (see band.tsx), shows.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, On, PaneOpenArgs, Timer } from 'claude-code'
8
9import type { Agent, Squishy } from '../types'
10import { compose } from './composer'
11import type { Size } from './composer'
12import { HELD_PREFIX, heldButton } from './held'
13import type { Hold } from './held'
14import { pickKeys } from './keys'
15import { KIT } from './kit'
16import { isSparkling } from './moments'
17import { PARTNER_BUTTON, PARTNER_KEY, PARTNER_PICTURE, partnerFrom } from './partner'
18import { halfBlocks } from './raster'
19import type { RasterCells } from './raster'
20import { SETTINGS_KEY, settingsFrom } from './settings'
21import {
22  FOOTER_BUTTON_GAP,
23  FOOTER_COLUMN_GAP,
24  FOOTER_COLUMNS,
25  FOOTER_ROW_COLUMNS,
26  FOOTER_ROW_GAP,
27  OVERFLOW_HOTKEY,
28  SETTINGS_BUTTON,
29  SQUISHYDEX_BUTTON,
30  SLOT_COLUMN_GAP,
31  SLOT_COLUMNS,
32  SLOT_ROWS,
33  SLOT_ROW_GAP,
34  SLOT_SHAPES,
35  buttonColumns,
36  layoutRoster,
37  linedUp,
38  slotLabel,
39} from './slots'
40import { moves } from './states'
41
42export const PANE_ID = 'squishys'
43
44/**
45 * What a Button that picks a squishy is keyed: this, then its agent's id.
46 * src/focus.tsx answers a press on any pane Button keyed so (the pick).
47 */
48export const PICK_PREFIX = 'squishy-'
49
50/**
51 * How a squishy's slot lights while the pointer is anywhere over it (the
52 * roster's, the band's and a met Squishydex place's keyed Box), so a
53 * squishy feels clickable; its Name stays the press. A background alone,
54 * which takes no cells, so nothing moves, in the theme's selection color:
55 * the one theme key that stands out from both the docked pane's background
56 * and the terminal's on every theme (AGENTS.md says how that was checked).
57 * Only fullscreen rendering has the pointer; on the main screen nothing
58 * changes.
59 */
60export const SLOT_HOVER = { backgroundColor: 'selectionBg' } as const
61
62/**
63 * How the pane is opened unasked, at the first spawn, with no `focus`, so it
64 * never asks for the keyboard while the user may be typing. Inline, rows
65 * are scarce: it asks for one row of slots.
66 */
67export const OPEN_PANE = { id: PANE_ID, title: 'Squishys', rows: SLOT_ROWS } as const
68
69/**
70 * How the pane is opened when the user asks for it (/squishys, /squishydex,
71 * a pick from the band): with `focus` too, so its hotkeys can work at once.
72 *
73 * What makes an open asked is the person's input behind it, not `focus`:
74 * the types say "An open answering the person's input (a command or prompt
75 * they entered, a press) is placed at any width". And `focus` is "A request,
76 * not a grant: the surface focuses (and raises) the pane only while the
77 * prompt has the keys over an empty composer. An element of the band or a
78 * pane the person holds, text in the composer, a dialog or a survey each
79 * refuse it: the pane opens without the keyboard." So a pick from the band,
80 * whose press holds the keys, likely opens it without them; the focus view
81 * and the starter pick say how to give it the keyboard while it lacks it.
82 */
83export const OPEN_PANE_ASKED = { ...OPEN_PANE, focus: true } as const
84
85/** Why the overflow count is held while the overflow is empty: as wide as a count, which the footer budgets. */
86const NO_OVERFLOW: Hold = { reason: 'every agent has a slot, so the overflow is empty.' }
87
88/** The keys the roster's footer takes, which no squishy's pick does. */
89const ROSTER_KEYS = [OVERFLOW_HOTKEY, SETTINGS_BUTTON.hotkey, SQUISHYDEX_BUTTON.hotkey]
90
91/**
92 * Whether the user asked for the pane since it last opened unasked. While
93 * they haven't, /squishys opens an open pane again (asking for the keyboard)
94 * rather than closing it. Kept here, not in $.state: a reload only makes the
95 * next /squishys open it again first.
96 */
97let askedFor = false
98
99/**
100 * Notes an open that went through, by the args it was opened with: only the
101 * opens the user asks for are OPEN_PANE_ASKED, the ones with `focus`.
102 */
103export function notePaneOpened(opened: PaneOpenArgs): void {
104  askedFor = opened.focus === true
105}
106
107/** The toast for an open the user asked for that a `ui.open` hook refused. */
108export function openRefused(error: unknown): string {
109  return `Squishys couldn't open its pane: ${error instanceof Error ? error.message : String(error)}`
110}
111
112/** How long each animation frame shows, in milliseconds. */
113export const FRAME_MS = 200
114
115// The engine reads each $.state reference off the file that uses it, so
116// every file declares its own atom for the values it reads or writes.
117const agents = atom({ plugin: 'squishys', key: 'agents' } as const, [])
118const mode = atom({ plugin: 'squishys', key: 'mode' } as const, 'roster')
119const reducedMotion = atom({ plugin: 'squishys', key: 'reducedMotion' } as const, false)
120const overflowOpen = atom({ plugin: 'squishys', key: 'overflowOpen' } as const, false)
121
122/**
123 * The agents the roster's slots showed when it was last drawn, by id in
124 * slot order: where each keeps its slot on the next drawing. Undefined
125 * until the roster is first drawn. A reload only lays the slots out afresh.
126 */
127let slotted: string[] | undefined
128
129/**
130 * Whether the overflow emptied while its list was asked for: the list
131 * stays shut then, even once the overflow fills again, until it's asked
132 * for anew. Kept here, since a drawing can't write $.state.
133 */
134let listOutlived = false
135
136/**
137 * The agents whose squishys are on screen, or will be again when the
138 * roster comes back: those in the roster's slots as last drawn and any
139 * other the pane last drew (the focus view's). Undefined before the
140 * roster is first drawn.
141 */
142export function squishysOnScreen(): readonly string[] | undefined {
143  if (slotted === undefined) return undefined
144  return [...new Set([...slotted, ...[...shown.values()].map(picture => picture.agentId)])]
145}
146
147// The animator. Its frames repaint the pane's pictures with `$.ui.blit`,
148// never a redraw, so they're kept here rather than in $.state: a reload
149// only restarts the animation.
150
151/** The frame every animated squishy is on: the timer's ticks so far. */
152let frame = 0
153/** The animator's timer, while it runs. */
154let animator: Timer | undefined
155/** Whether a frame's repaints are still going out. */
156let painting = false
157/**
158 * The pictures the animator may repaint, by site and Raster key (`shownKey`),
159 * so the pane's minis and the band's never stand for each other: whose
160 * squishy each shows, at what size, in which site (the pane or the band, by
161 * requestId), under which Raster key, and the cells it shows now. Each
162 * drawing of a site fills it again for that site, through `animatedPicture`.
163 */
164const shown = new Map<string, { agentId: string; size: Size; requestId: string; key: string; cells: string }>()
165
166/** Where `shown` keeps a picture: its site, then its Raster key. */
167function shownKey(requestId: string, key: string): string {
168  return `${requestId} ${key}`
169}
170
171/** The key of the Raster showing an agent's squishy at this size. */
172export function pictureKey(agentId: string, size: Size = 'full'): string {
173  return size === 'full' ? `picture-${agentId}` : `${size}-picture-${agentId}`
174}
175
176/**
177 * An agent's squishy in its state's pose at the animation's current frame,
178 * for the Raster keyed `pictureKey(agent.id, size)` in the site `requestId`
179 * (the pane unless said), which the animator then keeps repainting while
180 * the squishy moves. Every mode, and the band, draws its squishys through this.
181 */
182export function animatedPicture(agent: Agent, size: Size = 'full', requestId: string = PANE_ID): RasterCells {
183  const picture = pictureOf(agent, size)
184  const key = pictureKey(agent.id, size)
185  shown.set(shownKey(requestId, key), { agentId: agent.id, size, requestId, key, cells: picture.cells })
186  return picture
187}
188
189/**
190 * The agents whose squishys sparkle now (src/moments.ts), as last worked
191 * out: as a site is drawn, and at each frame. None under Reduce motion.
192 */
193let sparkling: ReadonlySet<string> = new Set()
194
195/**
196 * Works out which squishys sparkle now. The clock is read only while some
197 * agent has had a sparkle; one that can't be read stops every sparkle.
198 */
199async function noteSparkles($: EngineInterface, known: readonly Agent[], motionReduced: boolean): Promise<void> {
200  if (motionReduced || !known.some(agent => agent.sparkleUntil !== undefined)) {
201    sparkling = new Set()
202    return
203  }
204  try {
205    const now = await $.clock.now()
206    sparkling = new Set(known.filter(agent => isSparkling(agent.sparkleUntil, now)).map(agent => agent.id))
207  } catch {
208    sparkling = new Set()
209  }
210}
211
212/** Forgets the pictures a site showed, as it's drawn again. */
213function forgetShown(requestId: string): void {
214  for (const [at, picture] of shown) if (picture.requestId === requestId) shown.delete(at)
215}
216
217/** One slot of the roster as drawn: the partner's or an agent's. */
218type Slot = {
219  /** The agent's id, or `partner`. */
220  id: string
221  /** The key of the Button that picks it. */
222  pickKey: string
223  /** Its picture at the layout's slot size, and the Raster's key; none for a label slot. */
224  picture: { key: string; cells: RasterCells } | undefined
225  name: string
226  description: string
227  /** Whether the main view shows the agent (or, for the partner, the orchestrator). */
228  inView: boolean
229}
230
231export function registerPane(on: On): void {
232  on('session.start', async ($, e, next) => {
233    await readMotionSetting($)
234    // immediate: the orchestrator is usually mid-turn while its agents run,
235    // which is exactly when the user wants the pane.
236    await $.command.register({ name: 'squishys', description: 'Open or close the squishys pane', immediate: true })
237    // and so is /squishydex, which src/squishydex.tsx answers
238    await $.command.register({ name: 'squishydex', description: 'Open the Squishydex: every squishy you have met', immediate: true })
239    // A hot reload keeps $.state but drops the animator and forgets which
240    // squishys sparkle; drawing the roster again works that out from the
241    // agents' `sparkleUntil` and starts it.
242    if ((await read($, agents)).some(agent => moves(agent.state) || agent.sparkleUntil !== undefined)) $.ui.invalidate('ui.render')
243    // and starts with the overflow list shut
244    await closeOverflowList($)
245    return next(e)
246  })
247
248  // The user can turn Reduce motion on or off in /config at any time.
249  on('config.set', async ($, e, next) => {
250    const changed = await next(e)
251    await readMotionSetting($)
252    return changed
253  })
254
255  // A toggle. Claude Code's own list of open panes is the truth, since the
256  // user can also close the pane themselves (ctrl+x x). An unplaced pane
257  // (opened unasked on a narrow terminal) is opened: asked for, it's placed
258  // at any width, and the band (src/band.tsx) is drawn again to step aside.
259  // A placed pane closes once the user has asked for it since it last opened
260  // unasked; one that only opened unasked is opened again, asking for the
261  // keyboard. Whether it has the keyboard says nothing here: the command is
262  // typed at the prompt, which holds the keys while it's typed.
263  on('command.run', { command: 'squishys' }, async $ => {
264    const panes = await $.ui.panes()
265    if (askedFor && panes.some(pane => pane.id === PANE_ID && pane.isPlaced)) await $.ui.close({ id: PANE_ID })
266    else {
267      try {
268        await $.ui.open(OPEN_PANE_ASKED)
269        notePaneOpened(OPEN_PANE_ASKED)
270      } catch (error) {
271        $.ui.toast(openRefused(error))
272      }
273      $.ui.invalidate('ui.render')
274    }
275    return {}
276  })
277
278  // Any press in the pane but the overflow count's own, or a held control's
279  // (src/held.tsx), which does nothing but say why, shuts the overflow list: a
280  // pick from it (src/focus.tsx answers that same press, nested inside this
281  // hook), Settings, or anything else. It picks nothing itself.
282  on('ui.press', { plugin: 'squishys' }, async ($, e, next) => {
283    if (e.element !== 'overflow' && !e.element.startsWith(HELD_PREFIX)) await closeOverflowList($)
284    return next(e)
285  })
286
287  // The band (src/band.tsx) draws its mini squishys inside this hook, which
288  // animates them as the pane's own.
289  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
290    if (e.surface !== 'terminal') return next(e)
291    const motionReduced = await read($, reducedMotion)
292    forgetShown(e.requestId)
293    await noteSparkles($, await read($, agents), motionReduced)
294    const drawing = await next(e)
295    if (motionReduced) stopAnimating()
296    else await animateShown($)
297    return drawing
298  })
299
300  on('ui.render', { component: 'Pane', requestId: 'squishys' }, async ($, e, next) => {
301    // v1 draws only in the terminal; elsewhere Claude Code draws its own.
302    if (e.surface !== 'terminal') return next(e)
303    // Under Reduce motion every squishy is drawn at rest
304    const motionReduced = await read($, reducedMotion)
305    if (motionReduced) stopAnimating()
306    forgetShown(PANE_ID)
307    await noteSparkles($, await read($, agents), motionReduced)
308    // Each pane mode has its own hook, which draws only in its own mode; the
309    // animator moves whichever squishys it drew.
310    if ((await read($, mode)) !== 'roster') {
311      const drawing = await next(e)
312      if (!motionReduced) await animateShown($)
313      return drawing
314    }
315    const { Box, Button, Raster, Text } = $.ui.resolve(e)
316    const { placement, bodyColumns, scroll, view } = e.props
317    const known = await read($, agents)
318    const partner = await readPartner($)
319    const layout = layoutRoster({
320      placement,
321      bodyColumns,
322      bodyRows: scroll.bodyRows,
323      slotCap: await readSlotCap($),
324      agents: known,
325      slotted,
326      ...(partner !== undefined ? { partner } : {}),
327    })
328    slotted = layout.slots.map(agent => agent.id)
329    // The list shows while asked for, until the overflow empties
330    if (layout.overflow.length === 0) listOutlived ||= await read($, overflowOpen)
331    const showsList = layout.overflow.length > 0 && !listOutlived && (await read($, overflowOpen))
332    // Drawn at the animation's current frame, so a redraw doesn't jump
333    // back. Only the slots' pictures are drawn, so only they animate. The
334    // partner, pinned first, stands for the orchestrator: its picture is
335    // still, its pick (src/focus.tsx leaves it be) returns to the roster,
336    // and it's marked while the main view shows the orchestrator. A short
337    // inline pane draws mini pictures, or none (layout.slotSize).
338    const shape = SLOT_SHAPES[layout.slotSize]
339    const { picture: pictureSize } = shape
340    const slots: Slot[] = showsList
341      ? []
342      : [
343          ...(partner !== undefined && layout.partnerSlot
344            ? [
345                {
346                  id: 'partner',
347                  pickKey: PARTNER_BUTTON,
348                  picture: pictureSize === undefined ? undefined : { key: PARTNER_PICTURE, cells: stillPictureAt(partner, pictureSize) },
349                  name: partner.name,
350                  description: 'Orchestrator',
351                  inView: view.agentId === undefined,
352                },
353              ]
354            : []),
355          ...layout.slots.map(agent => ({
356            id: agent.id,
357            pickKey: `${PICK_PREFIX}${agent.id}`,
358            picture: pictureSize === undefined ? undefined : { key: pictureKey(agent.id, pictureSize), cells: animatedPicture(agent, pictureSize) },
359            name: agent.squishy.name,
360            description: agent.description,
361            inView: agent.id === view.agentId,
362          })),
363        ]
364    if (!motionReduced) await animateShown($)
365
366    const columns = Math.max(1, layout.columns)
367    const rows = Array.from({ length: Math.ceil(slots.length / columns) }, (_, row) => slots.slice(row * columns, (row + 1) * columns))
368    const noAgents = <Text dimColor>No agents yet. Each agent the orchestrator starts gets a squishy here.</Text>
369    // Digits, then the letters the footer leaves free, pick the squishys shown
370    const pickHotkeys = pickKeys(showsList ? layout.overflow.length : slots.length, ROSTER_KEYS)
371    const body = showsList ? (
372      // The overflow list: one line per agent, in place of the slots. Its
373      // presses pick the squishy, as a slot's do: src/focus.tsx answers them.
374      <Box key="overflow-list" flexDirection="column">
375        {layout.overflow.map((agent, index) => (
376          <Box key={`overflow-${agent.id}`} flexDirection="row" columnGap={1}>
377            <Button
378              key={`${PICK_PREFIX}${agent.id}`}
379              {...(pickHotkeys[index] === undefined ? {} : { hotkey: pickHotkeys[index] })}
380              plain
381              label={agent.squishy.name}
382              onPress={() => {}}
383            />
384            <Text dimColor wrap="truncate-end">
385              {agent.description}
386            </Text>
387          </Box>
388        ))}
389      </Box>
390    ) : known.length === 0 && slots.length === 0 ? (
391      noAgents
392    ) : (
393      <Box key="slots" flexDirection="column" rowGap={SLOT_ROW_GAP}>
394        {rows.map((row, rowIndex) => (
395          <Box key={`slot-row-${rowIndex}`} flexDirection="row" columnGap={SLOT_COLUMN_GAP}>
396            {row.map((slot, column) => {
397              const index = rowIndex * columns + column
398              const hotkey = pickHotkeys[index]
399              const lines = [
400                // An agent's press picks its squishy: src/focus.tsx answers it. The partner's leaves the roster be.
401                <Button key={slot.pickKey} {...(hotkey === undefined ? {} : { hotkey })} plain label={slotLabel(slot.name, hotkey)} onPress={() => {}} />,
402                ...(shape.lines === 'all'
403                  ? [
404                      <Text key={`description-${slot.id}`} dimColor wrap="truncate-end">
405                        {slot.description}
406                      </Text>,
407                      // Squishys only reads the main view, never changes it (ADR 0001).
408                      slot.inView ? (
409                        <Box key={`in-view-${slot.id}`}>
410                          <Text color="cyan">▲ in main view</Text>
411                        </Box>
412                      ) : null,
413                    ]
414                  : []),
415              ]
416              // The picture over the slot's lines, or beside them in a column of their own, or none (SLOT_SHAPES)
417              return (
418                <Box
419                  key={`slot-${slot.id}`}
420                  flexDirection={shape.direction}
421                  alignItems={shape.alignItems}
422                  columnGap={shape.gap}
423                  width={shape.columns}
424                  hover={SLOT_HOVER}
425                >
426                  {slot.picture === undefined ? null : <Raster key={slot.picture.key} {...slot.picture.cells} />}
427                  {shape.direction === 'column' ? (
428                    lines
429                  ) : (
430                    <Box key={`lines-${slot.id}`} flexDirection="column" width={SLOT_COLUMNS}>
431                      {lines}
432                    </Box>
433                  )}
434                </Box>
435              )
436            })}
437          </Box>
438        ))}
439        {known.length === 0 ? noAgents : null}
440      </Box>
441    )
442    const overflowLabel = `+${layout.overflow.length}`
443    const footer = [
444      {
445        columns: buttonColumns(overflowLabel, OVERFLOW_HOTKEY),
446        // With no overflow, the count is held, so m never reaches the prompt (src/held.tsx answers its press)
447        button:
448          layout.overflow.length > 0 ? (
449            <Button key="overflow" hotkey={OVERFLOW_HOTKEY} plain label={overflowLabel} onPress={() => void showOverflowList($, !showsList)} />
450          ) : (
451            heldButton(Button, 'overflow', OVERFLOW_HOTKEY, overflowLabel, NO_OVERFLOW)
452          ),
453      },
454      {
455        columns: buttonColumns(SETTINGS_BUTTON.label, SETTINGS_BUTTON.hotkey),
456        button: <Button key="settings" {...SETTINGS_BUTTON} plain dimColor onPress={() => void update($, mode, () => 'settings')} />,
457      },
458      {
459        columns: buttonColumns(SQUISHYDEX_BUTTON.label, SQUISHYDEX_BUTTON.hotkey),
460        // src/squishydex.tsx answers its press
461        button: <Button key="squishydex" {...SQUISHYDEX_BUTTON} plain dimColor onPress={() => {}} />,
462      },
463    ]
464    // As many buttons to a row as fit `columns` (slots.ts budgets the rows)
465    const linedFooter = (columns: number) => (
466      <Box key="footer" flexDirection="column" width={columns}>
467        {linedUp(
468          footer.map(each => each.columns),
469          columns,
470          FOOTER_BUTTON_GAP,
471        ).map((line, index) => (
472          <Box key={`footer-row-${index}`} flexDirection="row" columnGap={FOOTER_BUTTON_GAP}>
473            {line.map(at => footer[at]?.button)}
474          </Box>
475        ))}
476      </Box>
477    )
478    // Docked, the footer goes under the slots, so a narrow pane's takes more
479    // rows; inline, where rows are scarce, beside them: a button to a row,
480    // or in a pane too short for that, a row of them (layout.footer)
481    return placement === 'inline' ? (
482      <Box flexDirection="row" columnGap={FOOTER_COLUMN_GAP}>
483        <Box flexDirection="column" flexGrow={1}>
484          {body}
485        </Box>
486        {layout.footer === 'column' ? (
487          <Box key="footer" flexDirection="column" width={FOOTER_COLUMNS}>
488            {footer.map(each => each.button)}
489          </Box>
490        ) : (
491          linedFooter(FOOTER_ROW_COLUMNS)
492        )}
493      </Box>
494    ) : (
495      <Box flexDirection="column" rowGap={FOOTER_ROW_GAP}>
496        {body}
497        {linedFooter(bodyColumns)}
498      </Box>
499    )
500  })
501}
502
503/** Asks for the overflow list, or shuts it. */
504async function showOverflowList($: EngineInterface, show: boolean): Promise<void> {
505  const outlived = listOutlived
506  listOutlived = false
507  if ((await read($, overflowOpen)) !== show) await update($, overflowOpen, () => show)
508  // A list asked for again while still marked open writes nothing to redraw
509  else if (outlived && show) $.ui.invalidate('ui.render')
510}
511
512async function closeOverflowList($: EngineInterface): Promise<void> {
513  await showOverflowList($, false)
514}
515
516/** The slot cap from settings; a store that can't be read leaves the most. */
517async function readSlotCap($: EngineInterface): Promise<number> {
518  try {
519    return settingsFrom(await $.store.get(SETTINGS_KEY)).slotCap
520  } catch {
521    return settingsFrom(undefined).slotCap
522  }
523}
524
525/** The partner from the store; a store that can't be read leaves none. */
526async function readPartner($: EngineInterface): Promise<Squishy | undefined> {
527  try {
528    return partnerFrom(await $.store.get(PARTNER_KEY))
529  } catch {
530    return undefined
531  }
532}
533
534/** The partner's squishy at rest at this size (src/partner.tsx's `stillPicture` at full size). */
535function stillPictureAt(squishy: Squishy, size: Size): RasterCells {
536  return halfBlocks(compose(KIT, squishy, { state: 'working', frame: 0, size }))
537}
538
539/** An agent's squishy in its state's pose, at the animation's current frame, sparkling while it does. */
540function pictureOf(agent: Agent, size: Size): RasterCells {
541  return halfBlocks(compose(KIT, agent.squishy, { state: agent.state, frame, size, sparkle: sparkling.has(agent.id) }))
542}
543
544/** Keeps `reducedMotion` in step with Claude Code's `prefersReducedMotion` setting. */
545async function readMotionSetting($: EngineInterface): Promise<void> {
546  let reduced: boolean
547  try {
548    reduced = (await $.settings.read()).prefersReducedMotion === true
549  } catch {
550    return // settings that can't be read leave things as they were
551  }
552  if ((await read($, reducedMotion)) !== reduced) await update($, reducedMotion, () => reduced)
553}
554
555/** Starts the animator if a squishy just drawn is Working, Thinking or sparkling. */
556async function animateShown($: EngineInterface): Promise<void> {
557  if (movingPictures(await read($, agents), sparkling).length > 0) animator ??= $.clock.every(FRAME_MS, () => void nextFrame($))
558}
559
560/**
561 * The shown pictures whose squishy is Working, Thinking or one of
562 * `sparklers`, each with its agent.
563 */
564function movingPictures(known: readonly Agent[], sparklers: ReadonlySet<string>) {
565  return [...shown].flatMap(([at, each]) => {
566    const agent = known.find(({ id }) => id === each.agentId)
567    return agent !== undefined && moves(agent.state, sparklers.has(agent.id)) ? [{ at, agent, ...each }] : []
568  })
569}
570
571function stopAnimating(): void {
572  animator?.cancel()
573  animator = undefined
574  frame = 0
575}
576
577/**
578 * Moves the animation on a frame, blitting each shown squishy whose
579 * picture changed. A tick that comes while the last frame's repaints are
580 * still going out is skipped. A squishy whose repaint is refused is left
581 * alone until the pane is drawn again. A squishy whose sparkle just ended
582 * is repainted once more, at rest. The animation stops once no shown
583 * squishy is Working, Thinking or sparkling, or Reduce motion is on; the
584 * next drawing of the pane starts it again.
585 */
586async function nextFrame($: EngineInterface): Promise<void> {
587  if (painting) return
588  painting = true
589  try {
590    const known = await read($, agents)
591    if (await read($, reducedMotion)) return stopAnimating()
592    const sparkledBefore = sparkling
593    await noteSparkles($, known, false)
594    const moving = movingPictures(known, new Set([...sparkledBefore, ...sparkling]))
595    if (moving.length === 0) return stopAnimating()
596    frame += 1
597    for (const { at, key, agent, size, requestId, cells } of moving) {
598      const picture = pictureOf(agent, size)
599      if (cells === picture.cells) continue
600      let refused = true
601      try {
602        refused = (await $.ui.blit({ requestId, key, ...picture })).deny !== undefined
603      } catch {}
604      if (refused) shown.delete(at)
605      else shown.set(at, { agentId: agent.id, size, requestId, key, cells: picture.cells })
606    }
607  } finally {
608    painting = false
609  }
610}
611
src/partner.tsx 162 lines
1// The partner: the user's own squishy, which stands for the orchestrator.
2// The user picks it from the kit's three starters the first time the pane
3// opens (the pane's starter mode, drawn here), and it's kept in the store,
4// so it stays the same from session to session. The roster pins it in its
5// first slot (src/pane.tsx).
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, On } from 'claude-code'
9
10import type { Squishy } from '../types'
11import { stillPixels } from './composer'
12import { KIT } from './kit'
13import { halfBlocks } from './raster'
14import type { RasterCells } from './raster'
15import { speciesSquishy } from './roller'
16import type { AssembledSquishy } from './roller'
17import { SLOT_COLUMN_GAP, SLOT_COLUMNS, SLOT_ROW_GAP, slotLabel, slotsThatFit } from './slots'
18import { recordMet } from './squishydex-record'
19
20/**
21 * Where the store keeps the partner: its species (`body`, `face`), so it can
22 * later become any species the user has met (the Squishydex), and the
23 * palette it was picked in, so a change to the kit's palettes doesn't
24 * recolor or rename it.
25 */
26export const PARTNER_KEY = 'partner'
27
28/** What the pane's Button that picks the partner is keyed. */
29export const PARTNER_BUTTON = 'partner'
30
31/**
32 * What the starter pick says while the pane lacks the keyboard, when its
33 * digits would go to the prompt instead. On the main screen (Claude Code's
34 * classic rendering, not fullscreen) a click never reaches the pane, so it
35 * names only the keys.
36 */
37export function starterHint(isFullscreen: boolean): string {
38  const keys = `Ctrl+X Tab then 1–${KIT.starters.length}`
39  return isFullscreen ? `Click a squishy, or press ${keys}` : `Press ${keys}`
40}
41
42/** What the Raster showing the partner's still picture is keyed. */
43export const PARTNER_PICTURE = 'partner-picture'
44
45// The engine reads each $.state reference off the file that uses it, so
46// every file declares its own atom for the values it reads or writes.
47const mode = atom({ plugin: 'squishys', key: 'mode' } as const, 'roster')
48
49/**
50 * The partner a stored value names, in its palette (the kit's first once the
51 * kit no longer has it); undefined for none, or a species the kit can no
52 * longer make.
53 */
54export function partnerFrom(stored: unknown): Squishy | undefined {
55  if (typeof stored !== 'object' || stored === null) return undefined
56  const { body, face, palette } = stored as Record<string, unknown>
57  if (typeof body !== 'string' || typeof face !== 'string') return undefined
58  return speciesSquishy(KIT, { body, face }, typeof palette === 'string' ? palette : undefined)
59}
60
61/**
62 * The partner's picture, or a starter's: still, since the partner is no
63 * agent and never animates.
64 */
65export function stillPicture(squishy: Squishy): RasterCells {
66  return halfBlocks(stillPixels(KIT, squishy))
67}
68
69export function registerPartner(on: On): void {
70  // A new user picks their partner first: the pane opens on the starters.
71  // The pane's own session.start hook is the unmatched one, so this one
72  // matches every session.
73  on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
74    const partner = await pickWithoutPartner($)
75    const started = await next(e)
76    // The partner counts as met: a partner saved before the Squishydex kept
77    // one is recorded now (nothing is written once it is)
78    if (partner !== undefined) await recordMet({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), now: () => $.clock.now() }, [partner])
79    return started
80  })
81
82  // /clear, /resume and a branch put the pane back on the roster; compaction
83  // keeps $.state.
84  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
85    await pickWithoutPartner($)
86    return next(e)
87  })
88
89  // The starter mode of the pane: the three starters and nothing else, until
90  // one is picked. The pane's id is spelled out, since the engine reads a
91  // matcher off this file alone.
92  on('ui.render', { component: 'Pane', requestId: 'squishys' }, async ($, e, next) => {
93    if (e.surface !== 'terminal' || (await read($, mode)) !== 'starter') return next(e)
94    const { Box, Button, Raster, Text } = $.ui.resolve(e)
95    const starters = KIT.starters.flatMap(species => speciesSquishy(KIT, species) ?? [])
96    // As many across as fit the pane, so a narrow one wraps them
97    const across = Math.max(1, slotsThatFit(e.props.bodyColumns))
98    const rows = Array.from({ length: Math.ceil(starters.length / across) }, (_, row) => starters.slice(row * across, (row + 1) * across))
99    return (
100      <Box flexDirection="column">
101        <Text bold>Pick your partner, the squishy that stands for the orchestrator.</Text>
102        {/* The hint takes the blank row under the title, so the pick stays as tall as the inline pane asks for */}
103        <Text dimColor wrap="truncate-end">
104          {e.props.isFocused ? ' ' : starterHint(e.viewport?.isFullscreen === true)}
105        </Text>
106        <Box key="starters" flexDirection="column" rowGap={SLOT_ROW_GAP}>
107          {rows.map((row, rowIndex) => (
108            <Box key={`starter-row-${rowIndex}`} flexDirection="row" columnGap={SLOT_COLUMN_GAP}>
109              {row.map((squishy, column) => {
110                const number = rowIndex * across + column + 1
111                return (
112                  <Box key={`starter-slot-${number}`} flexDirection="column" alignItems="center" width={SLOT_COLUMNS}>
113                    <Raster key={`starter-picture-${number}`} {...stillPicture(squishy)} />
114                    <Button
115                      key={`starter-${number}`}
116                      hotkey={String(number)}
117                      plain
118                      label={slotLabel(squishy.name, String(number))}
119                      onPress={() => void choosePartner($, squishy)}
120                    />
121                  </Box>
122                )
123              })}
124            </Box>
125          ))}
126        </Box>
127      </Box>
128    )
129  })
130}
131
132/**
133 * Puts the pane on the starters when no partner is saved; a store that
134 * can't be read leaves it be. Returns the saved partner.
135 */
136async function pickWithoutPartner($: EngineInterface): Promise<Squishy | undefined> {
137  let stored: unknown
138  try {
139    stored = await $.store.get(PARTNER_KEY)
140  } catch {
141    return undefined // a pick that couldn't be saved would only come back next time
142  }
143  const partner = partnerFrom(stored)
144  if (partner === undefined) await update($, mode, () => 'starter')
145  return partner
146}
147
148/**
149 * Saves a starter as the partner (its species and the palette it shows),
150 * shows the roster, and records it in the Squishydex. A store that can't be
151 * written still lets the user on: they pick again next time.
152 */
153async function choosePartner($: EngineInterface, starter: AssembledSquishy): Promise<void> {
154  const { body, face, palette } = starter
155  try {
156    await $.store.set(PARTNER_KEY, { body, face, palette })
157  } catch {}
158  await update($, mode, () => 'roster')
159  // The partner counts as met
160  await recordMet({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), now: () => $.clock.now() }, [starter])
161}
162
src/rebuild.ts 326 lines
1// Rebuilding the roster when the session changes underneath it. /clear,
2// /resume and a branch reset every $.state value, and session.start doesn't
3// fire again; classic.SessionStart does. The roster is rebuilt from Claude
4// Code's agent list, each agent's state read off its status, and each
5// agent the mod gave a squishy before gets that squishy back: squishys are
6// also kept in the store, by agent id. /resume also brings back the resumed
7// session's agents the list has dropped: the store keeps which agents each
8// session started.
9
10import { atom, read, update } from 'claude-code'
11import type { AgentInfo, EngineInterface, On } from 'claude-code'
12
13import type { Agent, Squishy, SquishyState } from '../types'
14import { KIT } from './kit'
15import { squishysOnScreen } from './pane'
16import { PARTNER_KEY, partnerFrom } from './partner'
17import { isMoment } from './moments'
18import { cryptoRandom, forcedOdds, roll, squishyOf } from './roller'
19import type { Odds } from './roller'
20import { liveSquishys } from './slots'
21import { recordMet } from './squishydex-record'
22import type { StoreCalls } from './squishydex-record'
23import { endedState, stateOfStatus } from './states'
24
25// The engine reads each $.state reference off the file that uses it, so
26// every file declares its own atom for the values it reads or writes.
27const agents = atom({ plugin: 'squishys', key: 'agents' } as const, [])
28
29/**
30 * Where the store keeps each agent's squishy (Remembered), the agent seen
31 * longest ago first. Only the squishy's key, never a picture.
32 */
33export const REMEMBERED_KEY = 'agentSquishys'
34
35/**
36 * The most agents the store keeps a squishy for, and the most it keeps
37 * under their sessions (SESSIONS_KEY). Every session shares the store and
38 * the agent list names only its own session's agents, so entries are
39 * dropped by age rather than for missing from one list. At a few dozen
40 * bytes each (a description at most REMEMBERED_DESCRIPTION characters),
41 * this stays far inside the store's 4 MiB.
42 */
43export const REMEMBERED_AGENTS = 1000
44
45/** The most characters of an agent's description the store keeps. */
46export const REMEMBERED_DESCRIPTION = 100
47
48/** One agent's squishy, as the store keeps it. */
49export type RememberedPair = [agentId: string, squishyKey: string]
50export type Remembered = RememberedPair[]
51
52/** The pairs a stored value holds, leaving out anything else. */
53export function rememberedFrom(stored: unknown): Remembered {
54  if (!Array.isArray(stored)) return []
55  return stored.filter(
56    (pair): pair is RememberedPair => Array.isArray(pair) && typeof pair[0] === 'string' && typeof pair[1] === 'string',
57  )
58}
59
60/**
61 * The pairs with these agents' squishys as the ones seen last, and the
62 * agents seen longest ago dropped past REMEMBERED_AGENTS.
63 */
64export function withRemembered(remembered: Remembered, seen: readonly Pick<Agent, 'id' | 'squishy'>[]): Remembered {
65  const seenIds = new Set(seen.map(agent => agent.id))
66  const older = remembered.filter(([agentId]) => !seenIds.has(agentId))
67  const latest = seen.map((agent): RememberedPair => [agent.id, agent.squishy.key])
68  return [...older, ...latest].slice(-REMEMBERED_AGENTS)
69}
70
71/**
72 * Where the store keeps which agents each session started (Sessions), so
73 * /resume of a session brings its agents back after Claude Code's agent
74 * list has dropped them (#63). Their squishys stay under REMEMBERED_KEY.
75 */
76export const SESSIONS_KEY = 'sessionAgents'
77
78/** One agent a session started, by id, with its description. */
79export type SessionAgent = [agentId: string, description: string]
80/** A session (`$.session.id()`) and the agents it started, in the order seen. */
81export type SessionEntry = [sessionId: string, agents: SessionAgent[]]
82/** The sessions the store keeps, the one that started an agent longest ago first. */
83export type Sessions = SessionEntry[]
84
85/** The sessions a stored value holds, leaving out anything else. */
86export function sessionsFrom(stored: unknown): Sessions {
87  if (!Array.isArray(stored)) return []
88  const isAgent = (agent: unknown): agent is SessionAgent => Array.isArray(agent) && typeof agent[0] === 'string' && typeof agent[1] === 'string'
89  return stored.flatMap((entry): Sessions => {
90    if (!Array.isArray(entry) || typeof entry[0] !== 'string' || !Array.isArray(entry[1])) return []
91    return [[entry[0], entry[1].filter(isAgent)]]
92  })
93}
94
95/**
96 * The sessions with these agents kept as this session's, which becomes the
97 * one seen last. The sessions seen longest ago are dropped while they keep
98 * more than REMEMBERED_AGENTS agents in all, never the one seen last.
99 */
100export function withSessionAgents(sessions: Sessions, sessionId: string, seen: readonly Pick<Agent, 'id' | 'description'>[]): Sessions {
101  const seenIds = new Set(seen.map(agent => agent.id))
102  const kept = sessions.find(([id]) => id === sessionId)?.[1] ?? []
103  const agentsNow: SessionAgent[] = [
104    ...kept.filter(([agentId]) => !seenIds.has(agentId)),
105    ...seen.map((agent): SessionAgent => [agent.id, agent.description.slice(0, REMEMBERED_DESCRIPTION)]),
106  ]
107  const latest: SessionEntry = [sessionId, agentsNow.slice(-REMEMBERED_AGENTS)]
108  const older = sessions.filter(([id]) => id !== sessionId)
109  let total = latest[1].length + older.reduce((sum, [, each]) => sum + each.length, 0)
110  while (total > REMEMBERED_AGENTS && older.length > 0) total -= older.shift()?.[1].length ?? 0
111  return [...older, latest]
112}
113
114/**
115 * The agents the store keeps as a session's, in the order they were seen,
116 * each with its squishy. One whose squishy the store dropped, or the kit no
117 * longer has, is left out.
118 */
119export function agentsOfSession(sessions: Sessions, remembered: Remembered, sessionId: string): Pick<Agent, 'id' | 'squishy' | 'description'>[] {
120  const keyOf = new Map(remembered)
121  const started = sessions.find(([id]) => id === sessionId)?.[1] ?? []
122  return started.flatMap(([id, description]) => {
123    const squishy = squishyOf(KIT, keyOf.get(id) ?? '')
124    return squishy === undefined ? [] : [{ id, squishy, description }]
125  })
126}
127
128/**
129 * An agent just given a freshly rolled squishy, and whether
130 * SQUISHYS_FORCE_ROLL forced it to come up shiny or legendary: such a roll
131 * still toasts, sparkles and chimes, but the Squishydex never records it.
132 * A roll forced plain counts as any other, since it reaches nothing the
133 * standard odds don't (and it's what keeps the mod's tests from stray shinies).
134 */
135export type Rolled = { agent: Agent; forced: boolean }
136
137/** Whether a roll made with these odds was forced to come up shiny or legendary. */
138export function forcedMoment(odds: Partial<Odds> | undefined, squishy: Squishy): boolean {
139  return odds !== undefined && isMoment(squishy)
140}
141
142/**
143 * The agents the last rebuild gave fresh rolls, for the agent tracker's
144 * classic.SessionStart hook (outside this one) to announce a shiny or
145 * legendary among them, as it does a spawn's: `$` stays in each hook's file,
146 * so they're handed over as plain data. Restored squishys are never here.
147 */
148let freshRolls: Rolled[] = []
149
150/** The agents the last rebuild gave fresh rolls, handed over once. */
151export function takeFreshRolls(): readonly Rolled[] {
152  const taken = freshRolls
153  freshRolls = []
154  return taken
155}
156
157/** The last write to REMEMBERED_KEY and SESSIONS_KEY this process has queued. */
158let remembering: Promise<void> = Promise.resolve()
159
160/**
161 * The one way squishys, and the sessions agents started in, are written to
162 * the store: each write waits for the one before, then reads the values
163 * again, merges these agents in and writes, so writes from this process
164 * (parallel spawns, a spawn during a rebuild) never lose each other's. With
165 * a session, the agents are kept as that session's too (withSessionAgents).
166 * The hook passes its own store calls in (see StoreCalls), as `$` stays in
167 * the hook's file. Another session writing between one read and write can
168 * still lose an entry; that's rare, and costs only a squishy coming back
169 * after /clear or /resume. A store that can't be written loses only that too.
170 */
171export function rememberSquishys({ get, set }: StoreCalls, seen: readonly Pick<Agent, 'id' | 'squishy' | 'description'>[], sessionId?: string): Promise<void> {
172  remembering = remembering.then(async () => {
173    // Each value on its own, so one that fails costs only itself
174    try {
175      await set(REMEMBERED_KEY, withRemembered(rememberedFrom(await get(REMEMBERED_KEY)), seen))
176    } catch {}
177    if (sessionId === undefined) return
178    try {
179      await set(SESSIONS_KEY, withSessionAgents(sessionsFrom(await get(SESSIONS_KEY)), sessionId, seen))
180    } catch {}
181  })
182  return remembering
183}
184
185/**
186 * Whether the roster of a session shows an agent the agent list names, which
187 * names the agents of every session in the process: one the store keeps as
188 * that session's does, one it keeps as only other sessions' doesn't, and one
189 * it keeps under no session only while it runs.
190 */
191export function isOfSession(sessions: Sessions, sessionId: string, { id, status }: { id: string; status: string }): boolean {
192  const keptUnder = sessions.filter(([, started]) => started.some(([agentId]) => agentId === id)).map(([session]) => session)
193  if (keptUnder.length === 0) return endedState(status) === undefined
194  return keptUnder.includes(sessionId)
195}
196
197/**
198 * The session the latest classic.SessionStart (clear, resume, fork) named,
199 * and what `$.session.id()` answered then. For a while after a resume,
200 * `$.session.id()` still answers the session being left (seen for #63).
201 */
202let sessionStarted: { id: string; answered: string | undefined } | undefined
203
204/**
205 * The session an agent the tracker gives a squishy now started in, from what
206 * `$.session.id()` answers (undefined when it failed): the session the latest
207 * session start named while `$.session.id()` still answers what it did then.
208 */
209export function sessionOfSpawn(answered: string | undefined): string | undefined {
210  if (sessionStarted !== undefined && (answered === undefined || answered === sessionStarted.answered)) return sessionStarted.id
211  return answered
212}
213
214export function registerRebuild(on: On): void {
215  // Compaction keeps $.state, so its rebuild only adds agents the roster lacks.
216  // /resume also brings back the resumed session's agents the list no longer
217  // names. It names that session by the event's session_id: at this point
218  // `$.session.id()` still answers the session being left (seen for #63).
219  // /clear goes on under a new session id, so it starts fresh. Both show
220  // only the agents the list names that are their session's (isOfSession).
221  on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
222    if (e.source !== 'compact') {
223      let answered: string | undefined
224      try {
225        answered = await $.session.id()
226      } catch {}
227      sessionStarted = { id: e.session_id, answered }
228    }
229    const ofSession = e.source === 'clear' || e.source === 'resume' ? e.session_id : undefined
230    const added = await rebuild($, ofSession, e.source === 'resume')
231    const started = await next(e)
232    // Met: the Squishydex records the rebuilt squishys once the event has
233    // gone on, all but forced rolls. It keeps a squishy's first-met date,
234    // so a restored one changes nothing.
235    const met = added.filter(({ forced }) => !forced).map(({ agent }) => agent.squishy)
236    await recordMet({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), now: () => $.clock.now() }, met)
237    return started
238  })
239}
240
241/**
242 * Adds every agent the agent list names that the roster lacks, in its
243 * status's state; given a session (/clear's or /resume's), only those of
244 * that session (isOfSession), and for /resume the session's own (below). An agent the store kept a squishy for gets it back,
245 * even one another agent shows (an agent's identity wins); any other gets
246 * a fresh roll that repeats no live squishy (see liveSquishys), no
247 * restored one and not the partner's.
248 * A store that can't be read means fresh rolls, never no roster.
249 * SQUISHYS_FORCE_ROLL forces the fresh rolls, as it does the tracker's.
250 *
251 * Given the session /resume brings back, it also adds every agent the store
252 * keeps as that session's (agentsOfSession), first, in the order they were
253 * seen: Claude Code's list drops an agent within a minute of its end, so one
254 * the list doesn't name has ended, and its squishy is Asleep. One the list
255 * names takes its state from the list.
256 *
257 * Returns the agents it added, each with whether its roll was forced (a
258 * restored squishy's never was), and keeps the fresh rolls for the
259 * tracker to announce (takeFreshRolls).
260 */
261async function rebuild($: EngineInterface, sessionId: string | undefined, restoring: boolean): Promise<Rolled[]> {
262  freshRolls = []
263  const resumedSession = restoring ? sessionId : undefined
264  let listed: AgentInfo[] = []
265  try {
266    listed = await $.agent.list()
267  } catch {
268    if (resumedSession === undefined) return [] // nothing to rebuild from
269  }
270  if (listed.length === 0 && resumedSession === undefined) return []
271  let remembered: Remembered = []
272  let sessions: Sessions = []
273  let partner: Squishy | undefined
274  try {
275    remembered = rememberedFrom(await $.store.get(REMEMBERED_KEY))
276    if (sessionId !== undefined) sessions = sessionsFrom(await $.store.get(SESSIONS_KEY))
277    partner = partnerFrom(await $.store.get(PARTNER_KEY))
278  } catch {}
279  // The list names other sessions' agents too
280  if (sessionId !== undefined) listed = listed.filter(info => isOfSession(sessions, sessionId, info))
281  let odds: ReturnType<typeof forcedOdds>
282  try {
283    odds = forcedOdds(await $.env.get('SQUISHYS_FORCE_ROLL'))
284  } catch {}
285  // The resumed session's agents, then any other the list names
286  const listedById = new Map(listed.map(info => [info.id, info]))
287  const members = resumedSession === undefined ? [] : agentsOfSession(sessions, remembered, resumedSession)
288  const memberIds = new Set(members.map(member => member.id))
289  const wanted: { id: string; description: string; state: SquishyState }[] = [
290    ...members.map(({ id, description }) => {
291      const info = listedById.get(id)
292      return { id, description: info?.description ?? description, state: info === undefined ? ('asleep' as const) : stateOfStatus(info.status) }
293    }),
294    ...listed.filter(info => !memberIds.has(info.id)).map(info => ({ id: info.id, description: info.description, state: stateOfStatus(info.status) })),
295  ]
296  const keyOf = new Map(remembered)
297  const restored = new Map<string, Squishy>()
298  for (const { id } of wanted) {
299    const squishy = squishyOf(KIT, keyOf.get(id) ?? '')
300    if (squishy !== undefined) restored.set(id, squishy)
301  }
302  let added: Agent[] = []
303  const fresh = new Set<string>()
304  // Against the roster as it is now, which a spawn may have joined meanwhile
305  await update($, agents, current => {
306    const missing = wanted.filter(each => !current.some(agent => agent.id === each.id))
307    // Restored squishys all count, so no fresh roll repeats one
308    const live: Squishy[] = [...liveSquishys(current, squishysOnScreen(), partner), ...missing.flatMap(each => restored.get(each.id) ?? [])]
309    added = missing.map(({ id, description, state }): Agent => {
310      let squishy = restored.get(id)
311      if (squishy === undefined) {
312        squishy = roll(KIT, { live, rng: cryptoRandom, ...(odds !== undefined ? { odds } : {}) })
313        live.push(squishy)
314        fresh.add(id)
315      }
316      return { id, description, squishy, state }
317    })
318    return added.length === 0 ? current : [...current, ...added]
319  })
320  if (added.length === 0) return []
321  await rememberSquishys({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) }, added)
322  const rolled = added.map(agent => ({ agent, forced: fresh.has(agent.id) && forcedMoment(odds, agent.squishy) }))
323  freshRolls = rolled.filter(({ agent }) => fresh.has(agent.id))
324  return rolled
325}
326
src/settings.tsx 211 lines
1// Settings: the user's lasting options, kept in the mod's store so they
2// carry over from session to session, and the pane's settings mode that
3// changes them.
4
5import { atom, read, update } from 'claude-code'
6import type { AgentSpawnInput, EngineInterface, On } from 'claude-code'
7
8import { heldButton } from './held'
9import type { Hold } from './held'
10import { cycleLabel, nextOf } from './keys'
11
12/** The Agent tool's model aliases a default can name. */
13export const MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
14export type Model = (typeof MODELS)[number]
15
16/** The most roster slots the cap allows: one per digit hotkey. */
17export const MAX_SLOTS = 9
18
19export type Settings = {
20  /** The model every new agent starts on; absent lets Claude choose. */
21  model?: Model
22  /** The most slots the roster shows. */
23  slotCap: number
24  /**
25   * A chime plays when a shiny or legendary squishy is rolled. Off when
26   * absent. Offered only where `$.audio` makes a sound (macOS).
27   */
28  chime?: true
29}
30
31/** Where the settings live in the mod's store. */
32export const SETTINGS_KEY = 'settings'
33
34/** The model default control's choice of no default. */
35const LET_CLAUDE_CHOOSE = 'let-claude-choose'
36
37// The engine reads each $.state reference off the file that uses it, so
38// every file declares its own atom for the values it reads or writes.
39const mode = atom({ plugin: 'squishys', key: 'mode' } as const, 'roster')
40
41/** The settings a stored value holds, with defaults for anything missing or no longer valid. */
42export function settingsFrom(stored: unknown): Settings {
43  const { model, slotCap, chime } = (typeof stored === 'object' && stored !== null ? stored : {}) as Record<string, unknown>
44  return {
45    ...(isModel(model) ? { model } : {}),
46    slotCap: isSlotCap(slotCap) ? slotCap : MAX_SLOTS,
47    ...(chime === true ? { chime } : {}),
48  }
49}
50
51/**
52 * The spawn as it should start: on the default model when one is set.
53 * A fork always inherits the model of the agent or orchestrator it forked
54 * from, so it's left alone.
55 */
56export function withModelDefault(spawn: AgentSpawnInput, settings: Settings): AgentSpawnInput {
57  if (spawn.fork || settings.model === undefined) return spawn
58  return { ...spawn, model: settings.model }
59}
60
61export function isModel(value: unknown): value is Model {
62  return MODELS.includes(value as Model)
63}
64
65function isSlotCap(value: unknown): value is number {
66  return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_SLOTS
67}
68
69/**
70 * What a model control steps through: `first` (no model of its own), then
71 * each of `allowed` in MODELS order. Every model control (the default for
72 * new agents, an agent's next run) orders models so.
73 */
74export function modelCycle<First extends string>(first: First, allowed: readonly Model[] = MODELS): (First | Model)[] {
75  return [first, ...MODELS.filter(model => allowed.includes(model))]
76}
77
78/** The on-off settings, each `true` or absent. */
79type Toggle = 'chime'
80
81/** Settings with an on-off setting turned the other way. */
82export function toggled(settings: Settings, name: Toggle): Settings {
83  const { [name]: was, ...rest } = settings
84  return was === true ? rest : { ...rest, [name]: true }
85}
86
87export function registerSettings(on: On): void {
88  // The settings mode of the pane. Each mode's hook draws only while the
89  // pane is in that mode and passes the drawing on otherwise. The pane's id
90  // is spelled out, since the engine reads a matcher off this file alone.
91  // Each setting is a control whose hotkey steps it to its next choice
92  // (AGENTS.md, "Keys"). Saves queue (saveSettings), so two presses before
93  // a redraw step a setting twice.
94  on('ui.render', { component: 'Pane', requestId: 'squishys' }, async ($, e, next) => {
95    if (e.surface !== 'terminal' || (await read($, mode)) !== 'settings') return next(e)
96    const { Box, Button, Text } = $.ui.resolve(e)
97    const settings = await readSettings($)
98    const chimes = await chimePlays($)
99    const model = settings.model ?? LET_CLAUDE_CHOOSE
100    const toggle = (name: Toggle, hotkey: string, label: string) => (
101      <Button
102        key={name}
103        hotkey={hotkey}
104        plain
105        label={cycleLabel(label, onOff(settings[name]), onOff(settings[name] !== true))}
106        onPress={() => void saveSettings($, current => toggled(current, name))}
107      />
108    )
109    return (
110      <Box flexDirection="column" rowGap={1}>
111        <Text bold>Settings</Text>
112        <Button
113          key="model"
114          hotkey="m"
115          plain
116          label={cycleLabel(MODEL_DEFAULT_LABEL, modelName(model), modelName(nextOf(MODEL_DEFAULTS, model) ?? LET_CLAUDE_CHOOSE))}
117          onPress={() => void saveSettings($, withNextModel)}
118        />
119        <Button
120          key="slotCap"
121          hotkey="s"
122          plain
123          label={cycleLabel(SLOT_CAP_LABEL, String(settings.slotCap), String(nextSlotCap(settings.slotCap)))}
124          onPress={() => void saveSettings($, current => ({ ...current, slotCap: nextSlotCap(current.slotCap) }))}
125        />
126        {/* Where no chime plays, held: src/held.tsx answers its press, so c never reaches the prompt */}
127        {chimes ? (
128          toggle('chime', 'c', CHIME_LABEL)
129        ) : (
130          heldButton(Button, 'chime', 'c', CHIME_LABEL, NO_CHIME)
131        )}
132        <Button key="back" hotkey="r" plain label="Back to the roster" onPress={() => void update($, mode, () => 'roster')} />
133      </Box>
134    )
135  })
136}
137
138/** What each setting's control is labeled, before what it's on. */
139export const MODEL_DEFAULT_LABEL = 'Model for new agents'
140export const SLOT_CAP_LABEL = 'Roster slots, at most'
141export const CHIME_LABEL = 'Chime on a shiny or legendary'
142
143/** Why the chime setting is held where `$.audio` makes no sound (chimePlays). */
144const NO_CHIME: Hold = { why: 'macOS only', reason: 'no chime plays here: Claude Code plays sounds only on macOS.' }
145
146/** The model default's choices, in the order its control steps through them. */
147const MODEL_DEFAULTS = modelCycle(LET_CLAUDE_CHOOSE)
148
149/** How a model default's choice reads. */
150export function modelName(choice: string): string {
151  return choice === LET_CLAUDE_CHOOSE ? 'Let Claude choose' : choice
152}
153
154/** Settings with the model default stepped to its next choice. */
155function withNextModel({ model, ...rest }: Settings): Settings {
156  const stepped = nextOf(MODEL_DEFAULTS, model ?? LET_CLAUDE_CHOOSE)
157  return isModel(stepped) ? { ...rest, model: stepped } : rest
158}
159
160/** The slot cap after `cap`: one more, and 1 after the most. */
161function nextSlotCap(cap: number): number {
162  return (cap % MAX_SLOTS) + 1
163}
164
165/** How an on-off setting reads. */
166export function onOff(on: boolean | undefined): string {
167  return on === true ? 'On' : 'Off'
168}
169
170/**
171 * Whether `$.audio` makes a sound here, so the chime setting is worth
172 * offering. Nothing on `$` names the platform; `$.audio` plays a clip with
173 * `afplay` on macOS and plays nothing on Linux or Windows, which have no
174 * player, so the chime is offered wherever afplay is. A check that fails
175 * hides it.
176 */
177async function chimePlays($: EngineInterface): Promise<boolean> {
178  try {
179    return await $.fs.exists('/usr/bin/afplay')
180  } catch {
181    return false
182  }
183}
184
185async function readSettings($: EngineInterface): Promise<Settings> {
186  return settingsFrom(await $.store.get(SETTINGS_KEY))
187}
188
189/**
190 * The saves under way, one after another, so each reads what the one
191 * before it wrote: two quick presses step a setting twice. Kept here, never
192 * in $.state.
193 */
194let saving: Promise<void> = Promise.resolve()
195
196/**
197 * Saves an edit to the settings, validated like a stored value is on
198 * reading, after every save already under way. A save that fails leaves
199 * the settings as they were and the queue going.
200 */
201function saveSettings($: EngineInterface, edit: (settings: Settings) => Settings): Promise<void> {
202  saving = saving.then(async () => {
203    try {
204      await $.store.set(SETTINGS_KEY, settingsFrom(edit(await readSettings($))))
205    } catch {}
206    // The store isn't $.state, so nothing redraws the pane on its own.
207    $.ui.invalidate('ui.render')
208  })
209  return saving
210}
211
src/share.tsx 348 lines
1// Share: posts a squishy to X, by the user's own hand. One press of Share
2// (in the focus view of an agent whose squishy is Asleep, or on a met
3// species' Squishydex card) saves the squishy's pixel card (src/card.ts) as
4// a PNG, copies it to the clipboard (or shows where it is where it can't),
5// and only then opens X's compose page with the share text filled in, its
6// last line a reminder to paste the card once it was copied. Nothing is
7// ever posted: the user reviews the text, adds the card and posts it.
8
9import { atom, read } from 'claude-code'
10import type { EngineInterface, On } from 'claude-code'
11
12import type { Squishy } from '../types'
13import { shareCard } from './card'
14import { KIT } from './kit'
15import { encodePng } from './png'
16import { base64Of } from './raster'
17import { SHINY_MARK, squishyOf, withoutShinyMark } from './roller'
18import { SQUISHYDEX_KEY, progressOf, squishydexFrom } from './squishydex-record'
19import { canShare } from './states'
20import { printable, wellFormed } from './text'
21
22// The engine reads each $.state reference off the file that uses it, so
23// every file declares its own atom for the values it reads or writes.
24const agents = atom({ plugin: 'squishys', key: 'agents' } as const, [])
25
26/** The hotkey of Share, wherever it shows. */
27export const SHARE_HOTKEY = 'x'
28
29/** What every Share Button is keyed with, then `agent-<agent id>` or `species-<squishy key>`. */
30const SHARE_PREFIX = 'share-'
31const AGENT_SHARE = `${SHARE_PREFIX}agent-`
32const SPECIES_SHARE = `${SHARE_PREFIX}species-`
33
34/** The key of the Share Button in an agent's focus view. */
35export function agentShareKey(agentId: string): string {
36  return AGENT_SHARE + agentId
37}
38
39/** The key of the Share Button on a species' Squishydex card, for the squishy the card draws. */
40export function speciesShareKey(squishyKey: string): string {
41  return SPECIES_SHARE + squishyKey
42}
43
44/** What the link to the compose page says, where it's offered. */
45export const SHARE_LINK_LABEL = 'Post on X'
46
47/** Where a share's link goes: the mod's own repository. */
48export const REPO_URL = 'https://github.com/Rahat-ch/squishys'
49
50/**
51 * The longest an agent's description runs in the share text, in
52 * characters: it can hold private project details, so only its start goes
53 * in the compose box, for the user to review.
54 */
55const DESCRIPTION_LIMIT = 60
56
57/** How long each command Share runs may take. */
58const RUN_TIMEOUT_MS = 10_000
59
60/**
61 * Saves the card, its PNG arriving as base64 on standard input, named by
62 * `$1`, in a folder of the user's own: `$TMPDIR/squishys-share` on macOS
63 * (a per-user temp dir there), else `${XDG_CACHE_HOME:-$HOME/.cache}/squishys/share`.
64 * It refuses a folder that isn't the user's own or is a symbolic link, so
65 * another user can't plant one to make Share write over a file; makes it
66 * private (700); drops cards older than a day; and writes through mktemp,
67 * then moves the card onto its name, which replaces whatever is there
68 * rather than writing through it.
69 *
70 * It prints the system's name first (`uname -s`), whatever happens next,
71 * then the path written once it is. macOS's base64 decodes with `-D` (old
72 * releases know no `-d`), GNU's and BusyBox's with `-d`.
73 */
74export const SAVE_SCRIPT = [
75  'os=$(uname -s)',
76  'printf "%s\\n" "$os"',
77  'case "$os" in',
78  '  Darwin) base="${TMPDIR:-$HOME/Library/Caches}"; dir="${base%/}/squishys-share"; flag=-D ;;',
79  '  *) dir="${XDG_CACHE_HOME:-$HOME/.cache}/squishys/share"; flag=-d ;;',
80  'esac',
81  'mkdir -p "$dir" || exit 1',
82  'if [ ! -O "$dir" ] || [ -L "$dir" ]; then echo "$dir is not a folder of your own" >&2; exit 1; fi',
83  'chmod 700 "$dir" || exit 1',
84  'find "$dir" -maxdepth 1 -type f -name "*.png" -mtime +0 -exec rm -f {} + 2>/dev/null',
85  'tmp=$(mktemp "$dir/.card.XXXXXX") || exit 1',
86  'if base64 "$flag" > "$tmp" && mv -f "$tmp" "$dir/$1"; then printf "%s\\n" "$dir/$1"; else rm -f "$tmp"; exit 1; fi',
87].join('\n')
88
89/**
90 * Opens `$1` with xdg-open, left running in the background with its output
91 * dropped: a browser it starts would otherwise hold the output open, and
92 * `$.process.run` with it, until the timeout. So it exits 0 whether or not
93 * anything opened.
94 */
95const XDG_OPEN_SCRIPT = 'command -v xdg-open >/dev/null 2>&1 || { echo "xdg-open is not installed" >&2; exit 127; }\nxdg-open "$1" >/dev/null 2>&1 &'
96
97/** Sets the clipboard to the PNG at the path given (macOS). */
98const CLIPBOARD_SCRIPT = ['on run argv', 'set the clipboard to (read (POSIX file (item 1 of argv)) as «class PNGf»)', 'end run']
99
100/**
101 * Sets the clipboard to the PNG at `$1` (Linux): with wl-copy (Wayland),
102 * else xclip (X11), whichever is installed and takes it. Both stay running
103 * in the background to hold the clipboard, so their output is dropped, or
104 * they would hold `$.process.run` open until the timeout. Fails, saying
105 * why, when neither copied it: neither is installed, or each installed one
106 * refused (no display to reach, as over SSH).
107 */
108const LINUX_CLIPBOARD_SCRIPT = [
109  'wl=$(command -v wl-copy 2>/dev/null); xc=$(command -v xclip 2>/dev/null)',
110  'if [ -n "$wl" ] && wl-copy --type image/png < "$1" >/dev/null 2>&1; then exit 0; fi',
111  'if [ -n "$xc" ] && xclip -selection clipboard -t image/png -i "$1" >/dev/null 2>&1; then exit 0; fi',
112  'if [ -n "$wl$xc" ]; then echo "the clipboard refused the card" >&2; exit 1; fi',
113  'echo "neither wl-copy nor xclip is installed" >&2; exit 127',
114].join('\n')
115
116/** The one toast of a share that went through. */
117export const COPIED_NOTE = 'Card copied: paste it into your post'
118
119/** The system Share is on, by what `uname -s` says. */
120type Platform = 'macos' | 'linux' | 'other'
121
122function platformOf(uname: string): Platform {
123  if (uname === 'Darwin') return 'macos'
124  if (uname === 'Linux') return 'linux'
125  return 'other'
126}
127
128/**
129 * The compose URL of each Share whose browser may not have opened, by its
130 * Button's key, which the views draw as a link beside it. Kept here, not in
131 * $.state, so a reload leaves no stale link.
132 */
133const unopened = new Map<string, string>()
134
135/** The compose page to offer as a link after a Share that may not have opened it; none once one surely did. */
136export function unopenedShare(buttonKey: string): string | undefined {
137  return unopened.get(buttonKey)
138}
139
140/** Whether a share is under way, so a second press while it runs does nothing. */
141let sharing = false
142
143/** What a share sends: the squishy, and the text for the compose box. */
144type ShareContent = { squishy: Squishy; text: string }
145
146/** A squishy's Name as the share text gives it: crowned for a legendary, with one SHINY_MARK for a shiny. */
147function sharedName(squishy: Squishy): string {
148  const name = squishy.shiny ? SHINY_MARK + withoutShinyMark(squishy.name) : squishy.name
149  return squishy.kind === 'legendary' ? `👑 ${name}` : name
150}
151
152/** An agent's description as the share text gives it: printable, on one line, cut short past DESCRIPTION_LIMIT. */
153function descriptionLine(description: string): string {
154  const line = [...printable(description).replace(/\s+/g, ' ').trim()]
155  return line.length > DESCRIPTION_LIMIT ? `${line.slice(0, DESCRIPTION_LIMIT - 1).join('').trimEnd()}…` : line.join('')
156}
157
158/** The share text for an agent that finished. */
159function finishedText(squishy: Squishy, description: string): string {
160  const line = descriptionLine(description)
161  return `My squishy ${sharedName(squishy)} just finished${line === '' ? '' : `: ${line}`} 🥟`
162}
163
164/** The share text for a species met, with the count of species met. */
165function metText(squishy: Squishy, met: number, total: number): string {
166  return `I met ${sharedName(squishy)} in squishys 🥟 · ${met}/${total} species`
167}
168
169/**
170 * The share text's last line, after a blank one, once the card is on the
171 * clipboard: how to paste it, by the platform's `pasteKey`. With the
172 * longest Name and description, the text, this and the link (23 as X counts
173 * it) stay within a post's 280.
174 */
175export function pasteReminder(pasteKey: string): string {
176  return `\n\n(${pasteKey} to paste your squishy, then delete this line)`
177}
178
179/** X's compose page with the text and the repository's link filled in. Opening it posts nothing. */
180function composeUrl(text: string): string {
181  return `https://x.com/intent/post?text=${encodeURIComponent(wellFormed(text))}&url=${encodeURIComponent(REPO_URL)}`
182}
183
184/** The card's file name: the squishy's key, safe in a path. */
185export function cardFileName(squishy: Squishy): string {
186  return `${squishy.key.replace(/[^a-z0-9]+/gi, '-')}.png`
187}
188
189function reasonOf(error: unknown): string {
190  return error instanceof Error ? error.message : String(error)
191}
192
193export function registerShare(on: On): void {
194  // A press on any Share Button: the Buttons' own onPress is a no-op. Each
195  // step's outcome is a toast, including those before a step that throws;
196  // nothing throws out of the hook.
197  on('ui.press', { plugin: 'squishys', element: /^share-/ }, async ($, e, next) => {
198    const pressed = await next(e)
199    if (sharing) return pressed
200    sharing = true
201    const notes: string[] = []
202    try {
203      const content = await contentOf($, e.element)
204      if (content !== undefined) await share($, e.element, content, notes)
205    } catch (error) {
206      notes.push(`Couldn't finish sharing: ${reasonOf(error)}`)
207    } finally {
208      sharing = false
209      for (const note of notes) $.ui.toast(note)
210    }
211    return pressed
212  })
213}
214
215/** What a Share Button shares: an Asleep agent's squishy, or a species' squishy as its card draws it. */
216async function contentOf($: EngineInterface, element: string): Promise<ShareContent | undefined> {
217  if (element.startsWith(AGENT_SHARE)) {
218    const id = element.slice(AGENT_SHARE.length)
219    const agent = (await read($, agents)).find(each => each.id === id)
220    if (agent === undefined || !canShare(agent)) return undefined
221    return { squishy: agent.squishy, text: finishedText(agent.squishy, agent.description) }
222  }
223  if (element.startsWith(SPECIES_SHARE)) {
224    const squishy = squishyOf(KIT, element.slice(SPECIES_SHARE.length))
225    if (squishy?.kind !== 'assembled') return undefined
226    let progress = progressOf(squishydexFrom(undefined), KIT)
227    try {
228      progress = progressOf(squishydexFrom(await $.store.get(SQUISHYDEX_KEY)), KIT)
229    } catch {}
230    return { squishy, text: metText(squishy, progress.species, progress.speciesTotal) }
231  }
232  return undefined
233}
234
235/** How a command went: whether it exited 0, what it printed, and why not. */
236type CommandOutcome = { ok: boolean; stdout: string; reason: string }
237
238/** Runs a command, never rejecting: one that can't start says why. */
239async function run($: EngineInterface, argv: readonly string[], stdin?: string): Promise<CommandOutcome> {
240  try {
241    const ran = await $.process.run(argv, { timeoutMs: RUN_TIMEOUT_MS, ...(stdin === undefined ? {} : { stdin }) })
242    const reason = ran.stderr.trim().split('\n')[0] ?? ''
243    return { ok: ran.exitCode === 0, stdout: ran.stdout, reason: reason === '' ? `${argv[0] ?? 'it'} exited with ${ran.exitCode}` : reason }
244  } catch (error) {
245    return { ok: false, stdout: '', reason: reasonOf(error) }
246  }
247}
248
249/**
250 * Shares a squishy: saves its card, then hands the card and X's compose
251 * page over as the platform allows. What failed goes in `notes`, with one
252 * note when the card was copied, which the hook toasts. A compose page that
253 * may not have opened is offered as a link.
254 */
255async function share($: EngineInterface, buttonKey: string, { squishy, text }: ShareContent, notes: string[]): Promise<void> {
256  const saved = await run($, ['sh', '-c', SAVE_SCRIPT, 'sh', cardFileName(squishy)], base64Of(encodePng(shareCard(KIT, squishy))))
257  const [uname = '', written = ''] = saved.stdout.split('\n').map(line => line.trim())
258  const path = saved.ok && written !== '' ? written : undefined
259  if (path === undefined) notes.push(`Couldn't save the card: ${saved.reason}`)
260  const { url, offerLink } = await runPlatformCommands($, platformCommands(platformOf(uname)), path, text, notes)
261  if (offerLink) unopened.set(buttonKey, url)
262  else unopened.delete(buttonKey)
263  $.ui.invalidate('ui.render')
264}
265
266/**
267 * How a platform hands a card and a compose page over: the commands that
268 * copy the card, show it where it's saved, and open the page; whether it
269 * learns that the page opened; and the shortcut that pastes.
270 */
271type PlatformCommands = {
272  copy: (path: string) => readonly string[]
273  reveal: (path: string) => readonly string[]
274  open: (url: string) => readonly string[]
275  knowsOpened: boolean
276  pasteKey: string
277}
278
279/** The folder a path is in. */
280function folderOf(path: string): string {
281  return path.slice(0, path.lastIndexOf('/'))
282}
283
284/**
285 * Each platform's commands, the one place that tells platforms apart: none
286 * for a platform where Share knows no clipboard or browser command. On
287 * Linux, a failed copy (no wl-copy or xclip, or the clipboard refused, as
288 * over SSH) shows the card's folder; xdg-open runs in the background, so
289 * whether X opened is never known.
290 */
291function platformCommands(platform: Platform): PlatformCommands | undefined {
292  switch (platform) {
293    case 'macos':
294      return {
295        copy: path => ['osascript', ...CLIPBOARD_SCRIPT.flatMap(line => ['-e', line]), path],
296        reveal: path => ['open', '-R', path],
297        open: url => ['open', url],
298        knowsOpened: true,
299        pasteKey: '⌘V',
300      }
301    case 'linux':
302      return {
303        copy: path => ['sh', '-c', LINUX_CLIPBOARD_SCRIPT, 'sh', path],
304        reveal: path => ['sh', '-c', XDG_OPEN_SCRIPT, 'sh', folderOf(path)],
305        open: url => ['sh', '-c', XDG_OPEN_SCRIPT, 'sh', url],
306        knowsOpened: false,
307        pasteKey: 'Ctrl+V',
308      }
309    case 'other':
310      return undefined
311  }
312}
313
314/**
315 * Hands the saved card (if it was) and the compose page to the user: the
316 * card onto the clipboard, or shown where it is when it can't be, and only
317 * once that's done, the compose page, with the paste reminder only if the
318 * card was copied. Gives the compose page and whether to offer it as a
319 * link: wherever it may not have opened.
320 */
321async function runPlatformCommands(
322  $: EngineInterface,
323  commands: PlatformCommands | undefined,
324  path: string | undefined,
325  text: string,
326  notes: string[],
327): Promise<{ url: string; offerLink: boolean }> {
328  if (commands === undefined) {
329    if (path !== undefined) notes.push(`Card saved to ${path}: attach it to your post.`)
330    notes.push('Use the Post on X link to write your post.')
331    return { url: composeUrl(text), offerLink: true }
332  }
333  let copied = false
334  if (path !== undefined) {
335    const copy = await run($, commands.copy(path))
336    copied = copy.ok
337    if (copied) notes.push(COPIED_NOTE)
338    else {
339      await run($, commands.reveal(path))
340      notes.push(`Couldn't copy the card (${copy.reason}). It's saved at ${path}`)
341    }
342  }
343  const url = composeUrl(copied ? text + pasteReminder(commands.pasteKey) : text)
344  const opened = await run($, commands.open(url))
345  if (!opened.ok) notes.push(`Couldn't open your browser (${opened.reason}): use the Post on X link.`)
346  return { url, offerLink: !opened.ok || !commands.knowsOpened }
347}
348
src/spinner.tsx 35 lines
1// The spinner: squishys in Claude Code's own spinner line. Some turns, the
2// word Claude Code sampled for the turn gives way to a squishy verb
3// (src/verbs.ts); otherwise the spinner is Claude Code's own drawing.
4
5import type { On } from 'claude-code'
6
7import { cryptoRandom } from './roller'
8import { pickVerb } from './verbs'
9
10/**
11 * Each spinner's turn, by spinner id (the engine's `requestId` for the
12 * Spinner): the word Claude Code sampled for the turn, and the squishy verb
13 * it gave way to, if any. The pick holds through every drawing of the turn;
14 * a new word is a new turn. Kept here, since a drawing can't write $.state:
15 * a reload only picks again.
16 */
17const turns = new Map<string, { word: string; verb: string | undefined }>()
18
19/** The squishy verb this spinner's turn shows in place of `word`, picked once per turn; undefined to leave it. */
20function verbFor(spinnerId: string, word: string): string | undefined {
21  const turn = turns.get(spinnerId)
22  if (turn?.word === word) return turn.verb
23  const verb = pickVerb(cryptoRandom)
24  turns.set(spinnerId, { word, verb })
25  return verb
26}
27
28export function registerSpinner(on: On): void {
29  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
30    if (e.surface !== 'terminal') return next(e)
31    const verb = verbFor(e.requestId, e.props.word)
32    return next(verb === undefined ? e : { ...e, props: { ...e.props, word: verb } })
33  })
34}
35