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

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>.
hooks/register.tsx 187 lines1// 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