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

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.
i): type a message to an agent. A running agent reads it at its next step, and a finished one resumes with it.m/e): pick the model and effort an agent's next run uses.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
Run claude plugin update squishys@squishys and restart Claude Code, or turn on auto-update for the squishys marketplace (/plugin → Marketplaces → Enable auto-update).
/tui fullscreen): it gives you clicks, hover and, at 110 columns or wider, the pane docked beside the transcript.-p (print) runs or cloud sessions.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.
Every control in the pane has a hotkey, shown before its label (i: Redirect). Hotkeys work while the pane has the keyboard.
| Where | Keys |
|---|---|
| First run | 1–3 pick your starter partner |
| Roster | 1–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 view | i 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 |
| Settings | m model for new agents, s roster slots, c chime, r back |
| Squishydex | 1–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.
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'.
squishys runs inside your Claude Code session, with your permissions. Here is what it reads and what it can do.
It reads:
availableModels), reduced motion, and the models the session runs on.SQUISHYS_FORCE_ROLL environment variable, a development convenience that forces shiny or legendary rolls./usr/bin/afplay exists, to offer the chime only where it can play.It can:
$.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.$.audio), when you turn it on.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.$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.
Want to load the mod from a clone, run the tests or contribute? See CONTRIBUTING.md.
hooks/register.tsx 39 lines1// 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}
39src/agents.ts 448 lines1// 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}
448src/band.tsx 60 lines1// 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
60src/focus.tsx 840 lines1// 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}
840src/held.tsx 66 lines1// 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}
66src/model-switch.ts 381 lines1// 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}
381src/pane.tsx 611 lines1// 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}
611src/partner.tsx 162 lines1// 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}
162src/rebuild.ts 326 lines1// 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}
326src/settings.tsx 211 lines1// 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}
211src/share.tsx 348 lines1// 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}
348src/spinner.tsx 35 lines1// 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