SLOPSHOPPER

clawdify

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

newpanebandspinnerrowscommand
v0.7.0MITupdated 2026-10-02viik2k/clawdify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawdify
│ ┃ clawdify ✕ › fix the failing auth test and add an audit log call │ ┃ [ spinner ] [ turn ] [ prompt ] [ banner ] [ │ ┃ ⏺ Read(src/auth.ts) │ ┃ Words : Clawing, Scuttling, P ⎿ Read 6 lines │ ┃ While thinking : Pondering, Brooding ⏎ ⏺ Update(src/auth.ts) │ ┃ While using tools : Tinkering, Wrenching ⎿ Added 2 lines, removed 1 line │ ┃ While responding : Typing, Scribbling ⏎ ⏺ Bash(bun test) │ ┃ Suffix : ~ (replaces the …; ⎿ 3 pass, 1 fail │ ┃ │ ┃ Empty = Claude Code default · lists are ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ comma-separated · tokens {model} {cwd} │ ┃ {time} {date} ✻ Worked for 42s · done 4:20 PM │ │ › /clawdify │ ⎿ clawdify: clawdify open. Enter saves a field; an empty field res │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · clawdify
[ spinner ] [ turn ] [ prompt ] [ banner ] [ transcript ] [ Words : Clawing, Scuttling, Pinching ⏎ save While thinking : Pondering, Brooding ⏎ save While using tools : Tinkering, Wrenching ⏎ save While responding : Typing, Scribbling ⏎ save Suffix : ~ (replaces the …; {clawd} = a scut Empty = Claude Code default · lists are comma-separated · tokens {model} {cwd} {time} {date}
README

clawdify 🦀

clawdify in a Claude Code terminal: Clawd in a banner above the prompt, with a custom footer underneath

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

Install

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.

Use

CommandWhat it does
/clawdifyOpen the editor pane (keys 1–7 switch tabs)
/clawdify <anything>Describe what you want; Claude picks the settings
/clawdify getList 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 reloadPick 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.

Settings

TabKeys
spinnerspinnerVerbs, spinnerThinking, spinnerTools, spinnerResponding, spinnerSuffix
turndoneVerbs, doneTemplate, doneColor, doneToastSecs
promptfooter (your own row under the prompt), footerColor, hint, hintTail, modeLabel, backgroundHint (none hides it)
bannerbanner, bannerColor, bannerBorder, bannerAlign, mascot (still or animated Clawd, or a stock loop), mascotColor
transcriptuserPrefix, userColor, replyRewrites, expandToolGroups, hideNotices
  • Lists are comma-separated: Clawing, Scuttling, Pinching.
  • Stock Clawd loops for mascot: scuttle, hop, wave, cheer, think, snooze, peek, idle. The loop plays while Claude works; idle, Clawd blinks and glances about.
  • Or swap Clawd for a friend: 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.
  • Clawd stays above the prompt (mascot). Want him elsewhere? {clawd} is a one-row Clawd (▐▛███▜▌), animated in spinnerSuffix; the footer never draws him.
  • Templates take {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.
  • Colours are names (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.

Notes

  • /clawdify <anything> makes one small Sonnet call on your account. Its answer is filtered to known settings before anything is saved.
  • Claude Code's own startup header (the logo with version, model, plan and folder) stays. Mods can't draw over it yet; hideNotices only hides the dim lines under it.

Develop

claude plugin validate .
claude plugin test .

MIT licensed.

Source 4 files
hooks/register.tsx 505 lines
1import { 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}
505
hooks/settings.ts 573 lines
1import 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}
573
hooks/crabs.ts 225 lines
1// 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] ?? []
225
types/index.d.ts 39 lines
1export 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