Restyle Claude Code from /clawdify: spinner, turn footer, prompt hint, banner, status line, transcript rows and presets, or just describe what you want.


Restyle Claude Code from inside Claude Code. A mod that lets you change the spinner, the turn footer, the prompt hint, a banner above the prompt, the row under it, and how transcript rows look. It never touches Claude's system prompt. Use the /clawdify pane, the subcommands, or just say what you want:
/clawdify make it feel like a submarine
/clawdify green hacker footer, hide the startup notices
Needs a Claude Code build with mods (2.1.287 or later).
/plugin marketplace add viik2k/clawdify
/plugin install clawdify@clawdify
Or run it from a clone: claude --plugin-dir path/to/clawdify.
| Command | What it does |
|---|---|
/clawdify | Open the editor pane (keys 1–7 switch tabs) |
/clawdify <anything> | Describe what you want; Claude picks the settings |
/clawdify get | List what you changed |
/clawdify set <key> <value> | Change one setting (empty value = default) |
/clawdify preset <name> | Layer a preset on top: clawd, pirate, hacker, zen, minimal, or any friend by name (campfire, duck, binary-rain, …). One you saved brings back exactly that look instead |
/clawdify save <name> | Save your current look as your own preset (also on the pane's presets tab) |
/clawdify delete <name> | Delete a preset you saved |
/clawdify reset [key] | Back to Claude Code defaults |
/clawdify export / import <json> | Share settings as JSON |
/clawdify reload | Pick up edits made to the saved settings file |
Settings persist across sessions and /clear. Empty means Claude Code's own behaviour, so nothing changes until you set something.
They're saved as the settings object in ~/.claude/plugins/store/clawdify_*.json. Edit that file by hand, then run /clawdify reload.
| Tab | Keys |
|---|---|
| spinner | spinnerVerbs, spinnerThinking, spinnerTools, spinnerResponding, spinnerSuffix |
| turn | doneVerbs, doneTemplate, doneColor, doneToastSecs |
| prompt | footer (your own row under the prompt), footerColor, hint, hintTail, modeLabel, backgroundHint (none hides it) |
| banner | banner, bannerColor, bannerBorder, bannerAlign, mascot (still or animated Clawd, or a stock loop), mascotColor |
| transcript | userPrefix, userColor, replyRewrites, expandToolGroups, hideNotices |
Clawing, Scuttling, Pinching.mascot: scuttle, hop, wave, cheer, think, snooze, peek, idle. The loop plays while Claude works; idle, Clawd blinks and glances about.snail, bat, spider, bunny, dog, penguin, snake, jellyfish, duck, turtle, hourglass, plant, campfire, rain, moon, equalizer, heartbeat, screensaver, binary-rain, train. Same deal: the loop plays while Claude works, and idle it holds still.mascot). Want him elsewhere? {clawd} is a one-row Clawd (▐▛███▜▌), animated in spinnerSuffix; the footer never draws him.{model}, {cwd}, {path}, {time}, {date}; doneTemplate also takes {word}, and its {time} is the turn's length.footer also takes {branch}, {context}, {ctxbar}, {5h}, {7d}, {cost}; split it with · and percentages turn amber past 60 and rust past 85.green, magenta) or #rrggbb. An unknown colour makes Claude Code draw that element normally.replyRewrites is display-only find/replace on Claude's replies: you=>ye; /\bhello\b/gi=>ahoy./clawdify <anything> makes one small Sonnet call on your account. Its answer is filtered to known settings before anything is saved.hideNotices only hides the dim lines under it.claude plugin validate .
claude plugin test .
MIT licensed.
hooks/register.tsx 505 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ClawdifySettings } from '../types'
5import { DEFAULTS, FIELDS, PRESETS, PRESET_NAME, TABS, NO_USAGE, brief, unclawd, changed, clawd, clean, cleanSaved, duration, fill, heat, isKey, list, parseChange, pick, rewrite, scuttle, tinyClawd } from './settings'
6import type { Key, Saved, Tab } from './settings'
7import { CRABS, FRIENDS, crabFrame } from './crabs'
8
9const PANE = 'clawdify'
10const SETTINGS = { plugin: 'clawdify', key: 'settings' } as const
11
12const settings = atom(SETTINGS, DEFAULTS)
13// The user's own presets; the store is the truth, this copy only redraws the pane.
14const saved = atom({ plugin: 'clawdify', key: 'presets' } as const, {} as Saved)
15const tab = atom({ plugin: 'clawdify', key: 'tab' } as const, 'spinner')
16const CONTEXT = { plugin: 'clawdify', key: 'context' } as const
17const context = atom(CONTEXT, { cwd: '', model: '', now: 0, ...NO_USAGE })
18const frame = atom({ plugin: 'clawdify', key: 'frame' } as const, 0)
19
20const SPINNER_FIELD: Record<string, Key> = {
21 thinking: 'spinnerThinking',
22 'tool-input': 'spinnerTools',
23 'tool-use': 'spinnerTools',
24 requesting: 'spinnerResponding',
25 responding: 'spinnerResponding',
26}
27
28const USAGE = [
29 '/clawdify open the editor pane',
30 '/clawdify get list what you changed',
31 '/clawdify set <key> <value> change one setting (empty value = default)',
32 '/clawdify preset <name> apply a preset on top: ' + Object.keys(PRESETS).join(', ') + ', or one you saved',
33 '/clawdify save <name> save your current look as a preset of your own',
34 '/clawdify delete <name> delete a preset you saved',
35 '/clawdify reset [key] back to Claude Code defaults',
36 '/clawdify export copy your settings as JSON',
37 '/clawdify import <json> load settings from JSON',
38 '/clawdify reload pick up edits made to the saved settings file',
39 '/clawdify <anything else> say what you want ("make it feel like a submarine"); Claude sets it',
40 '',
41 'Keys: ' + FIELDS.map(f => f.key).join(', '),
42 'Template tokens: {model} {cwd} {path} {time} {date}; footer also {word} {time} = turn length.',
43].join('\n')
44
45type $ = EngineInterface
46
47// The store is the truth; $.state is this session's copy. A /clear starts a new session with no
48// session.start, so the copy is empty until ensure() refills it, and readers fall back to the store meanwhile.
49const stored = async ($: $) => ({ ...DEFAULTS, ...clean(await $.store.get('settings')) })
50const storedPresets = async ($: $) => cleanSaved(await $.store.get('presets'))
51
52const current = async ($: $) => {
53 const { value, version } = await $.state.get(SETTINGS)
54 return version && value ? value : stored($)
55}
56
57// Each install (marketplace, --plugin-dir, dev mod) gets its own store file. An empty one adopts the
58// newest sibling clawdify_*.json once, so settings made under another install carry over.
59const adopt = async ($: $) => {
60 if ((await $.store.get('settings')) !== undefined) return
61 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
62 const dir = `${(await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`}/plugins/store`
63 const files = (await $.fs.list(dir).catch(() => []))
64 .filter(f => f.kind === 'file' && /^clawdify_.*\.json$/.test(f.name))
65 .sort((a, b) => b.mtimeMs - a.mtimeMs)
66 for (const f of files) {
67 const file: unknown = await $.fs.read(`${dir}/${f.name}`).then(text => JSON.parse(text)).catch(() => undefined)
68 const found = file && typeof file === 'object' ? file as Record<string, unknown> : {}
69 if (!found.settings || typeof found.settings !== 'object') continue
70 await $.store.set('settings', clean(found.settings))
71 if (found.presets) await $.store.set('presets', cleanSaved(found.presets))
72 return
73 }
74}
75
76const load = async ($: $) => {
77 const s = await stored($)
78 const p = await storedPresets($)
79 await update($, saved, () => p)
80 return update($, settings, () => s)
81}
82
83let cwd = ''
84
85const ensure = async ($: $) => {
86 if (!(await $.state.get(SETTINGS)).version) await load($)
87 if (!(await $.state.get(CONTEXT)).version) {
88 const model = await $.session.model().catch(() => '')
89 await update($, context, () => ({ cwd, model, now: 0, ...NO_USAGE }))
90 }
91}
92
93// Context fill, rate limits and cost as the status line has them, and the branch from .git/HEAD.
94const refreshUsage = async ($: $) => {
95 const old = (await $.state.get(CONTEXT)).value
96 if (!old) return
97 const usage = await $.session.usage().catch(() => undefined)
98 const limit = (kind: string) => usage?.rateLimits.find(r => r.kind === kind)?.percentUsed ?? -1
99 // ponytail: reads <cwd>/.git/HEAD only; a subfolder or a linked worktree shows no branch.
100 const head = await $.fs.read(`${old.cwd}/.git/HEAD`).catch(() => '')
101 const branch = /^ref: refs\/heads\/(.+)$/m.exec(head)?.[1] ?? head.trim().slice(0, 7)
102 const next = { branch, context: usage?.context.percent ?? -1, limit5h: limit('five_hour'), limit7d: limit('seven_day'), cost: usage?.cost?.usd ?? -1 }
103 if ((Object.keys(next) as (keyof typeof next)[]).every(key => old[key] === next[key])) return
104 await update($, context, ctx => ({ ...ctx, ...next }))
105}
106
107const save = async ($: $, change: (old: ClawdifySettings) => ClawdifySettings) => {
108 // Change what is saved, not this session's copy, so a stale copy or an edit to the file is never lost.
109 const next = change(await stored($))
110 await $.store.set('settings', changed(next))
111 await update($, settings, () => next)
112 return next
113}
114
115const savePresets = async ($: $, change: (old: Saved) => Saved) => {
116 const next = change(await storedPresets($))
117 await $.store.set('presets', next)
118 await update($, saved, () => next)
119}
120
121const describe = (s: ClawdifySettings) => {
122 const diff = Object.entries(changed(s))
123 return diff.length ? diff.map(([key, value]) => `${key} = ${JSON.stringify(value)}`).join('\n') : 'Everything is at Claude Code defaults.'
124}
125
126const run = async ($: $, args: string): Promise<string> => {
127 const [verb = '', ...rest] = args.trim().split(/\s+/)
128 const tail = args.trim().slice(verb.length).trim()
129 switch (verb) {
130 case 'get':
131 return describe(await current($))
132 case 'set': {
133 const key = rest[0] ?? ''
134 if (!isKey(key)) return `Unknown key "${key}".\n\n${USAGE}`
135 const value = tail.slice(key.length).trim()
136 await save($, old => ({ ...old, [key]: value }))
137 return value ? `${key} = ${JSON.stringify(value)}` : `${key} reset to default.`
138 }
139 case 'preset': {
140 const name = rest[0] ?? ''
141 const preset = PRESETS[name]
142 // Built-ins layer on top; one of yours brings back exactly the look you saved.
143 if (preset) await save($, old => ({ ...old, ...preset }))
144 else {
145 const mine = (await storedPresets($))[name]
146 if (!mine) return `Presets: ${Object.keys(PRESETS).join(', ')}
147Yours: ${Object.keys(await storedPresets($)).join(', ') || 'none yet (/clawdify save <name>)'}`
148 await save($, () => ({ ...DEFAULTS, ...mine }))
149 }
150 return `Applied preset "${name}".`
151 }
152 case 'save': {
153 const name = tail
154 if (!PRESET_NAME.test(name)) return 'Name it with letters, digits, - or _ (up to 32): /clawdify save <name>'
155 if (name in PRESETS) return `"${name}" is a built-in preset. Pick another name.`
156 const look = changed(await current($))
157 await savePresets($, old => ({ ...old, [name]: look }))
158 return `Saved preset "${name}" (${Object.keys(look).length} settings). /clawdify preset ${name} brings it back.`
159 }
160 case 'delete': {
161 const name = tail
162 if (!(name in (await storedPresets($)))) return `You have no preset "${name}".`
163 await savePresets($, old => Object.fromEntries(Object.entries(old).filter(([key]) => key !== name)))
164 return `Deleted preset "${name}".`
165 }
166 case 'reset': {
167 const key = rest[0]
168 if (!key) {
169 await save($, () => DEFAULTS)
170 return 'All settings reset to Claude Code defaults.'
171 }
172 if (!isKey(key)) return `Unknown key "${key}".`
173 await save($, old => ({ ...old, [key]: DEFAULTS[key] }))
174 return `${key} reset.`
175 }
176 case 'export': {
177 const json = JSON.stringify(changed(await current($)), null, 2)
178 const { isCopied } = await $.ui.copy({ text: json })
179 return `${isCopied ? 'Copied to clipboard:' : 'Settings:'}\n${json}`
180 }
181 case 'import': {
182 let parsed: unknown
183 try {
184 parsed = JSON.parse(tail)
185 } catch {
186 return 'That is not valid JSON. Paste what /clawdify export gave you.'
187 }
188 const loaded = clean(parsed)
189 await save($, () => ({ ...DEFAULTS, ...loaded }))
190 return `Imported ${Object.keys(loaded).length} settings.`
191 }
192 case 'reload':
193 return `Reloaded.
194${describe(await load($))}`
195 case 'help':
196 return USAGE
197 default:
198 return ask($, args.trim())
199 }
200}
201
202const ask = async ($: $, request: string) => {
203 $.ui.toast('clawdify: asking Claude…')
204 const reply = await $.model.complete({
205 model: 'sonnet',
206 system: brief(await current($)),
207 prompt: request,
208 effort: 'low',
209 maxTokens: 2048,
210 timeoutMs: 60000,
211 })
212 if (!reply.isAnswered) return `Claude didn't answer (${reply.reason}). Try again, or use /clawdify set.\n\n${USAGE}`
213 const change = parseChange(reply.text)
214 if (!change) return `Couldn't read Claude's answer:\n${reply.text}`
215 const entries = Object.entries(change)
216 if (!entries.length) return `Nothing to change for that. /clawdify help lists what can change.`
217 await save($, old => ({ ...old, ...change }))
218 return entries.map(([key, value]) => `${key} = ${JSON.stringify(value)}`).join('\n') + '\n\n/clawdify reset <key> undoes one.'
219}
220
221export const register: Register = on => {
222 on('session.start', async ($, e, next) => {
223 // ponytail: clears the status entry clawdify drew up to 0.4; drop this once nobody upgrades from there.
224 $.ui.status(undefined)
225 cwd = e.cwd
226 await adopt($)
227 await load($)
228 const model = await $.session.model().catch(() => '')
229 await update($, context, () => ({ cwd, model, now: 0, ...NO_USAGE }))
230 await $.command.register({
231 name: 'clawdify',
232 description: 'Customise Claude Code: spinner, footer, hint, banner, status, transcript',
233 argumentHint: '[what you want | get | set <key> <value> | preset <name> | save <name> | delete <name> | reset [key] | export | import <json> | reload]',
234 })
235 const tick = async () => {
236 const now = await $.clock.now()
237 const old = (await $.state.get(CONTEXT)).value
238 // Only write when the minute changes, so {time} readers redraw once a minute at most.
239 if (old?.now && Math.floor(old.now / 60000) === Math.floor(now / 60000)) return
240 await update($, context, ctx => ({ ...ctx, now }))
241 }
242 await tick()
243 await refreshUsage($)
244 $.clock.every(15000, () => void ensure($).then(() => refreshUsage($)))
245 $.clock.every(15000, () => void tick())
246 // ponytail: one 50ms clock drives every Clawd, and only while one is animated. frame holds elapsed ms,
247 // written only when some Clawd's picture changes: 4x a second for the classic ones, at a stock loop's own pace.
248 let ms = 0
249 $.clock.every(50, async () => {
250 const s = await current($)
251 const crab = CRABS[s.mascot]
252 const steps = [
253 ...(s.mascot === 'animated' || s.spinnerSuffix.includes('{clawd}') ? [250] : []),
254 ...(crab ? [crab.ms, CRABS.idle!.ms] : []),
255 ]
256 if (!steps.length) return
257 ms += 50
258 if (steps.some(step => ms % step < 50)) await update($, frame, () => ms)
259 })
260 return next(e)
261 })
262
263 on('turn.start', async ($, e, next) => {
264 await ensure($)
265 const model = await $.session.model().catch(() => '')
266 if (model) await update($, context, ctx => (ctx.model === model ? ctx : { ...ctx, model }))
267 return next(e)
268 })
269
270 on('turn.complete', async ($, e, next) => {
271 const result = await next(e)
272 await refreshUsage($)
273 const secs = Number((await current($)).doneToastSecs)
274 if (!e.agentId && secs > 0 && e.durationMs >= secs * 1000) $.ui.toast(`Done in ${duration(e.durationMs)}`)
275 return result
276 })
277
278 on('command.run', { command: 'clawdify' }, async ($, e) => {
279 await ensure($)
280 if (e.args.trim()) return { text: await run($, e.args) }
281 await $.ui.open({ id: PANE, title: 'clawdify', focus: true })
282 return { text: 'clawdify open. Enter saves a field; an empty field restores the default. /clawdify help for commands.' }
283 })
284
285 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
286 const s = await current($)
287 const active = (await read($, tab)) as Tab
288 const mine = Object.keys(await read($, saved))
289
290 if (e.surface === 'mobile' || e.surface === 'vscode') {
291 const { Text } = $.ui.resolve(e)
292 return <Text>{describe(s)}{'\n\n'}Edit in the terminal or desktop app.</Text>
293 }
294
295 const { Box, Button, Input, Select, Text } = $.ui.resolve(e)
296 const width = Math.max(...FIELDS.map(f => f.label.length)) + 1
297 const set = (key: Key, value: string) => save($, old => ({ ...old, [key]: value }))
298
299 const body = active === 'presets'
300 ? (
301 <Box flexDirection="column" gap={1}>
302 <Text dimColor>Presets layer on top of what you have. Reset first for a clean slate.</Text>
303 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
304 {Object.keys(PRESETS).map(name => (
305 <Button key={`preset-${name}`} label={name} onPress={() => void save($, old => ({ ...old, ...PRESETS[name] }))} />
306 ))}
307 </Box>
308 <Text dimColor>{'Yours bring back exactly the look you saved. /clawdify delete <name> removes one.'}</Text>
309 {mine.length
310 ? (
311 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
312 {mine.map(name => (
313 <Button key={`mine-${name}`} label={name} onPress={() => void run($, `preset ${name}`)} />
314 ))}
315 </Box>
316 )
317 : null}
318 <Input
319 key="save-as"
320 label="Save current as"
321 placeholder="my-look"
322 value=""
323 submitLabel="save"
324 onSubmit={value => void run($, `save ${value}`).then(text => $.ui.toast(text))}
325 />
326 <Box flexDirection="row" columnGap={1}>
327 <Button key="export" label="copy JSON" onPress={() => void run($, 'export').then(() => $.ui.toast('Settings copied'))} />
328 <Button key="reset" label="reset all" onPress={() => void save($, () => DEFAULTS)} />
329 </Box>
330 <Text dimColor>{describe(s)}</Text>
331 </Box>
332 )
333 : (
334 <Box flexDirection="column">
335 {FIELDS.filter(f => f.tab === active).map((f, i) =>
336 f.options
337 ? (
338 <Select
339 key={f.key}
340 label={f.label.padEnd(width)}
341 value={s[f.key] || 'default'}
342 options={f.options.map(o => ({ value: o || 'default', label: o || 'default' }))}
343 onSelect={value => void set(f.key, value === 'default' ? '' : value)}
344 />
345 )
346 : (
347 <Input
348 key={f.key}
349 label={f.label.padEnd(width)}
350 placeholder={f.hint}
351 value={s[f.key]}
352 submitLabel="save"
353 autoFocus={i === 0 ? true : undefined}
354 onSubmit={value => void set(f.key, value)}
355 />
356 ),
357 )}
358 </Box>
359 )
360
361 return (
362 <Box flexDirection="column" gap={1}>
363 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
364 {TABS.map((name, i) => (
365 <Button
366 key={`tab-${name}`}
367 label={name}
368 hotkey={String(i + 1)}
369 variant={name === active ? 'primary' : undefined}
370 onPress={() => void update($, tab, () => name)}
371 />
372 ))}
373 </Box>
374 {body}
375 <Text dimColor>Empty = Claude Code default · lists are comma-separated · tokens {'{model} {cwd} {time} {date}'}</Text>
376 </Box>
377 )
378 })
379
380 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
381 // ponytail: desktop's word describes the current step, so leave it alone there.
382 if (e.surface !== 'terminal') return next(e)
383 const s = await current($)
384 const field = SPINNER_FIELD[e.props.mode]
385 const verbs = list(field ? s[field] : '').length ? list(field ? s[field] : '') : list(s.spinnerVerbs)
386 const props = { ...e.props }
387 if (verbs.length) props.word = pick(verbs, e.props.word)
388 if (s.spinnerSuffix) props.suffix = s.spinnerSuffix.includes('{clawd}') ? s.spinnerSuffix.replaceAll('{clawd}', tinyClawd(Math.floor((await read($, frame)) / 250))) : s.spinnerSuffix
389 return next({ ...e, props })
390 })
391
392 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
393 const s = await current($)
394 const verbs = list(s.doneVerbs)
395 const word = verbs.length ? pick(verbs, e.props.word + e.requestId) : e.props.word
396 if (!s.doneTemplate.trim()) return verbs.length ? next({ ...e, props: { ...e.props, word } }) : next(e)
397 const ctx = await read($, context)
398 const { Text } = $.ui.resolve(e)
399 const text = fill(s.doneTemplate, ctx, { word, time: duration(e.props.durationMs) })
400 return s.doneColor ? <Text color={s.doneColor}>{text}</Text> : <Text dimColor>{text}</Text>
401 })
402
403 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
404 const s = await current($)
405 if (unclawd(s.footer)) {
406 const ctx = await read($, context)
407 const { Box, Text } = $.ui.resolve(e)
408 const hint = s.hint && !e.props.isDraft && !e.props.isWorking ? s.hint : e.props.hint
409 const tail = s.hintTail.trim() ? ` · ${s.hintTail.trim()}` : ''
410 const segments = fill(unclawd(s.footer), ctx).split('·').map(part => part.trim()).filter(Boolean)
411 return (
412 <Box flexDirection="row" columnGap={1}>
413 {segments.flatMap((segment, i) => {
414 const color = heat(segment) ?? (s.footerColor || undefined)
415 const text = color ? <Text key={`seg-${i}`} color={color}>{segment}</Text> : <Text key={`seg-${i}`} dimColor>{segment}</Text>
416 return i ? [<Text key={`sep-${i}`} dimColor>·</Text>, text] : [text]
417 })}
418 {hint.trim() ? <Text key="hint" dimColor>{` ${hint.trim()}${tail}`}</Text> : null}
419 </Box>
420 )
421 }
422 const props = { ...e.props }
423 if (s.hint && !e.props.isDraft && !e.props.isWorking) props.hint = s.hint
424 if (s.hintTail.trim()) props.tail = ` · ${s.hintTail.trim()}`
425 return next({ ...e, props })
426 })
427
428 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
429 const label = (await current($)).modeLabel.trim()
430 return label ? next({ ...e, props: { modes: [...e.props.modes, label] } }) : next(e)
431 })
432
433 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
434 const hint = (await current($)).backgroundHint.trim()
435 if (!hint) return next(e)
436 return next({ ...e, props: { ...e.props, hint: hint === 'none' ? '' : hint } })
437 })
438
439 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
440 const s = await current($)
441 if ((!s.banner.trim() && !s.mascot) || e.props.hasSurvey) return next(e)
442 const ctx = await read($, context)
443 const { Box, Text } = $.ui.resolve(e)
444 const crab = CRABS[s.mascot]
445 const animated = s.mascot === 'animated' || !!crab
446 const ms = animated ? await read($, frame) : 0
447 const n = Math.floor(ms / 250)
448 const working = animated && e.props.isWorking
449 const justify = s.bannerAlign === 'center' ? 'center' : s.bannerAlign === 'right' ? 'flex-end' : 'flex-start'
450 const text = fill(s.banner, ctx)
451 return (
452 <Box
453 width={e.props.bodyColumns}
454 justifyContent={justify}
455 alignItems="center"
456 columnGap={1}
457 borderStyle={s.bannerBorder || undefined}
458 borderColor={s.bannerBorder && s.bannerColor ? s.bannerColor : undefined}
459 >
460 {s.mascot
461 ? (
462 <Box
463 flexDirection="column"
464 width={crab ? crab.frames[0]?.[0]?.length ?? 9 : animated ? 15 : 9}
465 paddingLeft={working && !crab ? scuttle(n, 6) : 0}
466 >
467 {(crab ? (working ? crabFrame(crab, ms) : s.mascot in FRIENDS ? crab.frames[0]! : crabFrame(CRABS.idle!, ms)) : clawd(n, working)).map((row, i) => <Text key={`clawd-${i}`} color={s.mascotColor || '#d77757'}>{row}</Text>)}
468 </Box>
469 )
470 : null}
471 {text.trim() ? (s.bannerColor ? <Text color={s.bannerColor}>{text}</Text> : <Text dimColor>{text}</Text>) : null}
472 </Box>
473 )
474 })
475
476 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
477 const s = await current($)
478 if (e.props.origin.kind !== 'composer' || (!s.userPrefix && !s.userColor)) return next(e)
479 const { Box, Text } = $.ui.resolve(e)
480 const color = s.userColor || undefined
481 return (
482 <Box flexDirection="row" columnGap={1}>
483 <Text color={color} bold>{s.userPrefix || '>'}</Text>
484 <Text color={color}>{e.props.text}</Text>
485 </Box>
486 )
487 })
488
489 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
490 const rules = (await current($)).replyRewrites
491 return rules.trim() ? next({ ...e, props: { ...e.props, text: rewrite(e.props.text, rules) } }) : next(e)
492 })
493
494 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
495 const expand = (await current($)).expandToolGroups === 'on'
496 return expand && !e.props.isExpanded ? next({ ...e, props: { ...e.props, isExpanded: true } }) : next(e)
497 })
498
499 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
500 if ((await current($)).hideNotices !== 'on') return next(e)
501 const { Box } = $.ui.resolve(e)
502 return <Box display="none" />
503 })
504}
505hooks/settings.ts 573 lines1import type { ClawdifyContext, ClawdifySettings, ClawdifyUsage } from '../types'
2import { CRABS, FRIENDS } from './crabs'
3
4export type Key = keyof ClawdifySettings
5
6// Empty string everywhere means "Claude Code's own behaviour".
7export const DEFAULTS: ClawdifySettings = {
8 spinnerVerbs: '',
9 spinnerThinking: '',
10 spinnerTools: '',
11 spinnerResponding: '',
12 spinnerSuffix: '',
13 doneVerbs: '',
14 doneTemplate: '',
15 doneColor: '',
16 doneToastSecs: '',
17 hint: '',
18 hintTail: '',
19 modeLabel: '',
20 footer: '',
21 footerColor: '',
22 backgroundHint: '',
23 banner: '',
24 bannerColor: '',
25 bannerBorder: '',
26 bannerAlign: '',
27 mascot: '',
28 mascotColor: '',
29 userPrefix: '',
30 userColor: '',
31 replyRewrites: '',
32 expandToolGroups: '',
33 hideNotices: '',
34}
35
36export const TABS = ['spinner', 'turn', 'prompt', 'banner', 'transcript', 'presets'] as const
37export type Tab = (typeof TABS)[number]
38
39type Field = { key: Key; tab: Tab; label: string; hint: string; options?: readonly string[] }
40
41const ON_OFF = ['', 'on'] as const
42
43export const FIELDS: readonly Field[] = [
44 { key: 'spinnerVerbs', tab: 'spinner', label: 'Words', hint: 'Clawing, Scuttling, Pinching' },
45 { key: 'spinnerThinking', tab: 'spinner', label: 'While thinking', hint: 'Pondering, Brooding' },
46 { key: 'spinnerTools', tab: 'spinner', label: 'While using tools', hint: 'Tinkering, Wrenching' },
47 { key: 'spinnerResponding', tab: 'spinner', label: 'While responding', hint: 'Typing, Scribbling' },
48 { key: 'spinnerSuffix', tab: 'spinner', label: 'Suffix', hint: ' ~ (replaces the …; {clawd} = a scuttling Clawd)' },
49 { key: 'doneVerbs', tab: 'turn', label: 'Done words', hint: 'Clawed, Snipped' },
50 { key: 'doneTemplate', tab: 'turn', label: 'Footer template', hint: '✓ {word} in {time}' },
51 { key: 'doneColor', tab: 'turn', label: 'Footer colour', hint: 'green, #ff8800, ansi:cyan' },
52 { key: 'doneToastSecs', tab: 'turn', label: 'Toast after (s)', hint: '30 = toast when a turn ran 30s+' },
53 { key: 'hint', tab: 'prompt', label: 'Hint (replace)', hint: 'replaces "? for shortcuts" when idle' },
54 { key: 'hintTail', tab: 'prompt', label: 'Hint tail', hint: 'snip snip' },
55 { key: 'modeLabel', tab: 'prompt', label: 'Mode label', hint: 'crab mode' },
56 { key: 'footer', tab: 'prompt', label: 'Footer', hint: '{model} · {cwd} {branch} · ctx {ctxbar} {context} · 5h {5h} · {cost}' },
57 { key: 'footerColor', tab: 'prompt', label: 'Footer colour', hint: '#d77757' },
58 { key: 'backgroundHint', tab: 'prompt', label: 'ctrl+b pill', hint: '"none" hides it' },
59 { key: 'banner', tab: 'banner', label: 'Banner', hint: '🦀 {model} · {cwd} · {time}' },
60 { key: 'bannerColor', tab: 'banner', label: 'Banner colour', hint: 'magenta, #c15f3c' },
61 { key: 'bannerBorder', tab: 'banner', label: 'Banner border', hint: '', options: ['', 'round', 'single', 'double', 'bold', 'classic'] },
62 { key: 'bannerAlign', tab: 'banner', label: 'Banner align', hint: '', options: ['', 'center', 'right'] },
63 { key: 'mascot', tab: 'banner', label: 'Clawd', hint: '', options: ['', 'still', 'animated', ...Object.keys(CRABS)] },
64 { key: 'mascotColor', tab: 'banner', label: 'Clawd colour', hint: '#d77757 (Claude orange)' },
65 { key: 'userPrefix', tab: 'transcript', label: 'Your prompt prefix', hint: '❯' },
66 { key: 'userColor', tab: 'transcript', label: 'Your prompt colour', hint: 'cyan' },
67 { key: 'replyRewrites', tab: 'transcript', label: 'Reply rewrites', hint: 'you=>ye; /\\bhello\\b/gi=>ahoy' },
68 { key: 'expandToolGroups', tab: 'transcript', label: 'Expand tool groups', hint: '', options: ON_OFF },
69 { key: 'hideNotices', tab: 'transcript', label: 'Hide startup notices', hint: '', options: ON_OFF },
70]
71
72// A friend's preset: its mascot, one colour everywhere, and theme leftovers from other presets cleared.
73const friend = (mascot: string, colour: string, p: Partial<ClawdifySettings>): Partial<ClawdifySettings> => ({
74 spinnerSuffix: '',
75 replyRewrites: '',
76 userPrefix: '',
77 bannerBorder: '',
78 bannerAlign: '',
79 mascot,
80 mascotColor: colour,
81 bannerColor: colour,
82 doneColor: colour,
83 footerColor: colour,
84 userColor: colour,
85 ...p,
86})
87
88export const PRESETS: Record<string, Partial<ClawdifySettings>> = {
89 clawd: {
90 spinnerVerbs: 'Clawing, Scuttling, Pinching, Sidestepping, Molting, Tinkering',
91 spinnerThinking: 'Reckoning, Mulling it over, Having a squiz, Yarning with meself, Cooking up a ripper, Chewing the fat, Having a good ponder, Fair dinkum thinking',
92 spinnerTools: 'Wrenching, Scuttling about, Giving it a burl, Whacking it on the barbie, Tinkering',
93 spinnerResponding: 'Scribbling, Typing up a beauty, Spinning a yarn, Penning a ripper',
94 spinnerSuffix: '',
95 doneVerbs: 'Clawed, Snipped, Scuttled, Nailed it, Sorted',
96 doneTemplate: '{word} in {time}, no worries',
97 doneColor: '#d77757',
98 hint: "she'll be right",
99 hintTail: 'snip snip, legend',
100 modeLabel: 'clawd mode',
101 banner: "G'day! {model} · {cwd} · {time}",
102 bannerColor: '#d77757',
103 bannerBorder: 'round',
104 bannerAlign: '',
105 mascot: 'animated',
106 mascotColor: '#d77757',
107 footer: '{model} · {cwd} {branch} · ctx {ctxbar} {context} · 5h {5h} · 7d {7d} · {cost}',
108 footerColor: '#d77757',
109 userPrefix: '❯',
110 userColor: '#d97757',
111 },
112 pirate: {
113 spinnerVerbs: 'Plundering, Swashbuckling, Hoisting, Parleying',
114 doneVerbs: 'Plundered, Pillaged',
115 doneTemplate: '⚓ {word} for {time}',
116 banner: '🏴☠️ Cap\'n {model} sails {cwd}',
117 replyRewrites: '/\\byou\\b/gi=>ye; /\\bmy\\b/gi=>me',
118 },
119 hacker: {
120 spinnerThinking: 'Decrypting, Compiling, Brute-forcing',
121 spinnerTools: 'Injecting, Patching, Rooting',
122 spinnerResponding: 'Uplinking, Transmitting',
123 spinnerSuffix: ' █',
124 doneTemplate: '[OK] {word} in {time}',
125 doneColor: 'green',
126 userPrefix: 'root@claude:~#',
127 userColor: 'green',
128 banner: '>> {model} :: {cwd} :: {time}',
129 bannerColor: 'green',
130 bannerBorder: 'classic',
131 },
132 zen: {
133 spinnerVerbs: 'Breathing, Settling, Noticing',
134 spinnerSuffix: ' .',
135 doneTemplate: '· {time} ·',
136 doneColor: 'gray',
137 hint: 'one thing at a time',
138 hideNotices: 'on',
139 },
140 minimal: {
141 spinnerSuffix: '',
142 doneTemplate: '{time}',
143 hint: ' ',
144 backgroundHint: 'none',
145 hideNotices: 'on',
146 expandToolGroups: '',
147 },
148 snail: friend('snail', '#c8a165', {
149 spinnerVerbs: 'Inching, Oozing, Escargot-ing, Taking the scenic route',
150 spinnerThinking: 'Contemplating at length, Considering every leaf, Mulling, très lentement',
151 spinnerTools: 'Sliming along, Carrying the house, Edging forward',
152 spinnerResponding: 'Writing in cursive, Composing at a stroll',
153 spinnerSuffix: ' . . .',
154 doneVerbs: 'Arrived, Got there eventually, Voilà, Slow and steady',
155 doneTemplate: '🐌 {word}. Only took {time}.',
156 hint: 'no rush',
157 hintTail: 'slow is smooth, smooth is fast',
158 modeLabel: 'snail mail',
159 banner: '🐌 {model} is on its way to {cwd}… eventually',
160 bannerBorder: 'round',
161 userPrefix: '@',
162 }),
163 bat: friend('bat', '#8b5cf6', {
164 spinnerVerbs: 'Echolocating, Hanging around, Flitting, Swooping',
165 spinnerThinking: 'Listening for echoes, Sonar-sweeping, Pinging the dark',
166 spinnerTools: 'Swooping in, Flitting between files, Snatching bugs mid-air',
167 spinnerResponding: 'Squeaking back, Returning from the night',
168 doneVerbs: 'Swooped, Roosted, Echoed back',
169 doneTemplate: '🦇 {word} in {time}. Back to the belfry.',
170 hint: 'finding bugs in the dark',
171 hintTail: 'eeeeee (ultrasonic)',
172 modeLabel: 'night shift',
173 banner: '🦇 {model} haunting {cwd} at {time}',
174 bannerBorder: 'double',
175 userPrefix: '^v^',
176 }),
177 spider: friend('spider', '#b0aea5', {
178 spinnerVerbs: 'Spinning, Weaving, Threading, Dangling',
179 spinnerThinking: 'Mapping the web, Feeling for vibrations, Plotting strands',
180 spinnerTools: 'Spinning silk, Weaving it together, Tying off threads',
181 spinnerResponding: 'Writing on the web, Spelling it out in silk',
182 doneVerbs: 'Woven, Spun, Caught one',
183 doneTemplate: '🕸 {word} in {time}',
184 hint: 'everything is connected',
185 hintTail: 'mind the web',
186 modeLabel: 'web dev (literally)',
187 banner: '🕷 {model} weaving through {cwd}',
188 bannerBorder: 'single',
189 userPrefix: '╲╱',
190 }),
191 bunny: friend('bunny', '#f2b8c6', {
192 spinnerVerbs: 'Hopping, Bounding, Binkying, Nibbling',
193 spinnerThinking: 'Twitching whiskers, Sniffing around, Ears up',
194 spinnerTools: 'Burrowing, Digging in, Hopping between files',
195 spinnerResponding: 'Thumping out an answer, Hopping to it',
196 doneVerbs: 'Hopped, Binkied, Burrowed through',
197 doneTemplate: '🐇 {word} in {time}',
198 hint: 'down the rabbit hole?',
199 hintTail: 'hop to it',
200 modeLabel: 'hop mode',
201 banner: '🐇 {model} hopping round {cwd}',
202 bannerBorder: 'round',
203 }),
204 dog: friend('dog', '#c68642', {
205 spinnerVerbs: 'Fetching, Sniffing, Wagging, Being a good boy',
206 spinnerThinking: 'Head tilting, Sniffing it out, Following the scent',
207 spinnerTools: 'Fetching, Digging up the yard, Retrieving, Chewing on it',
208 spinnerResponding: 'Bringing it back, Dropping it at your feet',
209 doneVerbs: 'Fetched, Retrieved, Good boy, Who\'s a good boy',
210 doneTemplate: '🐕 {word}! ({time}) *tail wags*',
211 hint: 'throw me a task',
212 hintTail: 'who\'s a good dev',
213 modeLabel: 'good boy mode',
214 banner: '🐕 {model} guarding {cwd}',
215 bannerBorder: 'round',
216 userPrefix: '🐾',
217 }),
218 penguin: friend('penguin', '#6a9bcc', {
219 spinnerVerbs: 'Waddling, Sliding, Huddling, Tobogganing',
220 spinnerThinking: 'Huddling for warmth, Pondering the ice, Standing very still',
221 spinnerTools: 'Belly-sliding, Diving in, Fishing',
222 spinnerResponding: 'Waddling back, Squawking politely',
223 doneVerbs: 'Waddled, Slid home, Dove in',
224 doneTemplate: '🐧 {word} in {time}, dressed for the occasion',
225 hint: 'stay cool',
226 hintTail: 'black tie required',
227 modeLabel: 'black tie',
228 banner: '🐧 {model} on the ice at {cwd}',
229 bannerBorder: 'bold',
230 userPrefix: '$',
231 }),
232 snake: friend('snake', '#788c5d', {
233 spinnerVerbs: 'Slithering, Coiling, Shedding, Hissing',
234 spinnerThinking: 'Coiling up, Tasting the air, Basking',
235 spinnerTools: 'Slithering through, Striking, Squeezing',
236 spinnerResponding: 'Hisssssing, Uncoiling',
237 spinnerSuffix: ' sss',
238 doneVerbs: 'Ssssorted, Struck, Shed',
239 doneTemplate: '🐍 {word} in {time}',
240 hint: 'import this',
241 hintTail: 'explicit is better than implicit',
242 modeLabel: 'pythonic',
243 banner: '🐍 {model} coiled around {cwd}',
244 userPrefix: '>>>',
245 replyRewrites: '/\\bYes\\b/g=>Yesss',
246 }),
247 jellyfish: friend('jellyfish', '#7fdbda', {
248 spinnerVerbs: 'Drifting, Pulsing, Glowing, Floating',
249 spinnerThinking: 'Drifting in thought, Going with the current, Pulsing gently',
250 spinnerTools: 'Trailing tentacles, Stinging bugs, Pulsing along',
251 spinnerResponding: 'Glowing softly, Surfacing',
252 spinnerSuffix: ' ~',
253 doneVerbs: 'Drifted in, Glowed, Surfaced',
254 doneTemplate: '≋ {word} after {time} ≋',
255 hint: 'no brain, no problem',
256 hintTail: 'go with the flow',
257 modeLabel: 'bioluminescent',
258 banner: '≋ {model} drifting through {cwd} ≋',
259 bannerAlign: 'center',
260 }),
261 duck: friend('duck', '#f5c542', {
262 spinnerVerbs: 'Paddling, Quacking, Rubber-ducking, Waddling',
263 spinnerThinking: 'Listening patiently, Rubber-ducking, Tilting head',
264 spinnerTools: 'Paddling furiously below the surface, Dabbling, Preening the code',
265 spinnerResponding: 'Quacking back, Explaining it to the duck',
266 doneVerbs: 'Quacked, Ducked it, Paddled through',
267 doneTemplate: '🦆 {word} in {time}. Calm on top.',
268 hint: 'explain it to the duck',
269 hintTail: 'quack',
270 modeLabel: 'rubber duck',
271 banner: '🦆 {model} · rubber duck on duty · {cwd}',
272 bannerBorder: 'round',
273 }),
274 turtle: friend('turtle', '#5f9e6e', {
275 spinnerVerbs: 'Plodding, Persevering, Basking, Shelling out',
276 spinnerThinking: 'Retreating into shell, Thinking slowly but surely, Weighing it up',
277 spinnerTools: 'Plodding along, Carrying the load, Steadily working',
278 spinnerResponding: 'Poking head out, Delivering steadily',
279 doneVerbs: 'Won the race, Plodded through, Shell-ebrated',
280 doneTemplate: '🐢 {word} in {time}. Hare nowhere to be seen.',
281 hint: 'slow and steady',
282 hintTail: 'turtles all the way down',
283 modeLabel: 'steady',
284 banner: '🐢 {model} plodding through {cwd}',
285 bannerBorder: 'bold',
286 }),
287 hourglass: friend('hourglass', '#d4a85a', {
288 spinnerVerbs: 'Waiting, Sifting, Trickling, Biding time',
289 spinnerThinking: 'Counting grains, Watching the sand, Biding time',
290 spinnerTools: 'Turning it over, Sifting through, Trickling',
291 spinnerResponding: 'Running out the clock, Finishing the turn',
292 doneVerbs: 'Time\'s up, Sifted, Turned over',
293 doneTemplate: '⏳ {word}: {time} of sand',
294 doneToastSecs: '30',
295 hint: 'time is a flat circle',
296 hintTail: 'tick tock',
297 modeLabel: 'sands of time',
298 banner: '⏳ {model} · {date} {time} · {cwd}',
299 bannerBorder: 'classic',
300 }),
301 plant: friend('plant', '#7cb342', {
302 spinnerVerbs: 'Growing, Photosynthesising, Sprouting, Rooting',
303 spinnerThinking: 'Putting down roots, Soaking up sun, Germinating',
304 spinnerTools: 'Pruning, Grafting, Repotting, Watering',
305 spinnerResponding: 'Blooming, Unfurling',
306 doneVerbs: 'Bloomed, Sprouted, Grown',
307 doneTemplate: '🌱 {word} in {time}',
308 hint: 'water me with prompts',
309 hintTail: 'touch grass',
310 modeLabel: 'green thumb',
311 banner: '🌱 {model} tending the {cwd} garden',
312 bannerBorder: 'round',
313 }),
314 campfire: friend('campfire', '#ff7a33', {
315 spinnerVerbs: 'Crackling, Kindling, Toasting, Smouldering',
316 spinnerThinking: 'Staring into the flames, Gathering kindling, Telling a story',
317 spinnerTools: 'Stoking the fire, Throwing on a log, Toasting marshmallows',
318 spinnerResponding: 'Spinning a yarn, Crackling away',
319 doneVerbs: 'Toasted, Stoked, Roaring',
320 doneTemplate: '🔥 {word} in {time}. Pass the marshmallows.',
321 hint: 'pull up a log',
322 hintTail: 'mind the embers',
323 modeLabel: 'cosy',
324 banner: '🔥 {model} round the fire at {cwd}',
325 bannerBorder: 'round',
326 }),
327 rain: friend('rain', '#7aa2c8', {
328 spinnerVerbs: 'Drizzling, Pouring, Pattering, Precipitating',
329 spinnerThinking: 'Clouding over, Gathering, Brewing a storm',
330 spinnerTools: 'Pouring down, Flooding the zone, Thundering',
331 spinnerResponding: 'Pattering out, Clearing up',
332 spinnerSuffix: ' ╷',
333 doneVerbs: 'Rained, Cleared, Forecast confirmed',
334 doneTemplate: '☔ {word} in {time} · clearing later',
335 hint: 'cloudy with a chance of commits',
336 hintTail: 'bring a brolly',
337 modeLabel: 'forecast',
338 banner: '☁ {model} over {cwd} · {time}',
339 }),
340 moon: friend('moon', '#f0eee6', {
341 spinnerVerbs: 'Waxing, Waning, Orbiting, Moonlighting',
342 spinnerThinking: 'Gazing, Phasing, Reflecting',
343 spinnerTools: 'Pulling the tides, Orbiting, Eclipsing',
344 spinnerResponding: 'Shining back, Reflecting',
345 doneVerbs: 'Full moon, Eclipsed, Orbited',
346 doneTemplate: '☾ {word} in {time}',
347 hint: 'night owls only',
348 hintTail: 'the dark side is a feature',
349 modeLabel: 'moonlighting',
350 banner: '☾ {model} over {cwd} · {time}',
351 bannerBorder: 'round',
352 hideNotices: 'on',
353 }),
354 equalizer: friend('equalizer', '#ff4fd8', {
355 spinnerVerbs: 'Vibing, Mixing, Dropping the beat, Remixing',
356 spinnerThinking: 'Finding the groove, Tuning up, Feeling the bass',
357 spinnerTools: 'Scratching, Sampling, Mixing down, Mastering',
358 spinnerResponding: 'Dropping it, Laying down tracks',
359 spinnerSuffix: ' ♪',
360 doneVerbs: 'Mastered, Dropped, Banger',
361 doneTemplate: '♫ {word} in {time} ♫',
362 hint: 'turn it up',
363 hintTail: 'bpm: high',
364 modeLabel: 'on air',
365 banner: '♫ now playing: {model} · live from {cwd}',
366 bannerBorder: 'bold',
367 }),
368 heartbeat: friend('heartbeat', '#ff4d5e', {
369 spinnerVerbs: 'Monitoring, Triaging, Stabilising, Diagnosing',
370 spinnerThinking: 'Reading vitals, Checking the pulse, Running diagnostics',
371 spinnerTools: 'Operating, Suturing, Administering a patch, Prescribing',
372 spinnerResponding: 'Charting, Writing the discharge notes',
373 doneVerbs: 'Stable, Discharged, Pulse restored',
374 doneTemplate: '♥ {word} · {time} on the table',
375 doneToastSecs: '60',
376 hint: 'the patient is stable',
377 hintTail: 'beep… beep…',
378 modeLabel: 'on call',
379 banner: '♥ {model} · ward {cwd} · {time}',
380 bannerBorder: 'single',
381 }),
382 screensaver: friend('screensaver', '#3fd0c9', {
383 spinnerVerbs: 'Bouncing, Idling, Drifting, Almost hitting the corner',
384 spinnerThinking: 'Calculating trajectory, Approaching the corner, So close',
385 spinnerTools: 'Bouncing off walls, Changing colour, Ricocheting',
386 spinnerResponding: 'Bouncing back, Rendering',
387 doneVerbs: 'HIT THE CORNER, Bounced, Ricocheted',
388 doneTemplate: '▣ {word} after {time}',
389 hint: 'it will hit the corner. eventually.',
390 hintTail: 'press any key',
391 modeLabel: 'idle since 2003',
392 banner: '▣ {model} · {cwd} · {date}',
393 bannerBorder: 'classic',
394 userPrefix: 'C:\\>',
395 }),
396 'binary-rain': friend('binary-rain', '#00ff41', {
397 spinnerVerbs: 'Decoding, Following the white rabbit, Jacking in, Bending the spoon',
398 spinnerThinking: 'Seeing the code, Choosing a pill, Dodging bullets',
399 spinnerTools: 'Rewriting the Matrix, Jacking in, Downloading kung fu',
400 spinnerResponding: 'Transmitting, Decoding',
401 spinnerSuffix: ' █',
402 doneVerbs: 'I know kung fu, Decoded, There is no spoon',
403 doneTemplate: '[{word}] {time}',
404 hint: 'follow the white rabbit',
405 hintTail: 'wake up',
406 modeLabel: 'the matrix',
407 banner: 'wake up… {model} has you · {cwd}',
408 bannerBorder: 'single',
409 userPrefix: '>',
410 }),
411 train: friend('train', '#d0473a', {
412 spinnerVerbs: 'Choo-chooing, Chugging, Steaming ahead, Full steam',
413 spinnerThinking: 'Checking the timetable, Switching tracks, Stoking the boiler',
414 spinnerTools: 'Laying track, Coupling carriages, Shunting',
415 spinnerResponding: 'Pulling into the station, Announcing',
416 doneVerbs: 'Arrived on time, All aboard, Pulled in',
417 doneTemplate: '🚂 {word} · journey time {time}',
418 hint: 'all aboard',
419 hintTail: 'mind the gap',
420 modeLabel: 'express',
421 banner: '🚂 {model} express · next stop {cwd}',
422 bannerBorder: 'double',
423 }),
424}
425
426// Clawd, Claude Code's own mascot, as the welcome screen draws him. Idle he blinks now and then;
427// working he scuttles, arms and legs swapping each frame.
428const CLAWD = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ '] as const
429
430export const clawd = (frame: number, isWorking: boolean) => {
431 const step = isWorking && frame % 2 === 1
432 return [
433 !isWorking && frame % 16 === 15 ? ' ▐█████▌ ' : CLAWD[0],
434 step ? '▗▜█████▛▖' : CLAWD[1],
435 step ? ' ▝▝ ▘▘ ' : CLAWD[2],
436 ]
437}
438
439// Crabs walk sideways: 0..span and back.
440export const scuttle = (frame: number, span: number) => {
441 const at = frame % (span * 2)
442 return at <= span ? at : span * 2 - at
443}
444
445// The one-row Clawd for the spinner line, shuffling side to side.
446export const tinyClawd = (frame: number) => ['▐▛███▜▌', ' ▐▛███▜▌', ' ▐▛███▜▌', ' ▐▛███▜▌'][frame % 4] ?? ''
447
448export const list = (value: string) => value.split(',').map(word => word.trim()).filter(Boolean)
449
450// The engine samples a fresh word per turn; hashing the seed keeps our pick stable across redraws.
451export const pick = (words: readonly string[], seed: string) => {
452 let hash = 0
453 for (const char of seed) hash = (hash * 31 + char.charCodeAt(0)) | 0
454 return words[Math.abs(hash) % words.length] ?? seed
455}
456
457export const duration = (ms: number) => {
458 const s = Math.round(ms / 1000)
459 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
460}
461
462const pad = (n: number) => String(n).padStart(2, '0')
463
464export const NO_USAGE: ClawdifyUsage = { branch: '', context: -1, limit5h: -1, limit7d: -1, cost: -1 }
465
466const pct = (n: number) => (n < 0 ? '–' : `${Math.round(n)}%`)
467const bar = (n: number) => (n < 0 ? '' : '▰'.repeat(Math.round(n / 20)) + '▱'.repeat(5 - Math.round(n / 20)))
468
469// Warm traffic light for a footer segment that carries a percentage: rust past 85, amber past 60.
470export const heat = (segment: string) => {
471 const n = Number(/(\d+)%/.exec(segment)?.[1] ?? -1)
472 return n >= 85 ? '#c15f3c' : n >= 60 ? '#e8a33d' : undefined
473}
474
475export const fill = (template: string, ctx: ClawdifyContext, extra: Record<string, string> = {}) => {
476 const d = new Date(ctx.now)
477 const vars: Record<string, string> = {
478 model: ctx.model,
479 cwd: ctx.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? ctx.cwd,
480 path: ctx.cwd,
481 clawd: CLAWD[0],
482 branch: ctx.branch,
483 context: pct(ctx.context),
484 ctxbar: bar(ctx.context),
485 '5h': pct(ctx.limit5h),
486 '7d': pct(ctx.limit7d),
487 cost: ctx.cost < 0 ? '' : `$${ctx.cost.toFixed(2)}`,
488 time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
489 date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
490 ...extra,
491 }
492 return template.replace(/\{(\w+)\}/g, (whole, name: string) => vars[name] ?? whole)
493}
494
495// "a=>b; /re/flags=>c": plain or regex find/replace, applied in order. Bad regexes are skipped.
496export const rewrite = (text: string, rules: string) => {
497 let out = text
498 for (const rule of rules.split(';')) {
499 const at = rule.indexOf('=>')
500 if (at < 0) continue
501 const from = rule.slice(0, at).trim()
502 const to = rule.slice(at + 2).trim()
503 if (!from) continue
504 const re = /^\/(.+)\/([a-z]*)$/.exec(from)
505 if (!re) {
506 out = out.split(from).join(to)
507 continue
508 }
509 try {
510 out = out.replace(new RegExp(re[1] ?? '', re[2]), to)
511 } catch {
512 // ponytail: an invalid regex is ignored rather than reported; surface it in the pane if people trip on it.
513 }
514 }
515 return out
516}
517
518// Trust boundary for /clawdify import and the store: keep known keys with string values only.
519export const clean = (raw: unknown): Partial<ClawdifySettings> => {
520 if (!raw || typeof raw !== 'object') return {}
521 const out: Partial<ClawdifySettings> = {}
522 for (const key of Object.keys(DEFAULTS) as Key[]) {
523 const value = (raw as Record<string, unknown>)[key]
524 if (typeof value === 'string') out[key] = value
525 }
526 // Up to 0.4 a separate status line sat under the prompt; it lives in the footer now.
527 const status = (raw as Record<string, unknown>).statusText
528 if (!out.footer && typeof status === 'string') out.footer = status
529 return out
530}
531
532// The row under the prompt stays neat: no Clawd there, whether as the token, pasted art or the emoji.
533export const unclawd = (text: string) =>
534 text.replace(/\{clawd\}|[▐▛█▜▌▝▘▗▖▟▙]+|🦀/gu, '').replace(/ {2,}/g, ' ').trim()
535
536export const isKey = (key: string): key is Key => key in DEFAULTS
537
538export const changed = (settings: ClawdifySettings) => clean(
539 Object.fromEntries(Object.entries(settings).filter(([key, value]) => value !== DEFAULTS[key as Key])),
540)
541
542// The user's own presets, from the store file: untrusted, so names are checked, built-in names skipped, settings cleaned.
543export const PRESET_NAME = /^[\w-]{1,32}$/
544export type Saved = Record<string, Partial<ClawdifySettings>>
545export const cleanSaved = (raw: unknown): Saved => Object.fromEntries(
546 Object.entries(raw && typeof raw === 'object' ? raw : {})
547 .filter(([name]) => PRESET_NAME.test(name) && !(name in PRESETS))
548 .map(([name, preset]) => [name, clean(preset)]),
549)
550
551// System prompt for /clawdify <request>: every setting, what it takes, and where things stand now.
552export const brief = (s: ClawdifySettings) => [
553 "You configure clawdify, a Claude Code mod that restyles the CLI. Turn the user's request into setting changes.",
554 'Reply with ONE JSON object and nothing else: setting keys to string values, only the keys to change. "" restores Claude Code\'s default. Reply {} if nothing fits.',
555 'Settings:',
556 ...FIELDS.map(f => `- ${f.key} (${f.tab}): ${f.label}${f.options ? `; one of ${f.options.map(o => JSON.stringify(o)).join(', ')}` : `; e.g. ${f.hint}`}`),
557 `The mascot is Clawd, Claude Code's own pixel crab, never the 🦀 emoji. mascot draws him above the prompt (animated: blinks idle, scuttles while working; a stock loop name plays that loop while working and glances about while idle: scuttle, hop, wave, cheer, think, snooze, peek, idle; or, only if the user wants something other than Clawd, a friend that plays its loop while working and holds still while idle: ${Object.keys(FRIENDS).join(", ")}), and that is the one place he goes. Only if the user asks for him somewhere specific, {clawd} is a one-row Clawd (animated in spinnerSuffix). Claude's colours are warm: #d77757 orange, #c15f3c rust, #f0eee6 cream.`,
558 'footer replaces the row under the prompt (the line with "? for shortcuts"); segments split on " · ", tokens {branch} {context} {ctxbar} {5h} {7d} {cost} plus the template ones. Clawd is never drawn in the footer.',
559 'Lists are comma-separated. Colours are names (green, magenta, gray, ...) or #rrggbb. Template tokens: {model} {cwd} {path} {time} {date}; in doneTemplate {word} is the done word and {time} the turn length.',
560 'replyRewrites is display-only find/replace on Claude\'s replies: "a=>b; /re/flags=>c".',
561 `Presets to borrow from: ${JSON.stringify(PRESETS)}`,
562 `Current non-default settings: ${JSON.stringify(changed(s))}`,
563].join('\n')
564
565// The model's reply is untrusted: take the outermost {...}, then keep known string keys only.
566export const parseChange = (text: string) => {
567 try {
568 return clean(JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)))
569 } catch {
570 return undefined
571 }
572}
573hooks/crabs.ts 225 lines1// Stock Clawd loops, from the "Crab, at work" sheet: eight loops built only from Clawd's own glyphs.
2// Every frame is 4 rows; row 0 is headroom for hops, thought bubbles and z's.
3
4type Pose = { e?: keyof typeof EYES; a?: 'up' | 'down' | 'wave' | 'wave2'; l?: string }
5type Stamp = readonly [rows: readonly string[], x: number, y: number]
6export type Crab = { ms: number; frames: string[][] }
7
8const EYES = { n: '▛███▜', b: '█████', l: '▛██▛█', r: '█▜██▜', u: '▙███▟', s: '▀███▀' }
9const LA = ' ▘▘ ▝▝ '
10const LB = ' ▝▝ ▘▘ '
11
12const crab = (o: Pose = {}) => {
13 const e = EYES[o.e ?? 'n']
14 let r0 = ` ▐${e}▌ `
15 let r1 = '▝▜█████▛▘'
16 if (o.a === 'up') [r0, r1] = [`▗▐${e}▌▖`, ' ▜█████▛ ']
17 if (o.a === 'wave') [r0, r1] = [`▗▐${e}▌ `, ' ▜█████▛▘']
18 if (o.a === 'wave2') [r0, r1] = [`▝▐${e}▌ `, ' ▜█████▛▘']
19 if (o.a === 'down') r1 = '▖▜█████▛▗'
20 return [r0, r1, o.l ?? LA]
21}
22
23const canvas = (w: number, h: number, stamps: readonly Stamp[]) => {
24 const g = Array.from({ length: h }, () => Array<string>(w).fill(' '))
25 for (const [rows, x, y] of stamps) {
26 rows.forEach((row, ri) => [...row].forEach((ch, ci) => {
27 const line = g[y + ri]
28 if (ch !== ' ' && line && x + ci >= 0 && x + ci < w) line[x + ci] = ch
29 }))
30 }
31 return g.map(r => r.join(''))
32}
33
34const rep = <T>(f: T, n: number): T[] => Array<T>(n).fill(f)
35const C = (o: Pose, w = 9, extra: Stamp[] = [], x = 0, y = 1) => canvas(w, 4, [[crab(o), x, y], ...extra])
36
37const scuttle: string[][] = []
38for (let x = 0; x <= 17; x++) scuttle.push(C({ e: 'r', l: x % 2 ? LB : LA }, 26, [], x))
39scuttle.push(C({}, 26, [], 17), C({ e: 'b' }, 26, [], 17))
40for (let x = 17; x >= 0; x--) scuttle.push(C({ e: 'l', l: x % 2 ? LB : LA }, 26, [], x))
41scuttle.push(C({}, 26, [], 0), C({ e: 'b' }, 26, [], 0))
42
43const b1: Stamp = [['·'], 10, 1]
44const b2: Stamp = [['o'], 11, 0]
45const b3: Stamp = [['O'], 13, 0]
46const z1: Stamp = [['z'], 10, 1]
47const z2: Stamp = [['Z'], 12, 0]
48const z3: Stamp = [['z'], 14, 0]
49const P = (o: Pose, y: number) => canvas(11, 4, [[crab(o), 1, y], [['───────────'], 0, 3]])
50
51const CLAWDS: Record<string, Crab> = {
52 idle: { ms: 200, frames: [
53 ...rep(C({}), 8), C({ e: 'b' }), ...rep(C({}), 6), ...rep(C({ e: 'l' }), 4), ...rep(C({}), 2),
54 ...rep(C({ e: 'r' }), 4), ...rep(C({}), 3), C({ e: 'b' }), C({}), C({ e: 'b' }), ...rep(C({}), 5),
55 ] },
56 scuttle: { ms: 110, frames: scuttle },
57 hop: { ms: 120, frames: [
58 ...rep(C({}), 4), C({ a: 'down', l: LB }), C({ a: 'up', e: 'u' }, 9, [], 0, 0), C({ a: 'up', e: 'u' }, 9, [], 0, 0),
59 C({ e: 'u' }, 9, [], 0, 0), C({ a: 'down', l: LB }), ...rep(C({}), 3),
60 ] },
61 wave: { ms: 170, frames: [
62 ...Array.from({ length: 6 }, () => [C({ a: 'wave', e: 'u' }), C({ a: 'wave2', e: 'u' })]).flat(),
63 C({}), C({}), C({ e: 'b' }), ...rep(C({}), 5),
64 ] },
65 cheer: { ms: 160, frames: [
66 ...Array.from({ length: 5 }, () => [C({ a: 'up', e: 'u', l: LA }), C({ a: 'down', e: 'u', l: LB })]).flat(),
67 ...rep(C({}), 4),
68 ] },
69 think: { ms: 300, frames: [
70 C({}, 15), C({ e: 'u' }, 15), C({ e: 'u' }, 15, [b1]), C({ e: 'u' }, 15, [b1, b2]),
71 ...rep(C({ e: 'u' }, 15, [b1, b2, b3]), 4), C({ e: 'b' }, 15, [b1, b2, b3]), C({ e: 'u' }, 15, [b1, b2, b3]),
72 C({ e: 'u' }, 15, [b2, b3]), C({ e: 'u' }, 15, [b3]), C({}, 15),
73 ] },
74 snooze: { ms: 400, frames: [
75 C({ e: 's' }, 16), C({ e: 's', a: 'down' }, 16, [z1]), C({ e: 's' }, 16, [z1, z2]),
76 C({ e: 's', a: 'down' }, 16, [z2, z3]), C({ e: 's' }, 16, [z3]), C({ e: 's', a: 'down' }, 16),
77 ] },
78 peek: { ms: 160, frames: [
79 ...rep(P({}, 3), 4), ...rep(P({}, 2), 2), ...rep(P({ e: 'l' }, 2), 3), ...rep(P({ e: 'r' }, 2), 3),
80 P({}, 2), P({}, 1), P({}, 0), ...rep(P({}, 0), 2), P({ e: 'b' }, 0), ...rep(P({}, 0), 3),
81 P({ e: 'u', a: 'up' }, 0), P({ e: 'u', a: 'up' }, 0), P({}, 0), P({}, 1), P({}, 2), ...rep(P({}, 3), 2),
82 ] },
83}
84
85// Friends, from the "Mega pack" sheet: twenty non-Clawd loops in the same 4-row box. Idle they hold their first frame.
86const M = (n: number, m: number) => ((n % m) + m) % m
87const tri = (i: number, a: number, b: number) => {
88 const s = b - a
89 const p = i % (2 * s)
90 return a + (p <= s ? p : 2 * s - p)
91}
92const range = <T>(n: number, f: (i: number) => T) => Array.from({ length: n }, (_, i) => f(i))
93const S = (rows: readonly string[], x = 0, y = 0): Stamp => [rows, x, y]
94const F = (w: number, ...s: Stamp[]) => canvas(w, 4, s)
95// 8 half-row levels per column, packed into 4 rows of ▀▄█.
96const halfRows = (w: number, cols: number[][]) => {
97 const g = Array.from({ length: 8 }, () => Array<number>(w).fill(0))
98 cols.forEach((c, x) => c.forEach(l => { if (l >= 0 && l < 8 && x < w) g[l]![x] = 1 }))
99 return range(4, r => range(w, x => {
100 const t = g[2 * r]![x]
101 const b = g[2 * r + 1]![x]
102 return t && b ? '█' : t ? '▀' : b ? '▄' : ' '
103 }).join(''))
104}
105
106const BTU = ['▚▖ ▄ ▗▞', ' ▜███▛ ', ' ▝ ▘ ']
107const BTD = [' ▄ ', '▄▟███▙▄', '▀ ▝ ▘ ▀']
108const BN = [' ▐▐ ', ' ▗▄▄█ █▖', '▐██████▘']
109const BC = [' ▗▗ ', BN[1]!, BN[2]!]
110const BJ = [...BN, ' ▘ ▘']
111const JA = ['▗▄███▄▖', '▀▀▀▀▀▀▀', ' ▚ ▞ ▚ ']
112const JB = ['▗▄███▄▖', '▀▀▀▀▀▀▀', ' ▞ ▚ ▞ ']
113const JC = [' ▄███▄ ', ' ▝███▘ ', ' ▐▐▌ ', ' ▌ ▐ ']
114const DK = [' ▗▄ ', ' █ █▄', '▙▄▄▟██▀ ']
115const DP = ['', '', '▙▄▄▟██▄▖']
116const TOP = ['███', '▄█▄', '▗▄▖', ' ▖ ', ' ']
117const BOT = [' ', ' ▄ ', '▗█▖', '▟█▙', '███']
118const POT = ' ▜███▛ '
119const STAGES = [['', '', ' ▖'], ['', '', ' ▌'], ['', ' ▌', ' ▚▌'], ['', ' ▌▞', ' ▚▌'], [' ▖', ' ▚▌▞', ' ▚▌'], [' ▟█▙', ' ▚▌▞', ' ▚▌']]
120const FLAME = [[' ▗ ', ' ▟▓▖ ', ' ▐▓█▒▌ '], [' ▖ ', ' ▗▓▙ ', ' ▐▒█▓▌ '], [' ▗ ', ' ▟▒▓▖ ', ' ▐▓█▓▌ '], [' ▖ ▗ ', ' ▗▓█▙ ', ' ▐█▓▒▌ ']]
121const MOON = ['▗██▖', '████', '▝██▘']
122const LIT = [[], [3], [2, 3], [1, 2, 3], [0, 1, 2, 3], [0, 1, 2], [0, 1], [0]]
123const BEAT = [5, 5, 5, 5, 5, 5, 4, 5, 5, 6, 0, 7, 5, 5, 4, 4, 5, 5, 5, 5, 5, 5]
124const SPD = [1, 2, 1, 3, 2, 1, 3, 2, 1]
125const OFF = [0, 3, 5, 1, 6, 2, 4, 0, 3]
126
127const dog = (t: boolean, e: boolean) => F(11, S([`${t ? '▖' : ' '} ▄▄▄ `, `${t ? ' ' : '▝'}▄▄▄▄▄${e ? '█▄█' : '█ █'}▙`, ' ██████▀▀ ', ' ▌▐ ▌▐ ']))
128const jelly = (r: string[], y: number) => F(7, S(r, 0, y))
129const ripple = (i: number) => range(22, x => '▁▂▃▂'[(x + i) % 4]).join('')
130const hourglass = (s: number) => F(7, S(['▀▀▀▀▀▀▀', ` ▚${TOP[s]}▞ `, ` ▞${BOT[s]}▚ `, '▄▄▄▄▄▄▄']))
131const sine = (x: number) => Math.round(3.5 + 3.5 * Math.sin(x * 0.55))
132
133export const FRIENDS: Record<string, Crab> = {
134 snail: { ms: 120, frames: range(102, i => {
135 const k = Math.floor(i / 3) % 2
136 return F(24, S([k ? ' ▄▀▀▄ ▖▗' : ' ▄▀▀▄ ▗▖', ' █ ▄▖ █ ▐▌', '▄█▄▄▄▄█▄█▘'], M(Math.floor(i / 3), 34) - 10, 1))
137 }) },
138 bat: { ms: 140, frames: range(32, i => F(15, S(i % 2 ? BTD : BTU, tri(i, 0, 8), i % 2))) },
139 spider: { ms: 200, frames: range(24, i => {
140 const y = Math.floor(tri(i, 0, 11) / 4)
141 return F(5, ...range(y, r => S(['│'], 2, r)), S(i % 2 ? ['╱▟█▙╲', '╲▀▀▀╱'] : ['╲▟█▙╱', '╱▀▀▀╲'], 0, y))
142 }) },
143 bunny: { ms: 110, frames: range(12, c => {
144 const x = -8 + 3 * c
145 return [...rep(F(26, S(BN, x, 1)), 3), F(26, S(BC, x, 1)), F(26, S(BJ, x + 1, 0)), F(26, S(BJ, x + 2, 0)), F(26, S(BC, x + 3, 1))]
146 }).flat() },
147 dog: { ms: 110, frames: range(24, i => dog(i % 2 === 1, i === 9 || i === 20)) },
148 penguin: { ms: 150, frames: range(48, i => {
149 const k = i % 2
150 return F(18, S([' ▗▄▄▖ ', '▐█▘▝█▌', k ? '▞▌ ▐▌' : '▐▌ ▐▚', k ? ' ▀▘ ▀ ' : ' ▀ ▝▀ '], M(Math.floor(i / 2), 24) - 6))
151 }) },
152 snake: { ms: 90, frames: range(34, i => {
153 const cols = range(24, (): number[] => [])
154 for (let k = 0; k < 9; k++) {
155 const x = i - k - 2
156 if (x < 0 || x >= 24) continue
157 cols[x]!.push(sine(x))
158 if (k === 0) cols[x]!.push(Math.min(7, sine(x) + 1))
159 }
160 if (i % 2 && i >= 1 && i <= 24) cols[i - 1]!.push(sine(i - 2))
161 return halfRows(24, cols)
162 }) },
163 jellyfish: { ms: 180, frames: [jelly(JA, 1), jelly(JB, 1), jelly(JA, 1), jelly(JB, 1), jelly(JC, 0), jelly(JC, 0), jelly(JB, 0), jelly(JA, 0), jelly(JB, 1), jelly(JA, 1)] },
164 duck: { ms: 160, frames: range(24, i => F(22, S([ripple(i)], 0, 3), S(i >= 16 && i < 19 ? DP : DK, 7))) },
165 turtle: { ms: 110, frames: range(102, i => {
166 const k = Math.floor(i / 3) % 2
167 return F(24, S([' ▗▄▄▄▖ ', ' ▟▛▚▞▜▙ ▄▖', '▝▀▀▀▀▀▀▀█▘', k ? ' ▀▘ ▝▀ ' : ' ▀▘▝▀ '], M(Math.floor(i / 3), 34) - 10))
168 }) },
169 hourglass: { ms: 160, frames: [...range(5, s => rep(hourglass(s), 4)).flat(), ...rep(F(7, S(['', '█▙▄▄▄▟█', '█▛▀▀▀▜█'])), 2)] },
170 plant: { ms: 200, frames: [
171 ...STAGES.flatMap(s => rep(F(7, S([...s, POT])), 3)),
172 ...range(10, i => F(7, S([i % 2 ? ' ▟█▙' : ' ▟█▙', ' ▚▌▞', ' ▚▌', POT]))),
173 ] },
174 campfire: { ms: 120, frames: range(12, i => F(7, S([...FLAME[i % 4]!, ' ▀▚▄▞▀ ']), ...(i % 5 === 2 ? [S(['·'], i % 2 ? 1 : 5)] : []))) },
175 rain: { ms: 110, frames: range(32, i => {
176 const bolt = i === 26 || i === 27
177 const cloud = [' ▗▄██▄▄▖ ', '▐████████▌'].map(s => bolt ? s.replace(/█/g, '▓') : s)
178 const drops = [2, 3].map(y => range(10, x => x > 0 && x < 9 && M(x * 3 + y - i, 4) === 0 ? '╷' : ' ').join(''))
179 return F(10, S(cloud), ...(bolt ? [S(['▞', '▘'], 5, 2)] : [S(drops, 0, 2)]))
180 }) },
181 moon: { ms: 220, frames: range(24, i => {
182 const lit = LIT[Math.floor(i / 3)]!
183 const tw = i % 2
184 const rows = MOON.map(r => [...r].map((c, ci) => lit.includes(ci) ? c : c === '█' ? '░' : ' ').join(''))
185 return F(12, S(rows, 4), S([tw ? '*' : '·'], 1, 1), S([tw ? '·' : '*'], 10), S(['·'], 9, 3), S([tw ? '·' : ' '], 2, 3))
186 }) },
187 equalizer: { ms: 90, frames: range(60, i => {
188 const cols = range(15, (): number[] => [])
189 for (let b = 0; b < 8; b++) {
190 const h = Math.max(1, Math.min(8, Math.round(4.5 + 3.5 * Math.sin(i * 0.35 * (1 + b * 0.17) + b * 1.3))))
191 for (let l = 8 - h; l < 8; l++) cols[b * 2]!.push(l)
192 }
193 return halfRows(15, cols)
194 }) },
195 heartbeat: { ms: 70, frames: range(22, i => halfRows(22, range(22, x => {
196 const a = BEAT[(x + i) % 22]!
197 const b = BEAT[(x + i + 21) % 22]!
198 return range(Math.abs(a - b) + 1, k => Math.min(a, b) + k)
199 }))) },
200 screensaver: { ms: 90, frames: range(76, i => F(24, S(['▗▄▄▄▖', '▝▀▀▀▘'], tri(i, 0, 19), tri(i, 0, 2)))) },
201 'binary-rain': { ms: 100, frames: range(84, i => {
202 const g = range(4, () => Array<string>(18).fill(' '))
203 for (let c = 0; c < 9; c++) {
204 const head = M(Math.floor(i / SPD[c]!) + OFF[c]!, 7)
205 for (let y = Math.max(0, head - 2); y <= Math.min(3, head); y++) {
206 g[y]![c * 2] = ((c * 7 + y * 3 + Math.floor(i / 3)) * 2654435761 >>> 0) % 2 ? '1' : '0'
207 }
208 }
209 return g.map(r => r.join(''))
210 }) },
211 train: { ms: 80, frames: range(53, i => {
212 const x = i - 22
213 const w = i % 2 ? 'o' : '0'
214 const car = ['▗▄▄▄▄▖', '▐████▌', `▝${w}▀▀${w}▘`]
215 const smoke = range(3, k => i - i % 3 - 3 * k).filter(e => e >= 0).map(e => S([i - e < 3 ? '▒' : '░'], e - 7))
216 return F(30, ...smoke, S(car, x, 1), S(['─'], x + 6, 2), S(car, x + 7, 1), S(['─'], x + 13, 2), S([' ▐▌ ▄▄▄ ', '▄██▄█ █▙', `▀${w}▀▀▀${w}▀▘`], x + 14, 1))
217 }) },
218}
219
220// Every loop mascot can name: Clawd's own, then the friends.
221export const CRABS: Record<string, Crab> = { ...CLAWDS, ...FRIENDS }
222
223// Elapsed ms to the frame showing then.
224export const crabFrame = (c: Crab, ms: number) => c.frames[Math.floor(ms / c.ms) % c.frames.length] ?? []
225types/index.d.ts 39 lines1export type ClawdifySettings = {
2 spinnerVerbs: string
3 spinnerThinking: string
4 spinnerTools: string
5 spinnerResponding: string
6 spinnerSuffix: string
7 doneVerbs: string
8 doneTemplate: string
9 doneColor: string
10 doneToastSecs: string
11 hint: string
12 hintTail: string
13 modeLabel: string
14 footer: string
15 footerColor: string
16 backgroundHint: string
17 banner: string
18 bannerColor: string
19 bannerBorder: string
20 bannerAlign: string
21 mascot: string
22 mascotColor: string
23 userPrefix: string
24 userColor: string
25 replyRewrites: string
26 expandToolGroups: string
27 hideNotices: string
28}
29
30// -1 = not known yet.
31export type ClawdifyUsage = { branch: string; context: number; limit5h: number; limit7d: number; cost: number }
32export type ClawdifyContext = ClawdifyUsage & { cwd: string; model: string; now: number }
33
34declare module 'claude-code' {
35 interface PluginState {
36 clawdify: { settings: ClawdifySettings; presets: Record<string, Partial<ClawdifySettings>>; tab: string; context: ClawdifyContext; frame: number } // frame: ms of Clawd animation so far
37 }
38}
39