SLOPSHOPPER

goblin-chrome

Goblin-mode's face inside Claude Code: a goblin in the band that reacts to the session, hint lines in its voice, a mask over every question, frames by model…

newbandspinnerrowsguardprompt
★ 5v0.1.1no licenseupdated 2026-10-07JasonWarrenUK/goblin-mode/marketplace/goblin-chrome
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goblin-chrome
› fix the failing auth test and add an audit log call ⏺ 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. dOnE. 42s. tOoK 9 tHiNgS. bIt mE oNcE. ┏━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ᛟ ┃ ▣ client module ./idle.tsx ┃ ┃ ┗━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
┏━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ᛟ ┃ ▣ client module ./idle.tsx ┃ ┃ ┗━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Turn line
dOnE. 42s. tOoK 9 tHiNgS. bIt mE oNcE.
README

goblin-chrome

[!NOTE] A Claude Code mod from goblin-mode: goblin-mode's face inside the terminal. Pure chrome. It changes nothing Claude does.


1. Install

claude plugin marketplace add JasonWarrenUK/goblin-mode
claude plugin install goblin-chrome@goblin-mode

Needs Claude Code 2.1.287 or later. Draws in the terminal; the hooks run everywhere else and draw nothing.


2. What it draws

WhereWhat
The band above the promptThree ranges split by dividers. The tier's rune at the left; the goblin in the middle, pacing while nothing happens, sitting after five minutes, sleeping after ten, startling when a prompt goes in, watching while Claude works and answering a poke; its dialogue at the right.
The line that closes a turndOnE. 42s. tOoK 7 tHiNgS. bIt mE oNcE. with the real numbers.
The hint under the promptThe engine's line stays live; a tail in the goblin's register reacts to a fresh session, idling, an unsent draft and typing mid-turn.
Every question Claude asksA face above the dialog, expression by context: expectant, nervous after a failed tool, folded arms for an interrogation, grinning late at night.
The goblin's dialogueThe band's right-hand range, for six seconds at a time: a poke, a yawn, a subagent returning, a compaction, a /clear and the commit-msg hook bouncing a message.

The band and the question box take their frame from the model that served the last request, so a /model switch shows once a reply has come back from it: each model has its own frame and theme colour.

ModelRuneFrameColour
Fableᚠdouble line ╔═╗accent
Opusᛟheavy line ┏━┓accent-2
Sonnetᛊround ╭─╮info
Haikuᚺdotted, plus corners +┈+, dimmedok

A skill that pinned one model while another answered draws singleDouble in warn with both runes. The band is drawn by hand with tees where the dividers meet the rules; the question box uses the engine's own border, which has no dotted style, so Haiku's box there is plain ASCII. The pin is read from ~/.claude/skills/ first, then <project>/.claude/skills/; plugin skills carry no pin.

The day has nine states on the local clock, fading over ten minutes, shifted one row towards lively on a Friday from four, one towards tired on a Sunday and one towards tired once the session passes four hours. Colours come from the project's theme in .claude/themes/, the clod family when it has none.


3. Options

Set in /config or under pluginConfigs in settings.json.

OptionDefaultWhat it does
enabledtrueThe one switch. Off, every hook passes through and nothing draws.
audiofalsePlay fx/cackle.wav when the commit-msg hook bounces a message.
schedule04=bed,06=caught,08=grumpy,11=functional,14=slump,16=second,19=perking,22=prime,01=feralHH=state or HH:MM=state pairs, 24h local. A malformed schedule falls back to the default whole.
themeemptyA theme family in .claude/themes to dress in. Empty picks the only family there, else clod.

4. Files

FileHolds
hooks/register.tsxThe hooks module: every event handled and every drawing
hooks/idle.tsxThe idle goblin, a Client surface module with its own frame clock and pointer
hooks/goblin.tsThe question face as Raster cells, the poke lines, the heckles
hooks/day.tsThe schedule, the ladder, the offsets, the fade and each state's style
hooks/tier.tsModel id to tier, the skill pin, the frame per tier
hooks/theme.tsPalette resolution per library/references/theme-conventions.md
types/index.d.tsThe $.state contract: palette, day, frame, vitals, idle, readable by a plugin that lists this one under dependencies

Check it with claude plugin validate marketplace/goblin-chrome and claude plugin test marketplace/goblin-chrome; type-check with tsc -p marketplace/goblin-chrome once a session has laid the engine's types beside the manifest.

