SLOPSHOPPER

crabarium

A pane that shows the main session and its subagents, with an animated pixel character for each agent

newpanecommandprompttimeragents
v0.1.0MITupdated 2026-10-16Nagato-Yuzuru/crabarium
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crabarium
│ ┃ crabarium ✕ › fix the failing auth test and add an audit log call │ ┃ Main session — · context — │ ┃ cost $0.42 · agent tokens — · time — · turn… ● crabarium: crabarium needs Claude Code 2.1.300 or later (this is 2. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · crabarium
Main session — · context — cost $0.42 · agent tokens — · time — · turn $0.00
Source 21 files
hooks/register.tsx 11 lines
1import type { Register } from 'claude-code'
2
3import { registerCollect } from './collect'
4import { motionMode } from './core/character'
5import { registerPane } from './view/pane'
6
7export const register: Register = (on, options) => {
8  registerCollect(on)
9  registerPane(on, motionMode(options.motion))
10}
11
hooks/collect.ts 254 lines
1// Event collection: the only layer that hooks engine events. It turns each
2// event into a domain event and applies it to the state through the core.
3
4import type {
5  AgentInfo,
6  EngineInterface,
7  HookStream,
8  On,
9  SessionUsage,
10  Timer,
11  TurnStepChunk,
12  TurnStepInput,
13  TurnStepResult,
14  TurnUsage,
15} from 'claude-code'
16import { atom, update } from 'claude-code'
17
18import type { AgentEvent } from './core/agents'
19import { initialAgents, reduceAgents } from './core/agents'
20import type { SessionEvent } from './core/session'
21import { cacheTtlMs, initialSession, reduceSession } from './core/session'
22import { initialUsage, reduceUsage } from './core/usage/usage'
23import type { StepFinished, UsageEvent } from './core/usage/usage'
24import { isSupportedEngine, MIN_ENGINE_VERSION } from './core/version'
25import { PANE_ID, SESSION_SHAPE } from './state'
26
27const session = atom({ plugin: 'crabarium', key: 'session' } as const, initialSession, { shape: SESSION_SHAPE })
28const agents = atom({ plugin: 'crabarium', key: 'agents' } as const, initialAgents)
29const usageBoard = atom({ plugin: 'crabarium', key: 'usage' } as const, initialUsage)
30
31const POLL_MS = 1000
32
33// A hot reload drops this module and its timers; session.start runs again
34// then and restarts the poll for a pane that is still open.
35let poll: Timer | null = null
36
37export function usageEvent(usage: SessionUsage, turns: number | null, at: number): SessionEvent {
38  return {
39    kind: 'usage-read',
40    contextPercent: usage.context.percent ?? null,
41    costUsd: usage.cost?.usd ?? null,
42    startedAt: usage.startedAt,
43    rateLimits: usage.rateLimits.map(({ kind, percentUsed }) => ({ kind, percentUsed })),
44    turns,
45    at,
46  }
47}
48
49export function stepEvent(e: Pick<TurnStepInput, 'agentId' | 'effort'>, u: TurnUsage): StepFinished {
50  return {
51    kind: 'step-finished',
52    agentId: e.agentId ?? null,
53    model: u.model,
54    effort: e.effort === undefined ? null : String(e.effort),
55    tokens: {
56      input: u.input_tokens,
57      output: u.output_tokens,
58      cacheRead: u.cache_read_input_tokens,
59      cacheWrite: u.cache_creation_input_tokens,
60    },
61  }
62}
63
64export function listedEvent(list: readonly AgentInfo[], at: number): AgentEvent {
65  return { kind: 'agents-listed', statuses: list.map(({ id, status }) => ({ id, status })), at }
66}
67
68async function dispatch($: EngineInterface, event: SessionEvent): Promise<void> {
69  await update($, session, s => reduceSession(s, event))
70}
71
72async function dispatchUsage($: EngineInterface, event: UsageEvent): Promise<void> {
73  await update($, usageBoard, s => reduceUsage(s, event))
74}
75
76// Runs a write of the mod's own state from a hook on engine work. A failure,
77// in the write or in making its event, is logged and does not fail the work.
78async function observe($: EngineInterface, what: string, write: () => Promise<void>): Promise<void> {
79  await Promise.resolve()
80    .then(write)
81    .catch((error: unknown) => {
82      $.ui.log(`crabarium: ${what} was not recorded: ${error instanceof Error ? error.message : String(error)}`)
83    })
84}
85
86async function dispatchAgent($: EngineInterface, event: AgentEvent): Promise<void> {
87  await update($, agents, s => reduceAgents(s, event))
88}
89
90/** The prompt origins that are the user's own: typed, through Remote Control, or from the SDK host. */
91const USER_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
92
93async function recordUsage($: EngineInterface, e: TurnStepInput, result: TurnStepResult): Promise<void> {
94  // A step that got no response has no usage to count.
95  if (result.usage === null) return
96  const usage = result.usage
97  await observe($, 'the usage of a step', () => dispatchUsage($, stepEvent(e, usage)))
98}
99
100// Passes a step's stream on unchanged. `done` gets the result and the time of
101// the first content chunk (`null` when none came).
102async function* timed(
103  $: EngineInterface,
104  stream: HookStream<TurnStepChunk, TurnStepResult>,
105  done: (result: TurnStepResult, firstAt: number | null) => Promise<void>,
106): AsyncGenerator<TurnStepChunk, TurnStepResult> {
107  let firstAt: number | null = null
108  for await (const chunk of stream) {
109    if (firstAt === null && chunk.kind !== 'engine') firstAt = await $.clock.now().catch(() => null)
110    yield chunk
111  }
112  const result = await stream.result
113  await done(result, firstAt)
114  return result
115}
116
117// One read of everything the pane polls: the usage, the agent list, the time.
118async function refresh($: EngineInterface): Promise<void> {
119  // The turn count is a detail: if the engine does not answer it, the rest still updates.
120  const turns = $.session.turns().catch(() => null)
121  const [usage, list, at] = await Promise.all([$.session.usage(), $.agent.list(), $.clock.now()])
122  await dispatch($, usageEvent(usage, await turns, at))
123  await dispatchAgent($, listedEvent(list, at))
124}
125
126function startPoll($: EngineInterface): void {
127  poll ??= $.clock.every(POLL_MS, () => void refresh($))
128}
129
130function stopPoll(): void {
131  poll?.cancel()
132  poll = null
133}
134
135async function isPaneOpen($: EngineInterface): Promise<boolean> {
136  return (await $.ui.panes()).some(pane => pane.id === PANE_ID)
137}
138
139export function registerCollect(on: On): void {
140  on('session.start', async ($, e, next) => {
141    const { version } = await $.session.version()
142    if (!isSupportedEngine(version)) {
143      $.ui.log(`crabarium needs Claude Code ${MIN_ENGINE_VERSION} or later (this is ${version}); it stays off.`)
144      return next(e)
145    }
146    await $.command.register({ name: 'crabarium', description: 'Show or hide the crabarium pane' })
147    if (await isPaneOpen($)) {
148      await refresh($)
149      startPoll($)
150    }
151    return next(e)
152  })
153
154  on('command.run', { command: 'crabarium' }, async $ => {
155    if (await isPaneOpen($)) {
156      await $.ui.close({ id: PANE_ID })
157      return { text: 'crabarium pane closed.' }
158    }
159    await refresh($)
160    const opened = await $.ui.open({ id: PANE_ID, title: 'crabarium' })
161    startPoll($)
162    return { text: opened.isPlaced ? 'crabarium pane opened.' : `crabarium pane waits: ${opened.reason}` }
163  })
164
165  // An observer: if it fails, the close goes on.
166  on('ui.close', { id: PANE_ID }, (_$, e, next) => {
167    stopPoll()
168    return next(e)
169  }).catch((_$, e, next) => next(e))
170
171  // The model request is the engine's work, not the mod's: a failed write of
172  // the mod's own state only logs, and never fails the request.
173  on('turn.step', async function* ($, e, next) {
174    // A step with an agentId belongs to a subagent, not the main session.
175    if (e.agentId !== undefined) return yield* timed($, next(e), result => recordUsage($, e, result))
176    await observe($, 'the model of a step', () => dispatch($, { kind: 'model-used', model: e.model }))
177    const sentAt = await $.clock.now()
178    return yield* timed($, next(e), async (result, firstAt) => {
179      await recordUsage($, e, result)
180      if (result.usage === null) return
181      const usage = result.usage
182      await observe($, 'the timing of a step', async () => {
183        const end = await $.clock.now()
184        await dispatch($, {
185          kind: 'step-timed',
186          // The output time starts at the first chunk: the wait before it, and
187          // any retry before a chunk came, is not output.
188          step: firstAt === null ? null : { outputTokens: usage.output_tokens, ms: end - firstAt },
189          model: e.model,
190          sentAt,
191          cacheUsed: usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0,
192        })
193      })
194    })
195  })
196
197  // Observers: if one fails, the prompt or the switch goes on. The turn cost
198  // counts from a prompt that the user sent, as the engine's turn count does;
199  // a notification or a schedule that wakes the loop does not start a turn.
200  on('prompt.submit', async ($, e, next) => {
201    if (USER_ORIGINS.has(e.origin.kind)) {
202      await observe($, 'the cost at a prompt', async () =>
203        dispatch($, { kind: 'prompt-sent', costUsd: (await $.session.usage()).cost?.usd ?? null }),
204      )
205    }
206    return next(e)
207  }).catch((_$, e, next) => next(e))
208
209  // The engine tells the cache TTL only here. The mod does not guess it at other times.
210  on('classic.PostModelSwitch', async ($, e, next) => {
211    await observe($, 'the model switch', () =>
212      dispatch($, { kind: 'model-switched', model: e.to_model, cacheTtlMs: cacheTtlMs(e.cache_ttl) }),
213    )
214    return next(e)
215  }).catch((_$, e, next) => next(e))
216
217  // A /clear or a resume ends the conversation without a new session.start;
218  // the token sums start again, as the engine's cost and time do.
219  on('session.end', async ($, e, next) => {
220    if (e.reason === 'clear' || e.reason === 'resume') {
221      await dispatchUsage($, { kind: 'usage-reset' })
222      await dispatch($, { kind: 'conversation-replaced' })
223    }
224    return next(e)
225  }).catch((_$, e, next) => next(e))
226
227  // Observers: if one fails, the spawn or the turn end goes on; `next(e)` in
228  // the fallback gives back what the first `next` call settled to.
229  on('agent.spawn', async ($, e, next) => {
230    const result = await next(e)
231    // A denied spawn starts nothing, and a remote workflow agent has no local loop.
232    if (result.deny !== undefined || result.agentId === undefined) return result
233    await dispatchAgent($, {
234      kind: 'agent-spawned',
235      id: result.agentId,
236      description: e.description,
237      type: e.subagentType,
238      model: result.model,
239      parentId: e.parentAgentId ?? null,
240      isTeammate: e.isTeammate === true,
241      at: await $.clock.now(),
242    })
243    return result
244  }).catch((_$, e, next) => next(e))
245
246  on('turn.complete', async ($, e, next) => {
247    const result = await next(e)
248    if (e.agentId !== undefined) {
249      await dispatchAgent($, { kind: 'agent-turn-ended', id: e.agentId, reason: e.reason, at: await $.clock.now() })
250    }
251    return result
252  }).catch((_$, e, next) => next(e))
253}
254
hooks/core/character.ts 97 lines
1// What each character shows at a time: its state from the agent status, and
2// the frame of each motion. Pure: the time comes in as an argument.
3import type { AgentRow } from './agents'
4import { isEnded } from './agents'
5import { motionOf } from './pack/motions'
6import { partOf } from './pack/pack'
7import type { CharacterState, Motion, Pack, PartFrame, PartSelection, Pose } from './pack/pack'
8
9/** How long a new character is `entering` before it is `working`. */
10export const ENTER_MS = 1500
11
12/** `full` animates; `reduced` shows one still pose for each state; `off` shows the still character. */
13export type MotionMode = 'full' | 'reduced' | 'off'
14
15const MOTION_MODES: readonly unknown[] = ['full', 'reduced', 'off'] satisfies MotionMode[]
16
17/** The `motion` option. The engine gives only the manifest's values, so another value is a bug; it falls back to `full`. */
18export function motionMode(option: unknown): MotionMode {
19  return MOTION_MODES.includes(option) ? (option as MotionMode) : 'full'
20}
21
22const STATE_OF_STATUS = {
23  pending: 'working',
24  running: 'working',
25  waiting: 'idle',
26  idle: 'idle',
27  completed: 'finished',
28  failed: 'failed',
29  killed: 'failed',
30} as const satisfies Record<AgentRow['status'], CharacterState>
31
32/**
33 * The state, and the time its motion starts from. A live agent's motion runs
34 * from the spawn, so it does not jump when `entering` turns into `working`. An
35 * ended agent's runs from the end, which a later correction of how it ended
36 * (failed to killed) does not move. Nothing gives `leaving` yet: rows do not
37 * leave the pane.
38 */
39export function characterState(row: AgentRow, now: number): { state: CharacterState; since: number } {
40  if (!isEnded(row.status) && now - row.startedAt < ENTER_MS) return { state: 'entering', since: row.startedAt }
41  const state = STATE_OF_STATUS[row.status]
42  if (state === 'working') return { state, since: row.startedAt }
43  if (row.endedAt !== null) return { state, since: row.endedAt }
44  // `$.state` outlives a hot reload, so a row from a build before `statusAt` has none.
45  return { state, since: Number.isFinite(row.statusAt) ? row.statusAt : row.startedAt }
46}
47
48/** A frame index, and the elapsed time of the next frame change; `null` when no later time changes it. */
49export type FrameAt = { index: number; nextAt: number | null }
50
51/** The frame of a motion `elapsedMs` after it started. A loop wraps; a one-shot motion holds its last frame. */
52export function frameAt(motion: Motion<unknown>, elapsedMs: number, mode: MotionMode = 'full'): FrameAt {
53  const last = motion.frames.length - 1
54  // Reduced motion keeps the pose that tells the state: the end of a one-shot motion, the start of a loop.
55  if (mode === 'reduced') return { index: motion.loop ? 0 : last, nextAt: null }
56  const step = Math.floor((Math.max(0, elapsedMs) * motion.fps) / 1000)
57  const index = motion.loop ? step % motion.frames.length : Math.min(step, last)
58  const changes = motion.loop ? last > 0 : step < last
59  // The first whole millisecond at which the step is one more.
60  return { index, nextAt: changes ? Math.ceil(((step + 1) * 1000) / motion.fps) : null }
61}
62
63/**
64 * What a character shows at `now`. `key` names the pose, so equal keys draw
65 * equal pixels; `nextAt` is the time of its next frame change, or `null` when
66 * no later time changes the pose.
67 */
68export type CharacterPose = { key: string; pose: Pose; nextAt: number | null }
69
70export function characterPose(pack: Pack, selection: PartSelection, row: AgentRow, now: number, mode: MotionMode): CharacterPose {
71  if (mode === 'off') return { key: 'still', pose: {}, nextAt: null }
72  const { state, since } = characterState(row, now)
73  const changes: number[] = []
74  const keys: string[] = []
75  const pose: { body?: Pose['body']; parts?: Record<string, PartFrame> } = {}
76
77  const body = motionOf(pack, state)
78  if (body !== null) {
79    const { index, nextAt } = frameAt(body, now - since, mode)
80    pose.body = body.frames[index]
81    keys.push(`${state}:${index}`)
82    if (nextAt !== null) changes.push(since + nextAt)
83  }
84  // A part motion is the part's own: it runs from the spawn, whatever the state.
85  for (const [slot, name] of Object.entries(selection)) {
86    const motion = partOf(pack, slot, name)?.motion
87    if (motion === undefined) continue
88    const { index, nextAt } = frameAt(motion, now - row.startedAt, mode)
89    pose.parts = { ...pose.parts, [slot]: motion.frames[index]! }
90    keys.push(`${slot}:${index}`)
91    if (nextAt !== null) changes.push(row.startedAt + nextAt)
92  }
93  // The entrance ends at a fixed time. Only a different working motion changes the pose then.
94  if (state === 'entering' && body !== motionOf(pack, 'working')) changes.push(row.startedAt + ENTER_MS)
95  return { key: keys.join('|') || 'still', pose, nextAt: changes.length > 0 ? Math.min(...changes) : null }
96}
97
hooks/view/pane.tsx 207 lines
1// View: draws the pane from the state. It reads; it never writes.
2import { atom, read } from 'claude-code'
3import type { Elements, On, ThemeKey } from 'claude-code'
4
5import { initialAgents } from '../core/agents'
6import type { AgentStatus } from '../core/agents'
7import type { MotionMode } from '../core/character'
8import { initialSession } from '../core/session'
9import { initialUsage } from '../core/usage/usage'
10import { PANE_ID, SESSION_SHAPE } from '../state'
11import { avatarKey, framePlan, framesDrawn, stopFrames } from './frames'
12import { paneLayout } from './layout'
13import type { AgentLine, Folded, Limit, Stat } from './layout'
14
15const session = atom({ plugin: 'crabarium', key: 'session' } as const, initialSession, { shape: SESSION_SHAPE })
16const agents = atom({ plugin: 'crabarium', key: 'agents' } as const, initialAgents)
17const usage = atom({ plugin: 'crabarium', key: 'usage' } as const, initialUsage)
18
19/** The mark and the theme color of each status. Pending, idle and waiting are all quiet. */
20const STATUS_MARK: Readonly<Record<AgentStatus, { mark: string; color: ThemeKey }>> = {
21  pending: { mark: '○', color: 'inactive' },
22  running: { mark: '●', color: 'claude' },
23  waiting: { mark: '◐', color: 'inactive' },
24  idle: { mark: '○', color: 'inactive' },
25  completed: { mark: '✓', color: 'success' },
26  failed: { mark: '✗', color: 'error' },
27  killed: { mark: '⊘', color: 'warning' },
28}
29
30const FOLDED_STATUSES = ['completed', 'failed', 'killed'] as const satisfies readonly (keyof Folded & AgentStatus)[]
31
32/** The cards and the one-column gaps between them fill the body. */
33function cardWidth(columns: number, count: number): number {
34  return Math.floor((columns - (count - 1)) / count)
35}
36
37// A narrow line cuts from the end, so the three values come first and the details after them.
38function statsLine(stats: readonly Stat[]): string {
39  return [...stats.map(s => `${s.label} ${s.value ?? '—'}`), ...stats.flatMap(s => (s.detail === undefined ? [] : [s.detail]))].join(' · ')
40}
41
42function limitsLine(limits: readonly Limit[]): string {
43  return `limits ${limits.map(l => `${l.name} ${l.used}`).join(' · ')}`
44}
45
46/** The two elements that every surface has, which the row helpers draw with. */
47type Ui = Pick<Elements['terminal'], 'Box' | 'Text'>
48
49function status({ Text }: Ui, l: AgentLine) {
50  const { mark, color } = STATUS_MARK[l.status]
51  return <Text color={color}>{` ${mark} ${l.status}`}</Text>
52}
53
54// A tree guide two lines high: the connector, then the line that goes on down (or space).
55function guide({ Box, Text }: Ui, first: string, rest: string) {
56  return (
57    <Box flexDirection="column" width={2} flexShrink={0}>
58      <Text dimColor>{first}</Text>
59      <Text dimColor>{rest}</Text>
60    </Box>
61  )
62}
63
64function folded({ Box, Text }: Ui, f: Folded) {
65  return (
66    <Box>
67      <Text dimColor>└ </Text>
68      {FOLDED_STATUSES.filter(st => f[st] > 0).map((st, i) => (
69        <Text key={st} color={STATUS_MARK[st].color}>{`${i > 0 ? '  ' : ''}${STATUS_MARK[st].mark} ${f[st]}`}</Text>
70      ))}
71      <Text dimColor>{`  ${f.agents} ended ${f.agents === 1 ? 'subagent' : 'subagents'}`}</Text>
72    </Box>
73  )
74}
75
76export function registerPane(on: On, mode: MotionMode): void {
77  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
78    const ui = $.ui.resolve(e)
79    const { Box, Text } = ui
80    // The terminal draws the characters with half blocks; other surfaces get them with the Svg renderer.
81    const raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : undefined
82    const columns = e.props.bodyColumns
83    const [s, board, u, now] = await Promise.all([read($, session), read($, agents), read($, usage), $.clock.now()])
84    const layout = paneLayout(board, s, u, now, columns, raster !== undefined)
85    // The current frames, so that a redraw continues each motion and does not start it again.
86    const plan = raster === undefined ? null : framePlan(board, layout.characters, now, mode)
87    if (plan !== null) framesDrawn($, mode, layout.characters, plan, now)
88
89    const character = (id: string) => {
90      const image = plan?.images.get(id)
91      if (raster === undefined || image === undefined) return null
92      // A Raster paints all its columns even where the layout gives it fewer. So
93      // its column has a fixed width and does not shrink; the text gives way.
94      return (
95        <Box width={image.columns} flexShrink={0} marginRight={1}>
96          {h(raster, { key: avatarKey(id), ...image })}
97        </Box>
98      )
99    }
100
101    // Line 1: the description and the status. Line 2: the detail, indented when no character shows.
102    const text = (l: AgentLine) => (
103      <Box flexDirection="column" flexGrow={1} flexShrink={1}>
104        <Box>
105          <Box flexGrow={1} flexShrink={1}>
106            <Text wrap="truncate-end">{l.description}</Text>
107          </Box>
108          {status(ui, l)}
109        </Box>
110        <Text dimColor wrap="truncate-end">
111          {l.character === null ? `  ${l.detail}` : l.detail}
112        </Text>
113      </Box>
114    )
115
116    const { header, steps } = layout
117    // The limits card shows only when there are limits; the cards share the width.
118    const width = cardWidth(columns, header.stats.length + (header.limits.length > 0 ? 1 : 0))
119    return (
120      <Box flexDirection="column">
121        <Box>
122          <Text weight="semibold">Main session</Text>
123          <Text dimColor wrap="truncate-end">{`  ${[header.model, `context ${header.context}`, ...header.live].join(' · ')}`}</Text>
124        </Box>
125        {steps.cards ? (
126          <Box gap={1}>
127            {header.stats.map(stat => (
128              <Box key={stat.label} borderStyle="round" borderDimColor flexDirection="column" width={width} paddingX={1}>
129                <Text dimColor wrap="truncate-end">
130                  {stat.label}
131                </Text>
132                {stat.value === null ? (
133                  <Text dimColor>—</Text>
134                ) : (
135                  <Text weight="semibold" wrap="truncate-end">
136                    {stat.value}
137                  </Text>
138                )}
139                <Text dimColor wrap="truncate-end">
140                  {stat.detail ?? ''}
141                </Text>
142              </Box>
143            ))}
144            {header.limits.length > 0 && (
145              // One line for each window, so a name never parts from its percent.
146              <Box key="limits" borderStyle="round" borderDimColor flexDirection="column" width={width} paddingX={1}>
147                <Text dimColor>limits</Text>
148                {header.limits.map(l => (
149                  <Text key={l.name} wrap="truncate-end">
150                    <Text dimColor>{`${l.name} `}</Text>
151                    <Text weight="semibold">{l.used}</Text>
152                  </Text>
153                ))}
154              </Box>
155            )}
156          </Box>
157        ) : (
158          <Box flexDirection="column">
159            <Text wrap="truncate-end">{statsLine(header.stats)}</Text>
160            {header.limits.length > 0 && <Text wrap="truncate-end">{limitsLine(header.limits)}</Text>}
161          </Box>
162        )}
163        {layout.groups.map(group => {
164          const count = ` ${group.agents} `
165          return (
166            <Box key={group.title} flexDirection="column" marginTop={1}>
167              <Box>
168                <Text weight="semibold">{group.title}</Text>
169                <Text dimColor>{count}</Text>
170                <Text dimColor wrap="truncate">
171                  {'─'.repeat(Math.max(0, columns - group.title.length - count.length))}
172                </Text>
173              </Box>
174              {group.trees.map((tree, i) => (
175                <Box key={tree.root.id} flexDirection="column" marginTop={i > 0 && !steps.character ? 1 : 0}>
176                  <Box alignItems="center">
177                    {character(tree.root.id)}
178                    {text(tree.root)}
179                  </Box>
180                  {tree.children.map((child, j) => {
181                    const isLast = j === tree.children.length - 1 && tree.folded === null
182                    return (
183                      <Box key={child.id}>
184                        {guide(ui, isLast ? '└ ' : '├ ', isLast ? '  ' : '│ ')}
185                        {character(child.id)}
186                        {text(child)}
187                      </Box>
188                    )
189                  })}
190                  {tree.folded !== null && folded(ui, tree.folded)}
191                </Box>
192              ))}
193            </Box>
194          )
195        })}
196        {layout.earlier > 0 && <Text dimColor>{`+ ${layout.earlier} earlier`}</Text>}
197      </Box>
198    )
199  })
200
201  // An observer: if it fails, the close goes on.
202  on('ui.close', { id: PANE_ID }, (_$, e, next) => {
203    stopFrames()
204    return next(e)
205  }).catch((_$, e, next) => next(e))
206}
207
hooks/core/agents.ts 94 lines
1// The subagents as the pane shows them. Pure: no engine calls; times come in
2// with the events.
3import type { AgentBoard, AgentRow, AgentStatus } from '../../types'
4
5export type { AgentBoard, AgentRow, AgentStatus }
6
7/** How a turn ended, as `turn.complete` gives it. */
8export type TurnEndReason = 'answer' | 'aborted' | 'refusal' | 'error'
9
10export type AgentEvent =
11  | {
12      kind: 'agent-spawned'
13      id: string
14      description: string
15      type: string
16      model: string
17      parentId: string | null
18      isTeammate: boolean
19      at: number
20    }
21  | { kind: 'agents-listed'; statuses: ReadonlyArray<{ id: string; status: AgentStatus }>; at: number }
22  | { kind: 'agent-turn-ended'; id: string; reason: TurnEndReason; at: number }
23
24export const initialAgents: AgentBoard = { now: null, rows: [] }
25
26const ENDED: ReadonlySet<AgentStatus> = new Set(['completed', 'failed', 'killed'])
27
28export function isEnded(status: AgentStatus): boolean {
29  return ENDED.has(status)
30}
31
32const STATUS_OF_TURN_END: Readonly<Record<TurnEndReason, AgentStatus>> = {
33  answer: 'completed',
34  aborted: 'killed',
35  refusal: 'failed',
36  error: 'failed',
37}
38
39// An ended agent starts again only through a new spawn (a resume). The list
40// can still say `running` for a moment after the turn end, so a list status
41// does not reopen an ended row; it can only correct how the row ended.
42function withStatus(row: AgentRow, status: AgentStatus, at: number): AgentRow {
43  if (row.status === status) return row
44  if (isEnded(row.status)) return isEnded(status) ? { ...row, status, statusAt: at } : row
45  return { ...row, status, statusAt: at, endedAt: isEnded(status) ? at : null }
46}
47
48function mapRows(rows: readonly AgentRow[], fn: (row: AgentRow) => AgentRow): readonly AgentRow[] {
49  const next = rows.map(fn)
50  return next.every((row, i) => row === rows[i]) ? rows : next
51}
52
53export function reduceAgents(state: AgentBoard, event: AgentEvent): AgentBoard {
54  switch (event.kind) {
55    case 'agent-spawned': {
56      const { kind: _kind, at, ...fields } = event
57      const row: AgentRow = { ...fields, status: 'running', statusAt: at, startedAt: at, endedAt: null }
58      const rest = state.rows.filter(r => r.id !== event.id)
59      return { now: at, rows: [...rest, row] }
60    }
61    case 'agents-listed': {
62      const byId = new Map(event.statuses.map(s => [s.id, s.status]))
63      const rows = mapRows(state.rows, row => {
64        const status = byId.get(row.id)
65        return status === undefined ? row : withStatus(row, status, event.at)
66      })
67      // `now` only moves durations; with no live row it changes nothing shown.
68      if (rows === state.rows && !rows.some(r => !isEnded(r.status))) return state
69      return { now: event.at, rows }
70    }
71    case 'agent-turn-ended': {
72      const rows = mapRows(state.rows, row =>
73        row.id === event.id && !row.isTeammate ? withStatus(row, STATUS_OF_TURN_END[event.reason], event.at) : row,
74      )
75      return rows === state.rows ? state : { now: event.at, rows }
76    }
77  }
78}
79
80/**
81 * Milliseconds the agent has run, or `null` before the first read of the time.
82 *
83 * A live row can show a different value than a `subagentStatusLine` row for the
84 * same agent. The status line computes its time from the engine's task
85 * `startTime`, and it refreshes on its own interval (some seconds). The mod API
86 * does not give `startTime`, so `startedAt` is the time the spawn hook ends, and
87 * the pane moves `now` each second. So the status line can show a smaller value
88 * until its next refresh. A finished row agrees with the engine's duration to the second.
89 */
90export function durationMs(row: AgentRow, now: number | null): number | null {
91  const end = row.endedAt ?? now
92  return end === null ? null : Math.max(0, end - row.startedAt)
93}
94
hooks/core/session.ts 122 lines
1// The main session as the pane shows it. Pure: no engine calls, no clock.
2import type { CacheUse, MainSession, PromptCache, RateLimit, TimedStep } from '../../types'
3
4export type { CacheUse, MainSession, PromptCache, RateLimit, TimedStep }
5
6export type SessionEvent =
7  | { kind: 'model-used'; model: string }
8  | {
9      kind: 'usage-read'
10      contextPercent: number | null
11      costUsd: number | null
12      startedAt: number
13      rateLimits: readonly RateLimit[]
14      /** `null` when the engine did not answer. */
15      turns: number | null
16      at: number
17    }
18  /** The user sent a prompt, at this session cost. */
19  | { kind: 'prompt-sent'; costUsd: number | null }
20  /**
21   * A main-session request answered. `model` is the model that the request
22   * named; `sentAt` is when the engine sent it; `cacheUsed` says that it read
23   * or wrote the prompt cache. `step` is `null` when no output came.
24   */
25  | { kind: 'step-timed'; step: TimedStep | null; model: string; sentAt: number; cacheUsed: boolean }
26  /** The model changed. The new model has no cache yet. `cacheTtlMs` is `null` for a TTL the mod does not know. */
27  | { kind: 'model-switched'; model: string; cacheTtlMs: number | null }
28  /** A `/clear` started a new conversation, or a resume put an old one in its place. */
29  | { kind: 'conversation-replaced' }
30
31export const initialSession: MainSession = {
32  model: null,
33  contextPercent: null,
34  costUsd: null,
35  startedAt: null,
36  readAt: null,
37  rateLimits: [],
38  turns: null,
39  turnBaseCost: null,
40  lastStep: null,
41  cache: { ttlMs: null, model: null, use: { kind: 'unknown' } },
42}
43
44const CACHE_TTL_MS: Readonly<Record<string, number>> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
45
46/** The TTL that the engine names, in milliseconds; `null` for a name that the mod does not know. */
47export function cacheTtlMs(name: string): number | null {
48  return CACHE_TTL_MS[name] ?? null
49}
50
51// A request refreshes the cache entry when the API reads the prompt, before
52// the first output token. So the countdown counts from the send: it can end
53// early, but never late.
54function cacheAfterStep(cache: PromptCache, model: string, sentAt: number, cacheUsed: boolean): PromptCache {
55  // A request to another model (a fallback, or one that a hook sent elsewhere) uses another cache.
56  if (!cacheUsed || (cache.model !== null && cache.model !== model)) return cache
57  return { ...cache, use: { kind: 'used', at: sentAt } }
58}
59
60export function reduceSession(state: MainSession, event: SessionEvent): MainSession {
61  switch (event.kind) {
62    case 'model-used':
63      return state.model === event.model ? state : { ...state, model: event.model }
64    case 'usage-read': {
65      const { kind: _kind, at, ...usage } = event
66      return { ...state, ...usage, readAt: at }
67    }
68    case 'prompt-sent':
69      // The cost comes from the same read as the base, so the turn cost never mixes an old cost with a new base.
70      return { ...state, costUsd: event.costUsd, turnBaseCost: event.costUsd }
71    case 'step-timed':
72      return {
73        ...state,
74        lastStep: event.step ?? state.lastStep,
75        cache: cacheAfterStep(state.cache, event.model, event.sentAt, event.cacheUsed),
76      }
77    case 'model-switched':
78      return { ...state, cache: { ttlMs: event.cacheTtlMs, model: event.model, use: { kind: 'cold' } } }
79    case 'conversation-replaced':
80      // The TTL is the session's setting, so it stays. Whether the cache is
81      // still warm is not known: a resume can come back within the TTL, and
82      // a /clear keeps the system prompt. The next request tells.
83      return {
84        ...state,
85        costUsd: null,
86        turnBaseCost: null,
87        lastStep: null,
88        cache: { ...state.cache, use: { kind: 'unknown' } },
89      }
90  }
91}
92
93/** The cost since the user's last prompt, subagents included; `null` when it is not known. */
94export function turnCost(s: MainSession): number | null {
95  if (s.costUsd === null || s.turnBaseCost === null) return null
96  const cost = s.costUsd - s.turnBaseCost
97  // Below the base, the cost started again under the mod (a /clear it did not see).
98  return cost < 0 ? null : cost
99}
100
101/** The output tokens per second of the last request; `null` before it, or when it took no time. */
102export function tokensPerSecond(step: TimedStep | null): number | null {
103  return step === null || step.ms <= 0 ? null : step.outputTokens / (step.ms / 1000)
104}
105
106/**
107 * The time until the prompt cache expires: 0 when it is cold, `null` when it
108 * is not known. The mod does not guess: a wrong countdown would be worse than
109 * none.
110 */
111export function cacheLeftMs(cache: PromptCache, now: number): number | null {
112  if (cache.ttlMs === null) return null
113  switch (cache.use.kind) {
114    case 'unknown':
115      return null
116    case 'cold':
117      return 0
118    case 'used':
119      return Math.max(0, cache.use.at + cache.ttlMs - now)
120  }
121}
122
hooks/core/usage/usage.ts 81 lines
1// The usage of each agent loop, summed over its model requests. Pure: the
2// request figures come in with the events.
3//
4// The counts are the API's own, request by request; only the sums are the
5// mod's. Two limits: a hook beneath the mod that sends a request again shows
6// the mod only the last answer, and a resumed session starts the sums at zero
7// while the engine's cost keeps the earlier part. There is no cost per agent:
8// see the note in ./models.ts.
9import type { AgentUsage, LastRequest, TokenSums, UsageBoard } from '../../../types'
10import { contextWindowOf, promptTokens } from './models'
11
12export type { AgentUsage, LastRequest, TokenSums, UsageBoard }
13
14/** One model request that answered: whose loop, which model, how hard, and what it used. */
15export type StepFinished = {
16  kind: 'step-finished'
17  /** `null` for the main session. */
18  agentId: string | null
19  /** The model that answered, by the id the API reports. */
20  model: string
21  effort: string | null
22  tokens: TokenSums
23}
24
25/** A conversation ended (`/clear`, or a resume took its place): the sums start again. */
26export type UsageReset = { kind: 'usage-reset' }
27
28export type UsageEvent = StepFinished | UsageReset
29
30export const initialUsage: UsageBoard = { main: null, agents: {} }
31
32const NO_TOKENS: TokenSums = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
33
34function add(a: TokenSums, b: TokenSums): TokenSums {
35  return {
36    input: a.input + b.input,
37    output: a.output + b.output,
38    cacheRead: a.cacheRead + b.cacheRead,
39    cacheWrite: a.cacheWrite + b.cacheWrite,
40  }
41}
42
43// A resume of an agent keeps its key, so its sums go on: they describe the
44// agent's whole life, while its row's duration starts again.
45export function reduceUsage(state: UsageBoard, event: UsageEvent): UsageBoard {
46  if (event.kind === 'usage-reset') return initialUsage
47  if (event.agentId === null) return { ...state, main: add(state.main ?? NO_TOKENS, event.tokens) }
48  const last: LastRequest = { promptTokens: promptTokens(event.tokens), model: event.model, effort: event.effort }
49  const before = state.agents[event.agentId]?.tokens ?? NO_TOKENS
50  const agent: AgentUsage = { tokens: add(before, event.tokens), last }
51  return { ...state, agents: { ...state.agents, [event.agentId]: agent } }
52}
53
54/**
55 * The tokens that the requests added: the uncached input, the cache writes,
56 * and the output. Cache reads are left out: each request reads the whole
57 * cached context again, so in a long session they would be most of a sum that
58 * says little about the work. `cacheHitRate` tells how much they were.
59 */
60export function newTokens(t: TokenSums): number {
61  return t.input + t.cacheWrite + t.output
62}
63
64/** The share of all input that the prompt cache served, 0 to 100; `null` before any input. */
65export function cacheHitRate(t: TokenSums): number | null {
66  const input = t.input + t.cacheRead + t.cacheWrite
67  return input === 0 ? null : (t.cacheRead / input) * 100
68}
69
70/** The sums of the main session and every agent together; `null` before the first request. */
71export function sessionSums(board: UsageBoard): TokenSums | null {
72  const sums = [...(board.main === null ? [] : [board.main]), ...Object.values(board.agents).map(a => a.tokens)]
73  return sums.length === 0 ? null : sums.reduce(add, NO_TOKENS)
74}
75
76/** The last prompt over its model's context window, 0 to 100; `null` when the window is unknown. */
77export function contextPercent(last: LastRequest): number | null {
78  const window = contextWindowOf(last.model)
79  return window === null ? null : (last.promptTokens / window) * 100
80}
81
hooks/core/version.ts 22 lines
1// Function hooks are an early-access API: the mod runs only on engine
2// versions it was tested against.
3export const MIN_ENGINE_VERSION = '2.1.300'
4
5type Triple = readonly [number, number, number]
6
7function parse(version: string): Triple | null {
8  const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version)
9  if (!match) return null
10  return [Number(match[1]), Number(match[2]), Number(match[3])]
11}
12
13// A development build of a release (`2.1.300-dev`) counts as that release.
14// A version that does not parse is not supported.
15export function isSupportedEngine(version: string, minimum: string = MIN_ENGINE_VERSION): boolean {
16  const have = parse(version)
17  const need = parse(minimum)
18  if (!have || !need) return false
19  const order = have[0] - need[0] || have[1] - need[1] || have[2] - need[2]
20  return order >= 0
21}
22
hooks/state.ts 12 lines
1// The engine's state scan reads each `$.state` reference in the file that
2// uses it, so each user declares its atom itself; types/index.d.ts holds them
3// to one contract.
4export const PANE_ID = 'crabarium'
5
6/**
7 * The shape tag of the session value. A hot reload keeps `$.state`, so a
8 * change to `MainSession` takes a new tag: the new code then reads the old
9 * value as absent and starts from the initial one.
10 */
11export const SESSION_SHAPE = 'session-2'
12
hooks/core/pack/motions.ts 29 lines
1// The built-in motions. They move only `*`, the whole character, so they work
2// on each pack. A pack motion with the same name replaces one.
3import { own } from '../own'
4import type { CharacterState, Motion, Pack } from './pack'
5
6const UP = { offset: { '*': [0, -1] } } as const
7
8export const BUILTIN_MOTIONS: Readonly<Record<string, Motion>> = {
9  walk: { fps: 6, loop: true, frames: [UP, {}] },
10  bob: { fps: 2, loop: true, frames: [{}, UP] },
11  float: { fps: 3, loop: true, frames: [{}, UP, { offset: { '*': [0, -2] } }, UP] },
12  swing: { fps: 4, loop: true, frames: [{ offset: { '*': [-1, 0] } }, {}, { offset: { '*': [1, 0] } }, {}] },
13}
14
15/** The motion of each state when the pack does not name one. Finished and failed stand still. */
16export const DEFAULT_STATES: Readonly<Partial<Record<CharacterState, string>>> = {
17  entering: 'walk',
18  working: 'walk',
19  idle: 'bob',
20  leaving: 'walk',
21}
22
23/** The motion that a state plays in this pack, or `null` for the still character. */
24export function motionOf(pack: Pack, state: CharacterState): Motion | null {
25  const name = pack.states?.[state] ?? DEFAULT_STATES[state]
26  if (name === undefined) return null
27  return own(pack.motions, name) ?? own(BUILTIN_MOTIONS, name) ?? null
28}
29
hooks/core/pack/pack.ts 271 lines
1// A character pack: parse and check `pack.json`, then compose pixels. Pure:
2// the pack is data, and nothing in it runs as code.
3import { own } from '../own'
4import { BUILTIN_MOTIONS } from './motions'
5import { CHARACTER_STATES, MAX_COLORS, packSchema } from './schema'
6import { validateSchema } from './validate'
7import type { SchemaError } from './validate'
8
9export type { SchemaError }
10
11export type Point = readonly [x: number, y: number]
12
13/** Pixel rows of palette keys; "." is transparent. */
14export type Rows = readonly string[]
15
16export type Palette = Readonly<Record<string, string>>
17
18/** A move in whole pixels. */
19export type Move = readonly [dx: number, dy: number]
20
21/** Other rows for the same place, by name. */
22export type Variants = Readonly<Record<string, Rows>>
23
24/** One frame of a body motion. A target is a group, a part slot, or `*` for the whole character. */
25export type Frame = {
26  offset?: Readonly<Record<string, Move>>
27  swap?: Readonly<Record<string, string>>
28  hide?: readonly string[]
29  palette?: Palette
30}
31
32/** One frame of a part motion: the same operations, on the part alone. */
33export type PartFrame = { offset?: Move; swap?: string; hide?: boolean; palette?: Palette }
34
35export type Motion<F = Frame> = { fps: number; loop: boolean; frames: readonly F[] }
36
37export type CharacterState = (typeof CHARACTER_STATES)[number]
38
39export type Group = { at: Point; rows: Rows; variants?: Variants }
40
41export type Part = { anchor: string; pivot: Point; rows: Rows; palette?: Palette; variants?: Variants; motion?: Motion<PartFrame> }
42
43/** `pack.json` after it passed the schema and the checks below. */
44export type Pack = {
45  formatVersion: 1
46  name: string
47  author: string
48  license: string
49  attribution?: string
50  notice?: string
51  grid: { width: number; height: number }
52  palette: Palette
53  anchors?: Readonly<Record<string, Point>>
54  groups: Readonly<Record<string, Group>>
55  parts?: Readonly<Record<string, Readonly<Record<string, Part>>>>
56  motions?: Readonly<Record<string, Motion>>
57  states?: Readonly<Partial<Record<CharacterState, string>>>
58  defaults?: { parts?: Readonly<Record<string, string>> }
59}
60
61export type ParseResult = { ok: true; pack: Pack } | { ok: false; errors: readonly SchemaError[] }
62
63export const TRANSPARENT = '.'
64
65/** The part `name` in slot `slot`, if the pack has it. */
66export function partOf(pack: Pack, slot: string, name: string): Part | undefined {
67  return own(own(pack.parts, slot), name)
68}
69
70function checkRows(rows: Rows, palette: Palette, path: string, errors: SchemaError[]): void {
71  rows.forEach((row, y) => {
72    ;[...row].forEach((key, x) => {
73      if (key !== TRANSPARENT && !Object.hasOwn(palette, key)) {
74        errors.push({ path: `${path}.rows[${y}]`, message: `column ${x}: ${JSON.stringify(key)} is not in the palette` })
75      }
76    })
77  })
78}
79
80function widthOf(rows: Rows): number {
81  return Math.max(...rows.map(r => [...r].length))
82}
83
84function framePaletteKeys(palette: Palette | undefined, keys: ReadonlySet<string>, path: string, errors: SchemaError[]): void {
85  for (const key of Object.keys(palette ?? {})) {
86    if (!keys.has(key)) errors.push({ path: `${path}.palette`, message: `${JSON.stringify(key)} is not in a palette` })
87  }
88}
89
90// The rules that a JSON Schema cannot state: they relate one field to another.
91function checkRelations(pack: Pack): SchemaError[] {
92  const errors: SchemaError[] = []
93  const { width, height } = pack.grid
94  const inside = ([x, y]: Point): boolean => x < width && y < height
95  const fits = (at: Point, rows: Rows, path: string): void => {
96    if (at[0] + widthOf(rows) > width || at[1] + rows.length > height) {
97      errors.push({ path, message: `the rows at [${at.join(', ')}] go outside the ${width} x ${height} grid` })
98    }
99  }
100  const parts = Object.entries(pack.parts ?? {})
101  const allPalettes = [pack.palette, ...parts.flatMap(([, named]) => Object.values(named).map(p => p.palette ?? {}))]
102  const allKeys = new Set(allPalettes.flatMap(p => Object.keys(p)))
103
104  for (const [name, point] of Object.entries(pack.anchors ?? {})) {
105    if (!inside(point)) errors.push({ path: `anchors.${name}`, message: `[${point.join(', ')}] is outside the ${width} x ${height} grid` })
106  }
107  for (const [name, group] of Object.entries(pack.groups)) {
108    fits(group.at, group.rows, `groups.${name}`)
109    checkRows(group.rows, pack.palette, `groups.${name}`, errors)
110    for (const [variant, rows] of Object.entries(group.variants ?? {})) {
111      fits(group.at, rows, `groups.${name}.variants.${variant}`)
112      checkRows(rows, pack.palette, `groups.${name}.variants.${variant}`, errors)
113    }
114  }
115  for (const [slot, named] of parts) {
116    // A frame names groups and slots in one list, so one name cannot be both.
117    if (Object.hasOwn(pack.groups, slot)) errors.push({ path: `parts.${slot}`, message: `${JSON.stringify(slot)} is also a group name` })
118    for (const [name, part] of Object.entries(named)) {
119      const path = `parts.${slot}.${name}`
120      const palette = { ...pack.palette, ...part.palette }
121      if (!Object.hasOwn(pack.anchors ?? {}, part.anchor)) {
122        errors.push({ path: `${path}.anchor`, message: `${JSON.stringify(part.anchor)} is not in anchors` })
123      }
124      checkRows(part.rows, palette, path, errors)
125      for (const [variant, rows] of Object.entries(part.variants ?? {})) checkRows(rows, palette, `${path}.variants.${variant}`, errors)
126      part.motion?.frames.forEach((frame, i) => {
127        const at = `${path}.motion.frames[${i}]`
128        if (frame.swap !== undefined && !Object.hasOwn(part.variants ?? {}, frame.swap)) {
129          errors.push({ path: `${at}.swap`, message: `${JSON.stringify(frame.swap)} is not in ${path}.variants` })
130        }
131        framePaletteKeys(frame.palette, new Set(Object.keys(palette)), at, errors)
132      })
133    }
134  }
135  const targets = new Set([...Object.keys(pack.groups), ...Object.keys(pack.parts ?? {})])
136  for (const [name, motion] of Object.entries(pack.motions ?? {})) {
137    motion.frames.forEach((frame, i) => {
138      const at = `motions.${name}.frames[${i}]`
139      for (const target of Object.keys(frame.offset ?? {})) {
140        if (target !== '*' && !targets.has(target))
141          errors.push({ path: `${at}.offset`, message: `${JSON.stringify(target)} is not a group or a slot` })
142      }
143      for (const target of frame.hide ?? []) {
144        if (!targets.has(target)) errors.push({ path: `${at}.hide`, message: `${JSON.stringify(target)} is not a group or a slot` })
145      }
146      for (const [group, variant] of Object.entries(frame.swap ?? {})) {
147        if (own(own(pack.groups, group)?.variants, variant) === undefined) {
148          errors.push({ path: `${at}.swap.${group}`, message: `${JSON.stringify(variant)} is not in groups.${group}.variants` })
149        }
150      }
151      framePaletteKeys(frame.palette, allKeys, at, errors)
152    })
153  }
154  for (const [state, motion] of Object.entries(pack.states ?? {})) {
155    if (!Object.hasOwn(pack.motions ?? {}, motion) && !Object.hasOwn(BUILTIN_MOTIONS, motion)) {
156      errors.push({ path: `states.${state}`, message: `${JSON.stringify(motion)} is not a motion in the pack or a built-in motion` })
157    }
158  }
159  for (const [slot, name] of Object.entries(pack.defaults?.parts ?? {})) {
160    if (partOf(pack, slot, name) === undefined) {
161      errors.push({ path: `defaults.parts.${slot}`, message: `${JSON.stringify(name)} is not a part in parts.${slot}` })
162    }
163  }
164  const framePalettes = [
165    ...Object.values(pack.motions ?? {}).flatMap(m => m.frames.map(f => f.palette ?? {})),
166    ...parts.flatMap(([, named]) => Object.values(named).flatMap(p => p.motion?.frames.map(f => f.palette ?? {}) ?? [])),
167  ]
168  const colors = new Set([...allPalettes, ...framePalettes].flatMap(p => Object.values(p).map(c => c.toLowerCase())))
169  if (colors.size > MAX_COLORS) errors.push({ path: 'palette', message: `the pack uses ${colors.size} colors; the limit is ${MAX_COLORS}` })
170  return errors
171}
172
173/** Checks the parsed JSON of `pack.json`. Each error names the field. */
174export function parsePack(json: unknown): ParseResult {
175  const errors = validateSchema(packSchema, json)
176  if (errors.length > 0) return { ok: false, errors }
177  const pack = json as Pack
178  const relations = checkRelations(pack)
179  return relations.length > 0 ? { ok: false, errors: relations } : { ok: true, pack }
180}
181
182/** Parses the text of `pack.json`. */
183export function parsePackText(text: string): ParseResult {
184  let json: unknown
185  try {
186    json = JSON.parse(text)
187  } catch (error) {
188    return { ok: false, errors: [{ path: '(root)', message: `is not JSON: ${(error as Error).message}` }] }
189  }
190  return parsePack(json)
191}
192
193/** The parts to draw: a part name for each slot. */
194export type PartSelection = Readonly<Record<string, string>>
195
196/** Colors as 0xRRGGBB, row-major; `null` is transparent. */
197export type PixelGrid = { width: number; height: number; pixels: readonly (number | null)[] }
198
199function paint(pixels: (number | null)[], grid: Pack['grid'], rows: Rows, palette: Palette, [ox, oy]: Point): void {
200  rows.forEach((row, dy) => {
201    ;[...row].forEach((key, dx) => {
202      const x = ox + dx
203      const y = oy + dy
204      // A part or an offset can reach past the grid; the grid clips it.
205      if (key === TRANSPARENT || x < 0 || y < 0 || x >= grid.width || y >= grid.height) return
206      pixels[y * grid.width + x] = Number.parseInt(palette[key]!.slice(1), 16)
207    })
208  })
209}
210
211/**
212 * The same picture at half size: each 2 x 2 block becomes one pixel. A block
213 * with two or more opaque pixels is opaque, in its most common color; on a tie,
214 * the first of them in reading order. One-pixel details, such as an eye, can
215 * go away.
216 */
217export function halve(grid: PixelGrid): PixelGrid {
218  const width = Math.ceil(grid.width / 2)
219  const height = Math.ceil(grid.height / 2)
220  const at = (x: number, y: number): number | null => (x < grid.width && y < grid.height ? (grid.pixels[y * grid.width + x] ?? null) : null)
221  const pixels: (number | null)[] = []
222  for (let y = 0; y < height; y += 1) {
223    for (let x = 0; x < width; x += 1) {
224      const block = [at(2 * x, 2 * y), at(2 * x + 1, 2 * y), at(2 * x, 2 * y + 1), at(2 * x + 1, 2 * y + 1)].filter(c => c !== null)
225      const counts = new Map<number, number>()
226      for (const color of block) counts.set(color, (counts.get(color) ?? 0) + 1)
227      const top = [...counts].reduce<[number, number] | null>((best, entry) => (best === null || entry[1] > best[1] ? entry : best), null)
228      pixels.push(block.length >= 2 && top !== null ? top[0] : null)
229    }
230  }
231  return { width, height, pixels }
232}
233
234/** The frame operations to apply: one body frame, and one frame for each slot whose part moves. */
235export type Pose = { body?: Frame; parts?: Readonly<Record<string, PartFrame>> }
236
237const NO_MOVE: Move = [0, 0]
238
239function moved([x, y]: Point, ...moves: (Move | undefined)[]): Point {
240  return moves.reduce<Point>(([px, py], m = NO_MOVE) => [px + m[0], py + m[1]], [x, y])
241}
242
243/**
244 * Draws the groups in their order, then one part for each selected slot, on
245 * its anchor. A slot or a part that the pack does not have draws nothing. The
246 * pose moves, swaps, hides, and recolors what it names.
247 */
248export function compose(pack: Pack, selection: PartSelection = pack.defaults?.parts ?? {}, pose: Pose = {}): PixelGrid {
249  const { grid } = pack
250  const { offset = {}, swap = {}, hide = [], palette: framePalette } = pose.body ?? {}
251  const hidden = new Set(hide)
252  const whole = own(offset, '*')
253  const pixels: (number | null)[] = Array.from({ length: grid.width * grid.height }, () => null)
254  for (const [name, group] of Object.entries(pack.groups)) {
255    if (hidden.has(name)) continue
256    const variant = own(swap, name)
257    const rows = variant === undefined ? group.rows : (own(group.variants, variant) ?? group.rows)
258    paint(pixels, grid, rows, { ...pack.palette, ...framePalette }, moved(group.at, whole, own(offset, name)))
259  }
260  for (const [slot, name] of Object.entries(selection)) {
261    const part = partOf(pack, slot, name)
262    const anchor = part === undefined ? undefined : own(pack.anchors, part.anchor)
263    const frame = own(pose.parts, slot) ?? {}
264    if (part === undefined || anchor === undefined || hidden.has(slot) || frame.hide === true) continue
265    const rows = frame.swap === undefined ? part.rows : (own(part.variants, frame.swap) ?? part.rows)
266    const origin = moved([anchor[0] - part.pivot[0], anchor[1] - part.pivot[1]], whole, own(offset, slot), frame.offset)
267    paint(pixels, grid, rows, { ...pack.palette, ...part.palette, ...framePalette, ...frame.palette }, origin)
268  }
269  return { width: grid.width, height: grid.height, pixels }
270}
271
hooks/view/frames.ts 104 lines
1// The frame loop of the pane: it repaints the characters with `$.ui.blit`.
2// It wakes at the next frame change of any character, blits only the
3// characters whose cells changed, and stops when no character moves or when
4// the pane goes away. Only the terminal draws Rasters.
5import { atom, read } from 'claude-code'
6import type { EngineInterface, Timer } from 'claude-code'
7
8import { initialAgents } from '../core/agents'
9import type { AgentBoard } from '../core/agents'
10import type { MotionMode } from '../core/character'
11import { PANE_ID } from '../state'
12import { avatarFrame } from './avatar'
13import type { RasterImage } from './avatar'
14import type { ShownCharacter } from './layout'
15
16const agents = atom({ plugin: 'crabarium', key: 'agents' } as const, initialAgents)
17
18export function avatarKey(id: string): string {
19  return `avatar-${id}`
20}
21
22/** The cells of each shown character at `now`, and the time of the next frame change of any of them. */
23export type FramePlan = { images: Map<string, RasterImage>; nextAt: number | null }
24
25export function framePlan(board: AgentBoard, characters: readonly ShownCharacter[], now: number, mode: MotionMode): FramePlan {
26  const rows = new Map(board.rows.map(r => [r.id, r]))
27  const images = new Map<string, RasterImage>()
28  let nextAt: number | null = null
29  for (const { id, size } of characters) {
30    const row = rows.get(id)
31    if (row === undefined) continue
32    const frame = avatarFrame(row, now, mode, size)
33    images.set(id, frame.image)
34    if (frame.nextAt !== null) nextAt = nextAt === null ? frame.nextAt : Math.min(nextAt, frame.nextAt)
35  }
36  return { images, nextAt }
37}
38
39// The module's own state: a hot reload drops it with the timer, and the next
40// render starts the loop again.
41let timer: Timer | null = null
42// Each render and each stop starts a new generation. A tick that awaited
43// across one is stale: it changes nothing and does not wake the loop again.
44let generation = 0
45// The characters that the last render drew, and the cells that each mounted
46// Raster shows now, by Raster key. Only a render changes which characters
47// show and at which size; the loop repaints them.
48let drawn: readonly ShownCharacter[] = []
49const shown = new Map<string, string>()
50
51export function stopFrames(): void {
52  generation += 1
53  timer?.cancel()
54  timer = null
55  drawn = []
56  shown.clear()
57}
58
59function wakeAt($: EngineInterface, mode: MotionMode, nextAt: number | null, now: number): void {
60  timer?.cancel()
61  timer = null
62  if (nextAt === null) return
63  const current = generation
64  timer = $.clock.after(Math.max(1, nextAt - now), () => {
65    timer = null
66    tick($, mode, current).catch((error: unknown) => {
67      // A failed frame stops the loop, with one log line; the next render starts it again.
68      stopFrames()
69      $.ui.log(`crabarium: the frame loop stopped: ${error instanceof Error ? error.message : String(error)}`)
70    })
71  })
72}
73
74async function tick($: EngineInterface, mode: MotionMode, current: number): Promise<void> {
75  const [board, now] = await Promise.all([read($, agents), $.clock.now()])
76  const { images, nextAt } = framePlan(board, drawn, now, mode)
77  const changed = [...images].filter(([id, image]) => shown.get(avatarKey(id)) !== image.cells)
78  const results = await Promise.all(changed.map(([id, image]) => $.ui.blit({ requestId: PANE_ID, key: avatarKey(id), cells: image.cells })))
79  if (current !== generation) return
80  // A deny means that the pane is not mounted: it closed, or it waits for room.
81  // Each mount renders the pane, and the render starts the loop again.
82  if (results.some(r => r.deny !== undefined)) {
83    stopFrames()
84    return
85  }
86  for (const [id, image] of changed) shown.set(avatarKey(id), image.cells)
87  wakeAt($, mode, nextAt, now)
88}
89
90/** Records what a render drew at `now`, and wakes the loop at the next frame change (or stops it). */
91export function framesDrawn(
92  $: EngineInterface,
93  mode: MotionMode,
94  characters: readonly ShownCharacter[],
95  plan: FramePlan,
96  now: number,
97): void {
98  generation += 1
99  drawn = characters
100  shown.clear()
101  for (const [id, image] of plan.images) shown.set(avatarKey(id), image.cells)
102  wakeAt($, mode, plan.nextAt, now)
103}
104