SLOPSHOPPER

broken-moth

Broken Moth, an Engram for Eidola.

panebandprompt
A shopper browsing a rack in a slop shop
README

Broken Moth

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