Source 9 files
hooks/register.tsx 383 lines
1// goblin-chrome: the hooks module. Every drawing reads the same state: the
2// palette (38), the day (37), the frame by tier (36), the vitals (18) and
3// the idle clock (28). The hint (27) and the question mask and heckles (29)
4// read it too. One switch in the options turns the lot off.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, RenderElement } from 'claude-code'
8
9import type { Day, Frame, Idle, Palette, Tier, Vitals } from '../types'
10import { mix, shade } from './colour'
11import { dayAt, parseSchedule, STYLE } from './day'
12import {
13	faceCells,
14	FACE_COLUMNS,
15	FACE_ROWS,
16	HECKLES,
17	POKE_LINES,
18	type Expression,
19} from './goblin'
20import { goblinCase, pick, seconds } from './text'
21import { CLOD, loadPalette, type Variant } from './theme'
22import { bandRule, DEFAULT_TIER, frameFor, glyphsFor, pinnedModel, tierOf } from './tier'
23
24const PLUGIN = 'goblin-chrome'
25
26const palette = atom({ plugin: 'goblin-chrome', key: 'palette' } as const, CLOD)
27const day = atom({ plugin: 'goblin-chrome', key: 'day' } as const, {
28	state: 'functional',
29	from: 'functional',
30	blend: 1,
31} as Day)
32const frame = atom({ plugin: 'goblin-chrome', key: 'frame' } as const, frameFor(DEFAULT_TIER, null, CLOD))
33const vitals = atom({ plugin: 'goblin-chrome', key: 'vitals' } as const, {
34	tools: 0,
35	errors: 0,
36	turnMs: 0,
37	turnTools: 0,
38	turnErrors: 0,
39	bounces: 0,
40} as Vitals)
41const idle = atom({ plugin: 'goblin-chrome', key: 'idle' } as const, {
42	lastPromptAt: 0,
43	lastTurnEndAt: 0,
44	sessionStartAt: 0,
45	isWorking: false,
46	draftSince: 0,
47	saying: '',
48	sayingUntil: 0,
49} as Idle)
50
51/** The accent as the day colours it: faded from the previous state's shade over ten minutes. */
52const accentOf = (p: Palette, d: Day): string =>
53	mix(shade(p.accent, STYLE[d.from].shade), shade(p.accent, STYLE[d.state].shade), d.blend)
54
55// Module-level because the validator holds `$` to top-level functions: these
56// are the helpers every hook shares, and the variables they read are set by
57// `register` from the options and by the hooks as the session goes. The tiers
58// the frame depends on live in the `frame` atom, which survives a hot reload.
59let schedule = parseSchedule('')
60let lastYawnAt = 0
61
62/** How long a line stays in the band's dialogue range. */
63const SAY_MS = 6_000
64
65/** The goblin speaks: the line shows in the band's dialogue range for a few seconds, then clears itself. */
66const say = async ($: EngineInterface, line: string): Promise<void> => {
67	const now = await $.clock.now()
68	const until = now + SAY_MS
69	await update($, idle, i => ({ ...i, saying: goblinCase(line), sayingUntil: until }))
70	$.clock.after(SAY_MS, () => {
71		void update($, idle, i => (i.sayingUntil === until ? { ...i, saying: '', sayingUntil: 0 } : i))
72	})
73}
74
75const refreshDay = async ($: EngineInterface): Promise<void> => {
76	const now = await $.clock.now()
77	const i = await read($, idle)
78	const next = dayAt(schedule, new Date(now), i.sessionStartAt ? now - i.sessionStartAt : 0)
79	await update($, day, () => next)
80	const style = STYLE[next.state]
81	if (style.yawnMs !== null && now - lastYawnAt > style.yawnMs && !i.isWorking) {
82		lastYawnAt = now
83		await say($, 'yawn.')
84	}
85}
86
87/** Redraw the frame from the atom's own tiers, with `patch` replacing whichever it names. */
88const refreshFrame = async ($: EngineInterface, patch: { served?: Tier; pinned?: Tier | null } = {}): Promise<void> => {
89	const p = await read($, palette)
90	await update($, frame, current =>
91		frameFor(patch.served ?? current.served, patch.pinned === undefined ? current.pinned : patch.pinned, p),
92	)
93}
94
95export const register: Register = (on, options) => {
96	if (options.enabled === false) return
97
98	schedule = parseSchedule(typeof options.schedule === 'string' ? options.schedule : '')
99	lastYawnAt = 0
100	const wantedTheme = typeof options.theme === 'string' ? options.theme : ''
101	const audio = options.audio === true
102
103	on('session.start', async ($, e, next) => {
104		const now = await $.clock.now()
105		lastYawnAt = now
106		await update($, idle, i => ({ ...i, sessionStartAt: now, lastTurnEndAt: now }))
107		let variant: Variant = 'dark'
108		try {
109			const row = (await $.config.list()).find(r => r.key === 'theme')
110			if (typeof row?.value === 'string' && /light/i.test(row.value)) variant = 'light'
111		} catch {
112			// no config to read; dark it is
113		}
114		const loaded = await loadPalette(
115			{
116				list: path => $.fs.list(path),
117				read: path => $.fs.read(path),
118				home: () => $.env.get('HOME'),
119			},
120			wantedTheme,
121			variant,
122		)
123		await update($, palette, () => loaded)
124		await refreshFrame($)
125		await refreshDay($)
126		$.clock.every(60_000, () => {
127			void refreshDay($)
128		})
129		$.clock.every(30_000, () => {
130			void update($, idle, i => ({ ...i }))
131		})
132		return next(e)
133	})
134
135	on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
136		const now = await $.clock.now()
137		await update($, idle, i => ({ ...i, sessionStartAt: now, lastTurnEndAt: now, isWorking: false }))
138		await update($, vitals, v => ({ ...v, tools: 0, errors: 0 }))
139		await refreshFrame($)
140		await refreshDay($)
141		return next(e)
142	})
143
144	on('prompt.submit', async ($, e, next) => {
145		const now = await $.clock.now()
146		await update($, idle, i => ({ ...i, lastPromptAt: now, draftSince: 0 }))
147		return next(e)
148	})
149
150	// A turn, not a submission: a dropped prompt never starts one.
151	on('turn.start', async ($, e, next) => {
152		await update($, idle, i => (i.isWorking ? i : { ...i, isWorking: true }))
153		await update($, vitals, v => ({ ...v, tools: 0, errors: 0 }))
154		return next(e)
155	})
156
157	// One write when a draft begins, one when it empties; nothing per keystroke.
158	on('prompt.edit', async ($, e, next) => {
159		const i = await read($, idle)
160		const isEmpty = e.text.length - (e.end - e.start) + e.inputText.length === 0
161		if (isEmpty && i.draftSince !== 0) await update($, idle, cur => ({ ...cur, draftSince: 0 }))
162		else if (!isEmpty && i.draftSince === 0) {
163			const now = await $.clock.now()
164			await update($, idle, cur => ({ ...cur, draftSince: now }))
165		}
166		return next(e)
167	})
168
169	on('tool.call', async ($, e, next) => {
170		if (!e.agentId) await update($, vitals, v => ({ ...v, tools: v.tools + 1 }))
171		const ran = await next(e)
172		if (e.agentId) return ran
173		if (ran.deny === undefined && ran.isError === true) {
174			await update($, vitals, v => ({ ...v, errors: v.errors + 1 }))
175		}
176		if (e.tool === 'Bash' && /\bgit\b[^|;&]*\bcommit\b/.test(e.command) && ran.deny === undefined) {
177			const text = typeof ran.text === 'string' ? ran.text : ''
178			const out = ran.result as { stderr?: unknown } | undefined
179			const stderr = typeof out?.stderr === 'string' ? out.stderr : ''
180			if (/commit-msg:/.test(stderr) || /commit-msg:/.test(text)) {
181				const v = await read($, vitals)
182				const bounces = v.bounces + 1
183				await update($, vitals, cur => ({ ...cur, bounces }))
184				await say($, bounces === 1 ? HECKLES.bounce : HECKLES.bounceAgain(bounces))
185				if (audio) void $.audio.play({ asset: 'fx/cackle.wav' }).catch(() => undefined)
186			}
187		}
188		return ran
189	})
190
191	on('turn.step', async function* ($, e, next) {
192		const result = yield* next(e)
193		if (!e.agentId && result.usage?.model) {
194			const tier = tierOf(result.usage.model)
195			if (tier !== (await read($, frame)).served) await refreshFrame($, { served: tier })
196		}
197		return result
198	})
199
200	on('skill.prompt', async ($, e, next) => {
201		let pinned: Tier | null = null
202		try {
203			// Personal shadows project, as Claude Code resolves a skill of the same name.
204			const home = await $.env.get('HOME')
205			const personal = home ? await $.fs.read(`${home}/.claude/skills/${e.skill}/SKILL.md`).catch(() => '') : ''
206			const text = personal || (await $.fs.read(`.claude/skills/${e.skill}/SKILL.md`))
207			const model = pinnedModel(text)
208			pinned = model ? tierOf(model) : null
209		} catch {
210			pinned = null
211		}
212		await refreshFrame($, { pinned })
213		return next(e)
214	})
215
216	on('turn.complete', async ($, e, next) => {
217		if (e.agentId) {
218			await say($, HECKLES.minionBack(e.durationMs))
219			return next(e)
220		}
221		const now = await $.clock.now()
222		await update($, idle, i => ({ ...i, lastTurnEndAt: now, isWorking: false }))
223		await update($, vitals, v => ({ ...v, turnMs: e.durationMs, turnTools: v.tools, turnErrors: v.errors }))
224		if ((await read($, frame)).pinned !== null) await refreshFrame($, { pinned: null })
225		return next(e)
226	})
227
228	on('session.compact', async ($, e, next) => {
229		if (!e.agentId) await say($, HECKLES.compaction)
230		return next(e)
231	})
232
233	on('command.run', { command: 'clear' }, async ($, e, next) => {
234		await say($, HECKLES.clear)
235		return next(e)
236	})
237
238	on('ui.message', async ($, e, next) => {
239		const data = e.data as { poke?: unknown } | null
240		if (data && data.poke === true) {
241			const now = await $.clock.now()
242			await say($, pick(POKE_LINES, Math.floor(now / 1000)))
243			return {}
244		}
245		return next(e)
246	})
247
248	// The band (18 and 28): three ranges split by dividers, the tier's rune, the
249	// idle goblin and the goblin's dialogue, framed by tier and coloured by the day.
250	on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
251		if (e.props.hasSurvey || e.surface !== 'terminal') return next(e)
252		const { Box, Text, Client } = $.ui.resolve(e)
253		const [p, d, f, i] = await Promise.all([read($, palette), read($, day), read($, frame), read($, idle)])
254		const now = await $.clock.now()
255		const accent = accentOf(p, d)
256		const since = Math.max(i.lastTurnEndAt, i.lastPromptAt, i.sessionStartAt)
257		const idleMs = i.isWorking || since === 0 ? 0 : Math.max(0, now - since)
258		const framed = e.props.maxRows >= 5
259		// The frame is drawn by hand so the dividers join the rules with tees: three ranges, each
260		// padded by a cell either side, so the rune sits centred in a range as wide as itself plus
261		// two (a mismatch such as `ᛊ→ᛟ` widens it). The room left for the goblin and its dialogue is
262		// the band less that range, the dividers, the other paddings and, when framed, the two outer
263		// edges. The dialogue takes under half, up to a long heckle's length.
264		const g = glyphsFor(f.borderStyle)
265		const runeRange = Array.from(f.rune).length + 2
266		const room = Math.max(0, e.props.bodyColumns - (framed ? 2 : 0) - 2 - runeRange - 4)
267		const sayingWidth = Math.min(42, Math.max(14, Math.floor(room * 0.45)))
268		const goblinWidth = Math.max(10, room - sayingWidth)
269		const ranges = [runeRange, goblinWidth + 2, sayingWidth + 2]
270		const saying = now < i.sayingUntil ? i.saying : ''
271		const bar = (
272			<Text color={f.borderColor} dimColor={f.borderDimColor}>
273				{g.vertical}
274			</Text>
275		)
276		return (
277			<Box flexDirection="column" width={e.props.bodyColumns}>
278				{framed && (
279					<Text color={f.borderColor} dimColor={f.borderDimColor} wrap="truncate-end">
280						{bandRule(f.borderStyle, 'top', ranges)}
281					</Text>
282				)}
283				<Box flexDirection="row" width={e.props.bodyColumns}>
284					{framed && bar}
285					<Box width={ranges[0]} paddingX={1}>
286						<Text color={f.borderColor} dimColor={f.borderDimColor}>
287							{f.rune}
288						</Text>
289					</Box>
290					{bar}
291					<Box width={ranges[1]} paddingX={1}>
292						<Client
293							key="goblin"
294							module="./idle.tsx"
295							width={goblinWidth}
296							props={{
297								mode: e.props.isWorking || i.isWorking ? 'working' : 'idle',
298								idleMs,
299								pace: STYLE[d.state].pace,
300								startleAt: i.lastPromptAt,
301								colour: accent,
302								dim: p.inkMuted,
303							}}
304						/>
305					</Box>
306					{bar}
307					<Box width={ranges[2]} paddingX={1}>
308						<Text color={accent} wrap="truncate-end">
309							{saying}
310						</Text>
311					</Box>
312					{framed && bar}
313				</Box>
314				{framed && (
315					<Text color={f.borderColor} dimColor={f.borderDimColor} wrap="truncate-end">
316						{bandRule(f.borderStyle, 'bottom', ranges)}
317					</Text>
318				)}
319			</Box>
320		)
321	})
322
323	// The line that closes a turn (18).
324	on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
325		if (e.surface !== 'terminal') return next(e)
326		const { Text } = $.ui.resolve(e)
327		const [p, v, d] = await Promise.all([read($, palette), read($, vitals), read($, day)])
328		const bits = [`done. ${seconds(e.props.durationMs)}.`]
329		// Only the row for the turn that just ended knows its counts; older rows keep the duration alone.
330		if (e.props.durationMs === v.turnMs) {
331			if (v.turnTools > 0) bits.push(`took ${v.turnTools} thing${v.turnTools === 1 ? '' : 's'}.`)
332			if (v.turnErrors > 0) bits.push(`bit me ${v.turnErrors === 1 ? 'once' : `${v.turnErrors} times`}.`)
333		}
334		return (
335			<Text color={accentOf(p, d)} dimColor>
336				{goblinCase(bits.join(' '))}
337			</Text>
338		)
339	})
340
341	// The hint under the prompt (27): the engine's line stays live, the tail is ours.
342	on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
343		const [d, i] = await Promise.all([read($, day), read($, idle)])
344		const now = await $.clock.now()
345		const since = Math.max(i.lastTurnEndAt, i.lastPromptAt, i.sessionStartAt)
346		const idleMs = since === 0 ? 0 : Math.max(0, now - since)
347		let line: string
348		if (e.props.isDraft && e.props.isWorking) line = 'queueing. patience.'
349		else if (e.props.isDraft && i.draftSince > 0 && now - i.draftSince > 60_000) line = 'type or regret'
350		else if (e.props.isDraft) line = pick(STYLE[d.state].hints, Math.floor(now / 60_000))
351		else if (i.lastPromptAt === 0) line = 'say the thing'
352		else if (!e.props.isWorking && idleMs > 2 * 60_000) line = 'still there?'
353		else line = pick(STYLE[d.state].hints, Math.floor(now / 60_000))
354		return next({ ...e, props: { ...e.props, tail: goblinCase(line) } })
355	})
356
357	// The question mask (29): a face above the engine's own dialog, kept once.
358	on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
359		const theirs = (await next(e)) as RenderElement
360		if (e.surface !== 'terminal') return theirs
361		const { Box, Text, Raster } = $.ui.resolve(e)
362		const [p, d, f, v] = await Promise.all([read($, palette), read($, day), read($, frame), read($, vitals)])
363		const questions = e.props.questions.length
364		const expression: Expression =
365			v.errors > 0 ? 'nervous' : d.state === 'prime' || d.state === 'feral' ? 'grin' : questions >= 3 ? 'folded' : 'expectant'
366		const lines: Record<Expression, string> = {
367			expectant: 'go on then.',
368			nervous: 'it went wrong and now it wants you.',
369			folded: 'an interrogation. lovely.',
370			grin: 'oh, it wants something.',
371		}
372		return (
373			<Box flexDirection="column">
374				<Box flexDirection="row" columnGap={1} borderStyle={f.borderStyle} borderColor={f.borderColor} borderDimColor={f.borderDimColor} paddingX={1}>
375					<Raster key="face" columns={FACE_COLUMNS} rows={FACE_ROWS} cells={faceCells(expression, p, STYLE[d.state].shade)} />
376					<Text color={accentOf(p, d)}>{goblinCase(lines[expression])}</Text>
377				</Box>
378				{theirs}
379			</Box>
380		)
381	})
382}
383
hooks/colour.ts 41 lines
1// Hex colour arithmetic for the chrome: blend two colours, shade one.
2// RGB space is enough for a terminal; nothing here is measured for contrast.
3
4const clamp = (n: number): number => Math.max(0, Math.min(255, Math.round(n)))
5
6export const parseHex = (hex: string): [number, number, number] | null => {
7	const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
8	if (!m || m[1] === undefined) return null
9	const n = parseInt(m[1], 16)
10	return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
11}
12
13export const toHex = (rgb: readonly [number, number, number]): string =>
14	'#' + rgb.map(c => clamp(c).toString(16).padStart(2, '0')).join('')
15
16/** `t` of the way from `a` to `b`; a bad hex on either side returns the other. */
17export const mix = (a: string, b: string, t: number): string => {
18	const pa = parseHex(a)
19	const pb = parseHex(b)
20	if (!pa) return b
21	if (!pb) return a
22	const k = Math.max(0, Math.min(1, t))
23	return toHex([
24		pa[0] + (pb[0] - pa[0]) * k,
25		pa[1] + (pb[1] - pa[1]) * k,
26		pa[2] + (pb[2] - pa[2]) * k,
27	])
28}
29
30/** Lighten (`delta` > 0) or darken (`delta` < 0) by a fraction of the way to white or black. */
31export const shade = (hex: string, delta: number): string =>
32	delta >= 0 ? mix(hex, '#ffffff', delta) : mix(hex, '#000000', -delta)
33
34/** The 24-bit integer a Raster cell takes, or the terminal's default for a bad hex. */
35export const RASTER_DEFAULT = 0x01000000
36
37export const rasterColour = (hex: string): number => {
38	const p = parseHex(hex)
39	return p ? (p[0] << 16) | (p[1] << 8) | p[2] : RASTER_DEFAULT
40}
41
hooks/day.ts 114 lines
1// The goblin's day (37): nine states on the local clock, read off a schedule
2// the person tunes in /config, shifted for Friday afternoons, Sundays and a
3// long session, with a ten-minute fade between states.
4
5import type { Day, DayState } from '../types'
6
7/** From most tired to most lively; the offsets walk this ladder. */
8export const LADDER: readonly DayState[] = [
9	'bed',
10	'caught',
11	'grumpy',
12	'slump',
13	'functional',
14	'second',
15	'perking',
16	'prime',
17	'feral',
18]
19
20export const DEFAULT_SCHEDULE = '04=bed,06=caught,08=grumpy,11=functional,14=slump,16=second,19=perking,22=prime,01=feral'
21
22export type Slot = { minute: number; state: DayState }
23
24const isState = (s: string): s is DayState => (LADDER as readonly string[]).includes(s)
25
26/** `HH=state` pairs, sorted by hour; a malformed schedule falls back whole to the default. */
27export const parseSchedule = (text: string): Slot[] => {
28	const slots: Slot[] = []
29	for (const part of text.split(',')) {
30		const m = /^\s*(\d{1,2})(?::(\d{2}))?\s*=\s*([a-z]+)\s*$/.exec(part)
31		if (!m || m[1] === undefined || m[3] === undefined || !isState(m[3])) {
32			return text === DEFAULT_SCHEDULE ? [] : parseSchedule(DEFAULT_SCHEDULE)
33		}
34		const hour = Number(m[1])
35		const minute = Number(m[2] ?? '0')
36		if (hour > 23 || minute > 59) return text === DEFAULT_SCHEDULE ? [] : parseSchedule(DEFAULT_SCHEDULE)
37		slots.push({ minute: hour * 60 + minute, state: m[3] })
38	}
39	return slots.sort((a, b) => a.minute - b.minute)
40}
41
42/** The slot in force at a minute of the day, and the one before it. */
43export const slotAt = (slots: readonly Slot[], minuteOfDay: number): { now: Slot; before: Slot; since: number } => {
44	const first = slots[0]
45	if (!first) {
46		const flat = { minute: 0, state: 'functional' as const }
47		return { now: flat, before: flat, since: 600 }
48	}
49	let index = -1
50	for (let i = 0; i < slots.length; i++) {
51		const slot = slots[i]
52		if (slot && slot.minute <= minuteOfDay) index = i
53	}
54	if (index === -1) index = slots.length - 1
55	const now = slots[index] ?? first
56	const before = slots[(index - 1 + slots.length) % slots.length] ?? first
57	const since = (minuteOfDay - now.minute + 1440) % 1440
58	return { now, before, since }
59}
60
61/** Move along the ladder, clamped at either end. */
62export const shift = (state: DayState, delta: number): DayState => {
63	const i = LADDER.indexOf(state)
64	const j = Math.max(0, Math.min(LADDER.length - 1, i + delta))
65	return LADDER[j] ?? state
66}
67
68/** The ladder offset for the calendar and the session's age. */
69export const offset = (date: Date, sessionAgeMs: number): number => {
70	let delta = 0
71	if (date.getDay() === 5 && date.getHours() >= 16) delta += 1
72	if (date.getDay() === 0) delta -= 1
73	if (sessionAgeMs > 4 * 3_600_000) delta -= 1
74	return delta
75}
76
77export const FADE_MINUTES = 10
78
79export const dayAt = (slots: readonly Slot[], date: Date, sessionAgeMs: number): Day => {
80	const { now, before, since } = slotAt(slots, date.getHours() * 60 + date.getMinutes())
81	const delta = offset(date, sessionAgeMs)
82	const state = shift(now.state, delta)
83	return {
84		state,
85		from: shift(before.state, delta),
86		blend: Math.min(1, since / FADE_MINUTES),
87	}
88}
89
90export type Pace = 'lie' | 'sway' | 'slow' | 'normal' | 'sit' | 'fast' | 'grin' | 'lively' | 'feral'
91
92export type DayStyle = {
93	/** Fraction towards white (positive) or black (negative) applied to the accent. */
94	shade: number
95	/** What the hint line says when nothing more specific applies. */
96	hints: readonly string[]
97	/** How the idle goblin carries itself. */
98	pace: Pace
99	/** How often it yawns at you, or never. */
100	yawnMs: number | null
101}
102
103export const STYLE: Record<DayState, DayStyle> = {
104	bed: { shade: -0.55, hints: ['no.', 'go to bed', 'bed.'], pace: 'lie', yawnMs: null },
105	caught: { shade: -0.3, hints: ['...', 'what time is it', 'hm.'], pace: 'sway', yawnMs: 240_000 },
106	grumpy: { shade: -0.15, hints: ['what.', 'say it', 'hm'], pace: 'slow', yawnMs: null },
107	functional: { shade: 0, hints: ['say the thing', 'go on', 'well?'], pace: 'normal', yawnMs: null },
108	slump: { shade: -0.1, hints: ['sure', 'later', '*yawn*'], pace: 'sit', yawnMs: 300_000 },
109	second: { shade: 0.05, hints: ['right then', 'quicker', 'go on, say it'], pace: 'fast', yawnMs: null },
110	perking: { shade: 0.1, hints: ['oh good', 'tell me', 'yes?'], pace: 'grin', yawnMs: null },
111	prime: { shade: 0.2, hints: ['say the thing. properly.', 'you took your time', 'and?'], pace: 'lively', yawnMs: null },
112	feral: { shade: 0.25, hints: ['heh', 'say it. SAY IT.', 'what could go wrong'], pace: 'feral', yawnMs: 180_000 },
113}
114
hooks/goblin.ts 48 lines
1// The goblin's face as Raster cells: the mask over a question (29). Every
2// glyph is one cell wide and in the BMP, as RasterProps demands.
3
4import { rasterColour, RASTER_DEFAULT, shade } from './colour'
5import { packCells, type Cell } from './text'
6import type { Palette } from '../types'
7
8export type Expression = 'expectant' | 'nervous' | 'folded' | 'grin'
9
10// A goblin head: ears out to the sides, up when it is alert and drooping when
11// it is not, a fang in the mouth when it is pleased.
12const FACES: Record<Expression, readonly [string, string, string]> = {
13	expectant: ['╲╭───╮╱', ' │o o│ ', ' ╰─v─╯ '],
14	nervous: [" ╭───╮'", '╱│o O│╲', ' ╰─~─╯ '],
15	folded: [' ╭───╮ ', '╱│- -│╲', ' ╰─_─╯ '],
16	grin: ['╲╭───╮╱', ' │^ ^│ ', ' ╰vVv╯ '],
17}
18
19export const FACE_COLUMNS = 7
20export const FACE_ROWS = 3
21
22/** The face's cells: the outline in the palette's ink, the features in its accent. */
23export const faceCells = (expression: Expression, palette: Palette, tint: number = 0): string => {
24	const outline = rasterColour(palette.ink)
25	const feature = rasterColour(shade(palette.accent, tint))
26	const rows = FACES[expression].map(row =>
27		Array.from(row).map((ch): Cell => [ch, /[│╭╮╰╯─╱╲]/.test(ch) ? outline : feature]),
28	)
29	return packCells(rows, FACE_COLUMNS, RASTER_DEFAULT)
30}
31
32export const POKE_LINES = [
33	'ow.',
34	'what.',
35	'do that again and see.',
36	'I was resting.',
37	'type something instead.',
38	'this is not a petting zoo.',
39] as const
40
41export const HECKLES = {
42	minionBack: (ms: number) => `minion back. took ${Math.round(ms / 1000)}s. unimpressed.`,
43	compaction: 'memory wiped. going again.',
44	clear: 'fine.',
45	bounce: 'the hook bounced that message. told you.',
46	bounceAgain: (n: number) => `bounced again. that is ${n} so far.`,
47} as const
48
hooks/text.ts 71 lines
1// Small text and byte helpers shared by the drawings.
2
3/** aLtErNaTiNg CaPs, the house spinner style: each word starts lower, letters alternate, everything else stays. */
4export const goblinCase = (text: string): string => {
5	let upper = false
6	let out = ''
7	for (const ch of text) {
8		if (/[a-z]/i.test(ch)) {
9			out += upper ? ch.toUpperCase() : ch.toLowerCase()
10			upper = !upper
11		} else {
12			out += ch
13			if (/\s/.test(ch)) upper = false
14		}
15	}
16	return out
17}
18
19const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
20
21/** Standard padded base64 of bytes; the environment has no Buffer. */
22export const base64 = (bytes: Uint8Array): string => {
23	let out = ''
24	for (let i = 0; i < bytes.length; i += 3) {
25		const a = bytes[i] ?? 0
26		const b = bytes[i + 1]
27		const c = bytes[i + 2]
28		const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
29		out += B64[(n >> 18) & 63]
30		out += B64[(n >> 12) & 63]
31		out += b === undefined ? '=' : B64[(n >> 6) & 63]
32		out += c === undefined ? '=' : B64[n & 63]
33	}
34	return out
35}
36
37export type Cell = readonly [char: string, fg: number, bg?: number]
38
39/**
40 * Pack rows of cells into a Raster's `cells`: little-endian u32 triplets of
41 * code point, foreground and background, row-major. Every row is padded or
42 * cut to `columns` so the grid is always `columns * rows`.
43 */
44export const packCells = (rows: readonly (readonly Cell[])[], columns: number, blank: number): string => {
45	const words = new Uint32Array(columns * rows.length * 3)
46	let w = 0
47	for (const row of rows) {
48		for (let x = 0; x < columns; x++) {
49			const cell = row[x]
50			words[w++] = cell ? cell[0].codePointAt(0) ?? 32 : 32
51			words[w++] = cell ? cell[1] : blank
52			words[w++] = cell?.[2] ?? blank
53		}
54	}
55	return base64(new Uint8Array(words.buffer))
56}
57
58/** Pick one line from a list by a counter, so the same minute shows the same line. */
59export const pick = <T,>(items: readonly T[], n: number): T => {
60	const item = items[Math.abs(Math.floor(n)) % items.length]
61	if (item === undefined) throw new Error('pick: empty list')
62	return item
63}
64
65export const seconds = (ms: number): string => {
66	const s = Math.round(ms / 1000)
67	if (s < 60) return `${s}s`
68	const m = Math.floor(s / 60)
69	return `${m}m ${s - m * 60}s`
70}
71
hooks/theme.ts 120 lines
1// Dressed for the project (38): the palette every drawing takes its colours
2// from. Resolution follows library/references/theme-conventions.md: a family
3// named in the options, else the only core in the project's .claude/themes,
4// else the global clod family, else the clod values baked in here.
5
6import type { Palette } from '../types'
7
8export type Variant = 'light' | 'dark'
9
10/** The clod family's light variant, for a light terminal theme. */
11export const CLOD_LIGHT: Palette = {
12	family: 'clod',
13	ink: '#1E232B',
14	inkMuted: '#555D69',
15	surface: '#F7F3EA',
16	surfaceRaised: '#FFFFFF',
17	line: '#D8D1C3',
18	accent: '#7A540C',
19	accentInk: '#FFF8E6',
20	accent2: '#1C6E65',
21	ok: '#2E7D32',
22	warn: '#8A5A00',
23	danger: '#B3412F',
24	info: '#2F6FA8',
25}
26
27/** The clod family's dark variant, so the chrome draws before any file is read. */
28export const CLOD: Palette = {
29	family: 'clod',
30	ink: '#EDE7DA',
31	inkMuted: '#A39C8E',
32	surface: '#14181F',
33	surfaceRaised: '#1D232C',
34	line: '#2E3640',
35	accent: '#D9A441',
36	accentInk: '#1A1408',
37	accent2: '#4FB3A8',
38	ok: '#6FBF73',
39	warn: '#E0A83A',
40	danger: '#E06C5B',
41	info: '#6FA8DC',
42}
43
44const SWATCHES: Record<Exclude<keyof Palette, 'family'>, string> = {
45	ink: 'ink',
46	inkMuted: 'ink-muted',
47	surface: 'surface',
48	surfaceRaised: 'surface-raised',
49	line: 'line',
50	accent: 'accent',
51	accentInk: 'accent-ink',
52	accent2: 'accent-2',
53	ok: 'ok',
54	warn: 'warn',
55	danger: 'danger',
56	info: 'info',
57}
58
59const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
60
61/** A core theme file's variant as a Palette, or null when the file is not a core. */
62export const paletteFrom = (core: unknown, variant: Variant = 'dark'): Palette | null => {
63	if (!isRecord(core) || !isRecord(core.palette) || typeof core.family !== 'string') return null
64	const palette = core.palette
65	const out: Record<string, string> = { family: core.family }
66	for (const [key, swatch] of Object.entries(SWATCHES)) {
67		const entry = palette[swatch]
68		const hex = isRecord(entry) ? entry[variant] : undefined
69		if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return null
70		out[key] = hex
71	}
72	return out as Palette
73}
74
75/** Core files in a themes directory: `<family>.json`, never a hyphenated target; a symlink counts. */
76export const coreNames = (entries: readonly { name: string; kind: string; isLink?: boolean }[]): string[] =>
77	entries
78		.filter(e => (e.kind === 'file' || e.isLink === true) && /^[a-z0-9]+\.json$/.test(e.name))
79		.map(e => e.name.slice(0, -5))
80		.sort()
81
82type Reader = {
83	list: (path: string) => Promise<readonly { name: string; kind: string; isLink?: boolean }[]>
84	read: (path: string) => Promise<string>
85	home: () => Promise<string | undefined>
86}
87
88/**
89 * Resolve the palette for this session. `wanted` is the family the options
90 * name, '' for none. Every failure falls through to the next source and the
91 * last source is the baked-in clod, so this never throws.
92 */
93export const loadPalette = async (reader: Reader, wanted: string, variant: Variant = 'dark'): Promise<Palette> => {
94	const tryRead = async (path: string): Promise<Palette | null> => {
95		try {
96			return paletteFrom(JSON.parse(await reader.read(path)), variant)
97		} catch {
98			return null
99		}
100	}
101	try {
102		const families = coreNames(await reader.list('.claude/themes'))
103		const family = wanted ? (families.includes(wanted) ? wanted : null) : families.length === 1 ? families[0] : null
104		if (family) {
105			const found = await tryRead(`.claude/themes/${family}.json`)
106			if (found) return found
107		}
108	} catch {
109		// no project themes directory
110	}
111	const home = await reader.home().catch(() => undefined)
112	if (home) {
113		for (const name of new Set([wanted || 'clod', 'clod'])) {
114			const global = await tryRead(`${home}/.claude/library/themes/${name}.json`)
115			if (global) return global
116		}
117	}
118	return variant === 'light' ? CLOD_LIGHT : CLOD
119}
120
hooks/tier.ts 88 lines
1// Borders by tier (36): the frame every mod-drawn box takes from the model
2// that served the current request, and the rune of the tier a skill pinned.
3
4import type { Frame, Palette, Tier } from '../types'
5
6/** The runic glyph convention from clod-config-skill_conventions: fehu for Fable, othala for Opus. */
7export const RUNE: Record<Tier, string> = { top: 'ᛟ', mid: 'ᛊ', small: 'ᚺ', fable: 'ᚠ' }
8
9/** Which tier a model id or alias belongs to; unknown ids count as the middle. */
10export const tierOf = (model: string): Tier => {
11	const m = model.toLowerCase()
12	if (/haiku/.test(m)) return 'small'
13	if (/sonnet/.test(m)) return 'mid'
14	if (/fable/.test(m)) return 'fable'
15	if (/opus|mythos/.test(m)) return 'top'
16	return 'mid'
17}
18
19/** The `model:` line of a skill's frontmatter, or null when it has none. */
20export const pinnedModel = (skillMarkdown: string): string | null => {
21	const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMarkdown)
22	if (!fm || fm[1] === undefined) return null
23	const line = /^model:\s*["']?([^\s#"']+)/m.exec(fm[1])
24	const model = line?.[1] ?? null
25	return model === null || model === 'inherit' ? null : model
26}
27
28const STYLE: Record<Tier, { borderStyle: string; dim: boolean }> = {
29	top: { borderStyle: 'bold', dim: false },
30	fable: { borderStyle: 'double', dim: false },
31	mid: { borderStyle: 'round', dim: false },
32	small: { borderStyle: 'classic', dim: true },
33}
34
35/** Which palette colour each tier's frame takes: the theme's two accents, then its info and ok hues. `warn` is kept for the mismatch frame and `danger` for errors. */
36const TIER_COLOUR: Record<Tier, 'accent' | 'accent2' | 'info' | 'ok'> = {
37	fable: 'accent',
38	top: 'accent2',
39	mid: 'info',
40	small: 'ok',
41}
42
43export const frameFor = (served: Tier, pinned: Tier | null, palette: Palette): Frame => {
44	const mismatch = pinned !== null && pinned !== served
45	const style = STYLE[served]
46	return {
47		borderStyle: mismatch ? 'singleDouble' : style.borderStyle,
48		borderColor: mismatch ? palette.warn : palette[TIER_COLOUR[served]],
49		borderDimColor: style.dim,
50		rune: mismatch ? `${RUNE[pinned]}→${RUNE[served]}` : RUNE[served],
51		served,
52		pinned,
53	}
54}
55
56export const DEFAULT_TIER: Tier = 'top'
57
58/** The box-drawing set of each border style the frames use, with the tees where a divider meets the rules. */
59export type Glyphs = {
60	topLeft: string
61	topRight: string
62	bottomLeft: string
63	bottomRight: string
64	horizontal: string
65	vertical: string
66	teeDown: string
67	teeUp: string
68}
69
70export const GLYPHS: Record<string, Glyphs> = {
71	double: { topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', horizontal: '═', vertical: '║', teeDown: '╦', teeUp: '╩' },
72	bold: { topLeft: '┏', topRight: '┓', bottomLeft: '┗', bottomRight: '┛', horizontal: '━', vertical: '┃', teeDown: '┳', teeUp: '┻' },
73	round: { topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯', horizontal: '─', vertical: '│', teeDown: '┬', teeUp: '┴' },
74	// Haiku: plus corners and tees, dotted runs, dotted bars.
75	classic: { topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+', horizontal: '┈', vertical: '┊', teeDown: '+', teeUp: '+' },
76	singleDouble: { topLeft: '╓', topRight: '╖', bottomLeft: '╙', bottomRight: '╜', horizontal: '─', vertical: '║', teeDown: '╥', teeUp: '╨' },
77}
78
79/** The glyphs for a frame's border style; an unknown style draws as Haiku's plain frame. */
80export const glyphsFor = (borderStyle: string): Glyphs => GLYPHS[borderStyle] ?? GLYPHS.classic!
81
82/** The top or bottom rule of a band split into ranges of `widths` cells: a tee wherever a divider meets it. */
83export const bandRule = (borderStyle: string, edge: 'top' | 'bottom', widths: readonly number[]): string => {
84	const g = glyphsFor(borderStyle)
85	const [left, right, tee] = edge === 'top' ? [g.topLeft, g.topRight, g.teeDown] : [g.bottomLeft, g.bottomRight, g.teeUp]
86	return left + widths.map(w => g.horizontal.repeat(w)).join(tee) + right
87}
88
hooks/idle.tsx 160 lines
1// The idle goblin (28): a Client surface module that animates in the band.
2// It paces while nothing happens, sits after five minutes, sleeps after ten,
3// startles when a prompt goes in, watches while Claude works, and can be
4// poked with the pointer. No `$` here: the hooks module hands it props and
5// reads its posts.
6//
7// Every change of state happens on the frame clock or on a pointer event,
8// never while drawing: a draw that wrote state would schedule another draw
9// that wrote again, and the engine unmounts a module that does that.
10
11import type { ClientModule } from 'claude-code'
12
13export type IdleProps = {
14	mode: 'idle' | 'working'
15	idleMs: number
16	pace: 'lie' | 'sway' | 'slow' | 'normal' | 'sit' | 'fast' | 'grin' | 'lively' | 'feral'
17	startleAt: number
18	colour: string
19	dim: string
20}
21
22type Pose = 'pace' | 'sit' | 'sleep' | 'startle' | 'watch' | 'poked' | 'lie'
23
24type State = {
25	tick: number
26	x: number
27	dir: 1 | -1
28	seenStartle: number
29	pokedUntil: number
30	startledUntil: number
31}
32
33const SIT_AFTER = 5 * 60_000
34const SLEEP_AFTER = 10 * 60_000
35export const TICK_MS = 120
36
37/** Milliseconds per step of the walk, by the day's pace. */
38export const STEP: Record<IdleProps['pace'], number> = {
39	lie: 2_000,
40	sway: 900,
41	slow: 700,
42	normal: 420,
43	sit: 600,
44	fast: 280,
45	grin: 320,
46	lively: 220,
47	feral: 140,
48}
49
50const SPRITES = {
51	right: '(ಠ_ಠ)>',
52	left: '<(ಠ_ಠ)',
53	sit: '(ಠ‿ಠ)  ',
54	sleep: ['(-_-) z ', '(-_-) zZ', '(-_-)zZz'],
55	startle: '(ಠoಠ)! ',
56	watch: ['(ಠ_ಠ)  ', '(ಠ_ಠ)  ', '(-_-)  '],
57	poked: '(ಠ_ಠ)  ',
58	lie: '_(-_-)_',
59	sway: ['(ಠ_ಠ)~', '~(ಠ_ಠ)'],
60} as const
61
62const WIDTH = 8
63
64const poseFor = (props: IdleProps, state: State, now: number): Pose => {
65	if (state.pokedUntil > now) return 'poked'
66	if (state.startledUntil > now) return 'startle'
67	if (props.mode === 'working') return 'watch'
68	if (props.pace === 'lie') return 'lie'
69	if (props.idleMs >= SLEEP_AFTER) return 'sleep'
70	if (props.idleMs >= SIT_AFTER || props.pace === 'sit') return 'sit'
71	return 'pace'
72}
73
74/** Whether a step of `stepMs` falls in the tick that ends at `now`. */
75export const stepsAt = (now: number, stepMs: number): boolean =>
76	now > 0 && Math.floor(now / stepMs) !== Math.floor((now - TICK_MS) / stepMs)
77
78/** One tick of the clock: the next state from the last, the props as last drawn and the room. */
79export const advance = (state: State, props: IdleProps, columns: number): State => {
80	const tick = state.tick + 1
81	const now = tick * TICK_MS
82	let { seenStartle, startledUntil, x, dir } = state
83	if (props.startleAt > seenStartle) {
84		seenStartle = props.startleAt
85		startledUntil = now + 1_200
86	}
87	const next: State = { ...state, tick, seenStartle, startledUntil }
88	if (poseFor(props, next, now) === 'pace' && stepsAt(now, STEP[props.pace])) {
89		const room = Math.max(0, columns - WIDTH)
90		x += dir
91		if (x >= room) {
92			x = room
93			dir = -1
94		}
95		if (x <= 0) {
96			x = 0
97			dir = 1
98		}
99	}
100	return { ...next, x, dir }
101}
102
103// The props as last drawn, for the tick to read; a module variable is fine
104// here, since a reload of the plugin drops this environment with its timer.
105let latest: IdleProps | null = null
106
107const Goblin: ClientModule<IdleProps, State> = (props, surface) => {
108	latest = props
109	const { Box, Text } = surface.elements
110	let state = surface.state
111	if (state === undefined) {
112		const initial: State = { tick: 0, x: 0, dir: 1, seenStartle: props.startleAt, pokedUntil: 0, startledUntil: 0 }
113		surface.every(TICK_MS, () => {
114			surface.setState(advance(surface.state ?? initial, latest ?? props, surface.columns))
115		})
116		surface.onPointer(e => {
117			if (e.type !== 'down') return
118			const s = surface.state ?? initial
119			surface.setState({ ...s, pokedUntil: s.tick * TICK_MS + 1_500 })
120			surface.post({ poke: true })
121		})
122		// Persist at once, so a second draw before the first tick starts nothing twice.
123		surface.setState(initial)
124		state = initial
125	}
126	const now = state.tick * TICK_MS
127	const pose = poseFor(props, state, now)
128	const beat = Math.floor(now / 600)
129	const sprite =
130		pose === 'sit'
131			? SPRITES.sit
132			: pose === 'sleep'
133				? SPRITES.sleep[beat % SPRITES.sleep.length] ?? SPRITES.sleep[0]
134				: pose === 'startle'
135					? SPRITES.startle
136					: pose === 'watch'
137						? SPRITES.watch[beat % SPRITES.watch.length] ?? SPRITES.watch[0]
138						: pose === 'poked'
139							? SPRITES.poked
140							: pose === 'lie'
141								? SPRITES.lie
142								: props.pace === 'sway'
143									? SPRITES.sway[beat % SPRITES.sway.length] ?? SPRITES.sway[0]
144									: state.dir === 1
145										? SPRITES.right
146										: SPRITES.left
147	const room = Math.max(0, surface.columns - WIDTH)
148	const left = pose === 'pace' ? Math.min(state.x, room) : pose === 'watch' ? room : 0
149	return (
150		<Box flexDirection="row" width={surface.columns || WIDTH}>
151			{left > 0 && <Text>{' '.repeat(left)}</Text>}
152			<Text color={pose === 'sleep' || pose === 'lie' ? props.dim : props.colour} bold={pose === 'startle' || pose === 'poked'}>
153				{sprite}
154			</Text>
155		</Box>
156	)
157}
158
159export default Goblin
160
types/index.d.ts 81 lines
1// The state contract for goblin-chrome: every value the module keeps in
2// `$.state`, declared so `claude plugin validate` can hold the module to it
3// and a plugin that lists goblin-chrome under `dependencies` can read the
4// frame and palette it publishes.
5
6export type Tier = 'top' | 'mid' | 'small' | 'fable'
7
8export type DayState =
9	| 'bed'
10	| 'caught'
11	| 'grumpy'
12	| 'functional'
13	| 'slump'
14	| 'second'
15	| 'perking'
16	| 'prime'
17	| 'feral'
18
19export type Palette = {
20	family: string
21	ink: string
22	inkMuted: string
23	surface: string
24	surfaceRaised: string
25	line: string
26	accent: string
27	accentInk: string
28	accent2: string
29	ok: string
30	warn: string
31	danger: string
32	info: string
33}
34
35export type Day = {
36	state: DayState
37	from: DayState
38	blend: number
39}
40
41export type Frame = {
42	borderStyle: string
43	borderColor: string
44	borderDimColor: boolean
45	rune: string
46	served: Tier
47	pinned: Tier | null
48}
49
50export type Vitals = {
51	tools: number
52	errors: number
53	turnMs: number
54	turnTools: number
55	turnErrors: number
56	bounces: number
57}
58
59export type Idle = {
60	lastPromptAt: number
61	lastTurnEndAt: number
62	sessionStartAt: number
63	isWorking: boolean
64	draftSince: number
65	/** What the goblin last said, shown in the band's dialogue range until `sayingUntil`. */
66	saying: string
67	sayingUntil: number
68}
69
70declare module 'claude-code' {
71	interface PluginState {
72		'goblin-chrome': {
73			palette: Palette
74			day: Day
75			frame: Frame
76			vitals: Vitals
77			idle: Idle
78		}
79	}
80}
81