SLOPSHOPPER

starter

Starter, an Engram for Eidola: a small creature drawn in Eidola's pane and band.

paneband
A shopper browsing a rack in a slop shop
README

Starter

An Engram for Eidola: a small mote of light, drawn by this plugin inside Eidola's pane and band (an Engram plugin, contract 1). Eidola keeps the frame, the HUD, the corner and the picker; this plugin draws the creature.

  • engram.json: the Engram (vessel.scene "self").
  • hooks/register.tsx: announces the Engram to Eidola and draws it in each mood, with an error effect. Change the creature in LOOKS and draw().
  • vessel/thumb-pane.svg: the picker's still.

Check it before you share it, from the Eidola mod's folder: node scripts/check-engram.mjs <this folder>.

Source 1 files
hooks/register.tsx 187 lines
1// The starter Engram plugin for Eidola (contract 1, docs/spec/engram-plugin.md
2// in the Eidola mod): a small creature, a mote of light, in the three moods,
3// with an error effect. The Eidola skill copies this folder to make a new
4// Engram "from nothing", then changes the creature with you.
5//
6// What to change to make it yours:
7//   - LOOKS: the creature itself, one look per mood and one for the error
8//     effect. Each look is a colour, a body (lines of text, all the same
9//     width) and a caption.
10//   - draw(): how a look is laid out in the room Eidola hands over.
11//   - ID: engram.json's id. The skill sets both when it copies the starter.
12// The rest (announcing, the two seats of the hand-off, the fault report) is
13// the contract, and works as it is.
14//
15// It writes none of Eidola's state and adds nothing to any prompt.
16//
17// Everything sits inside `register`, so a test can load this function as an
18// inline plugin (Eidola's hooks/starter.test.ts does).
19
20import type { Register, RenderElement } from 'claude-code'
21
22export const register: Register = on => {
23  /** This Engram's id: engram.json's. */
24  const ID = 'starter'
25  /** The Box its drawing comes back in, and the slot Eidola leaves for it. */
26  const SCENE = `eidola:${ID}`
27  const SLOT = `eidola-slot:${ID}`
28  /** Where a failure is reported from. */
29  const FILE = 'hooks/register.tsx'
30  /** How long the error effect plays after the ink rises, in ms. */
31  const ERROR_MS = 2000
32
33  type Mood = 'idle' | 'working' | 'attention'
34  /** One look: its colour, its body (lines of equal width) and the caption under it. */
35  type Look = { colour: string; glow: string; body: readonly string[]; caption: string }
36
37  // ── The creature ───────────────────────────────────────────────────────
38  const LOOKS: Record<Mood | 'error', Look> = {
39    // At rest: a soft blue mote, eyes half open, drifting.
40    idle: { colour: '#7fd4ff', glow: '#2f6f8f', body: ['  .--.  ', ' ( -.- ) ', "  `~~'  "], caption: 'drifting' },
41    // Busy: amber, eyes squeezed, sparks flying off it.
42    working: { colour: '#ffb347', glow: '#a8641c', body: [' *.--.* ', ' ( >.< ) ', " *`~~'* "], caption: 'busy busy' },
43    // Asking to be looked at: bright yellow, eyes wide, arms up.
44    attention: { colour: '#ffe14d', glow: '#b39a1f', body: ['\\ .--. /', ' ( O.O ) ', "  `~~'  "], caption: 'look here!' },
45    // The error effect, over any mood for ERROR_MS: red, crossed eyes, smoke.
46    error: { colour: '#ff4d4d', glow: '#8f1f1f', body: [' ~.--.~ ', ' ( x.x ) ', "  `~~'  "], caption: 'ouch' },
47  }
48
49  /** What the drawing needs: the room, the mood, and whether the error effect plays. */
50  type Seen = { columns: number; rows: number; mood: Mood; isError: boolean }
51  type Els = { Box: (props: Record<string, unknown>) => RenderElement; Text: (props: Record<string, unknown>) => RenderElement }
52
53  /** The creature, `columns` × `rows`, in one Box keyed eidola:<id>. */
54  const draw = (els: Els, seen: Seen): RenderElement => {
55    const { Box, Text } = els
56    const look = (seen.isError ? LOOKS.error : LOOKS[seen.mood]) ?? LOOKS.idle
57    // A border and a caption only where there is room for them.
58    const hasBorder = seen.rows >= look.body.length + 2 && seen.columns >= 14
59    const hasCaption = seen.rows >= look.body.length + (hasBorder ? 3 : 1)
60    const lines = look.body.map((line, i) => Text({ key: `${ID}-body-${i}`, color: look.colour, bold: true, children: line }))
61    return Box({
62      key: SCENE,
63      width: seen.columns,
64      height: seen.rows,
65      flexDirection: 'column',
66      alignItems: 'center',
67      justifyContent: 'center',
68      children: [
69        Box({
70          key: `${ID}-body`,
71          flexDirection: 'column',
72          alignItems: 'center',
73          paddingX: hasBorder ? 1 : 0,
74          ...(hasBorder ? { borderStyle: 'round', borderColor: look.glow } : {}),
75          children: lines,
76        }),
77        ...(hasCaption ? [Text({ key: `${ID}-caption`, color: look.glow, children: look.caption })] : []),
78      ],
79    })
80  }
81
82  // ── The contract: no need to change anything below ─────────────────────
83
84  /** Eidola's tree with this Engram's slot, wherever it is, filled; anything else as it came. */
85  const fill = (node: unknown, make: (columns: number, rows: number) => RenderElement): unknown => {
86    if (node === null || typeof node !== 'object') {
87      return node
88    }
89    const element = node as { props?: { key?: unknown; width?: unknown; height?: unknown }; children?: unknown }
90    if (element.props?.key === SLOT) {
91      return make(Number(element.props.width) || 20, Number(element.props.height) || 5)
92    }
93    if (Array.isArray(element.children)) {
94      return { ...element, children: element.children.map(child => fill(child, make)) }
95    }
96    return node
97  }
98
99  /** True when the tree holds this Engram's slot. */
100  const hasSlot = (node: unknown): boolean => {
101    if (node === null || typeof node !== 'object') {
102      return false
103    }
104    const element = node as { props?: { key?: unknown }; children?: unknown }
105    return element.props?.key === SLOT || (Array.isArray(element.children) && element.children.some(hasSlot))
106  }
107
108  type Handoff = { engram: string; columns: number; rows: number; mood: Mood; ink: number; inkAt: number }
109
110  // Announce: Eidola reads engram.json from this folder. Without Eidola there
111  // is nobody to announce to, and nothing else changes.
112  on('session.start', async ($, e, next) => {
113    try {
114      await $.eidola.announce({ root: $.plugin.root })
115    } catch {
116      // Eidola isn't installed: this plugin draws nothing.
117    }
118    return next(e)
119  })
120
121  // The pane and the band, one hook each, the same way. Beneath Eidola (this
122  // plugin loaded after it), Eidola asks with the hand-off on e.props.eidola.
123  // Above it (this plugin loaded first), Eidola's tree comes back from next
124  // with a slot to fill, and the state is read from Eidola's.
125  on('ui.render', { component: 'Pane', requestId: 'eidola' }, async ($, e, next) => {
126    const handoff = (e.props as { eidola?: Handoff }).eidola
127    if (handoff) {
128      if (handoff.engram !== ID) {
129        return next(e)
130      }
131      const now = await $.clock.now()
132      try {
133        return draw($.ui.resolve(e) as unknown as Els, { columns: handoff.columns, rows: handoff.rows, mood: handoff.mood, isError: handoff.ink > 0 && now - handoff.inkAt < ERROR_MS })
134      } catch (error) {
135        await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
136        return next(e)
137      }
138    }
139    const tree = await next(e)
140    if (!hasSlot(tree)) {
141      return tree
142    }
143    const { value: mood = 'idle' } = await $.state.get({ plugin: 'eidola', key: 'mood' })
144    const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
145    const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
146    const now = await $.clock.now()
147    const els = $.ui.resolve(e) as unknown as Els
148    try {
149      return fill(tree, (columns, rows) => draw(els, { columns, rows, mood, isError: ink > 0 && now - inkAt < ERROR_MS })) as RenderElement
150    } catch (error) {
151      await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
152      return tree
153    }
154  })
155
156  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
157    const handoff = (e.props as { eidola?: Handoff }).eidola
158    if (handoff) {
159      if (handoff.engram !== ID) {
160        return next(e)
161      }
162      const now = await $.clock.now()
163      try {
164        return draw($.ui.resolve(e) as unknown as Els, { columns: handoff.columns, rows: handoff.rows, mood: handoff.mood, isError: handoff.ink > 0 && now - handoff.inkAt < ERROR_MS })
165      } catch (error) {
166        await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
167        return next(e)
168      }
169    }
170    const tree = await next(e)
171    if (!hasSlot(tree)) {
172      return tree
173    }
174    const { value: mood = 'idle' } = await $.state.get({ plugin: 'eidola', key: 'mood' })
175    const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
176    const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
177    const now = await $.clock.now()
178    const els = $.ui.resolve(e) as unknown as Els
179    try {
180      return fill(tree, (columns, rows) => draw(els, { columns, rows, mood, isError: ink > 0 && now - inkAt < ERROR_MS })) as RenderElement
181    } catch (error) {
182      await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
183      return tree
184    }
185  })
186}
187