/docs pane for repo Markdown, /receipt turn receipts, /park notes, secret-shield redaction of tool output, blast-radius confirms for destructive Bash, /pet…

A Claude Code mod. /docs opens a pane that lists every Markdown file (.md, .mdx, .markdown) in the current repo, plus .html and .txt files. Click a file to read it (Markdown is rendered, the others are shown as code), click < Back to return to the list.
node_modules, .git, .next, dist, build and coverage are skipped.
Worktrees under .claude/worktrees/ show as a collapsed group. Click a worktree to scan and list its files.
If the repo has a package.json, Markdown files in ~/.claude/release-notes/<package name>/ are listed too, newest version first, under "Release notes".
Shows a one-line receipt under each answer: how long the turn took, how many tool calls it made, and the tokens when the API reports them. On by default; /receipt toggles it.
Redacts AWS, GitHub, Slack, Anthropic and OpenAI keys, private key blocks, and password= / token= style values from tool output before Claude reads it. A "shield: redacted N secrets" toast says when it did.
Holds destructive Bash commands (rm -r, git reset --hard, git push --force, git clean -f, DROP TABLE, kubectl delete) and asks you to confirm. For rm it shows what the command would hit. No answer in 60 s cancels it. It is a regex matcher, so a quoted or aliased command can slip past.
Saves where you left off (note, branch, last prompt) to ~/.claude/park/<repo>.json. The next session in that repo shows a "Parked: <note> (2d ago)" band above the prompt with Dismiss. /park clear deletes it.
Toggles code-pet, a small emoji pet above the prompt. It eats while tools run, gets sick on errors, cheers after a turn and naps after 60 s idle.
Swaps Claude's spinner verbs for a themed word pack. The choice is remembered in ~/.claude/spinner-pack. Only the live spinner changes, not the past-tense "Baked for 3s" line.
Toggles a fire-style meter above the prompt (🔥 flames, ▁..▇ bar, token count) showing the tokens spent this session. Tiers at 50k, 200k and 500k tokens. It resets on hot reload.
At the prompt of a terminal session:
/plugin install repo-docs --marketplace prithivrajmu/claude-docs-viewer
From a shell:
claude plugin install repo-docs --marketplace prithivrajmu/claude-docs-viewer
Answer y to add the marketplace, then pick a scope.
claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.tsx 230 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { DocsView } from '../types'
5import { register as secretShield } from './secret-shield'
6import { register as turnReceipt, startReceipt } from './turn-receipt'
7import { register as blastRadius } from './blast-radius'
8import { register as park, startPark } from './park'
9import { register as codePet, startPet } from './code-pet'
10import { register as spinnerPacks, startSpinners } from './spinner-packs'
11import { register as burn, startBurn } from './burn-meter'
12
13const PANE = 'repo-docs'
14const WORKTREES = '.claude/worktrees'
15const SKIP = new Set(['node_modules', '.git', '.next', 'dist', 'build', 'coverage'])
16const DOC = /\.(md|mdx|markdown)$/i // drawn as Markdown
17const WANTED = /\.(md|mdx|markdown|html?|txt)$/i // the rest is drawn as code
18const MAX_CHARS = 90000 // one drawing holds 100000 characters of Markdown
19const NOTES_ROOT = '.claude/release-notes' // under the home folder, one subfolder per package.json name
20const EMPTY: DocsView = { files: [], notes: [], worktrees: [], openWt: '', wtFiles: [], path: null, text: '', note: '' }
21const view = atom({ plugin: 'repo-docs', key: 'view' } as const, EMPTY)
22
23// Paths are relative to the session's working directory, so a worktree session lists its own files.
24// `root` is '' for the working directory or a worktree's folder under it.
25// ponytail: scans on /docs and on Refresh only; add a watcher if that is not enough.
26const scan = async ($: any, root = ''): Promise<string[]> => {
27 const out: string[] = []
28 const walk = async (dir: string): Promise<void> => {
29 for (const f of await $.fs.list(dir || undefined).catch(() => [])) {
30 const p = dir ? `${dir}/${f.name}` : f.name
31 if (f.kind === 'dir') {
32 if (!SKIP.has(f.name) && p !== WORKTREES) await walk(p)
33 } else if (f.kind === 'file' && WANTED.test(f.name)) out.push(p)
34 }
35 }
36 await walk(root)
37 const first = root ? `${root}/CHANGELOG.md` : 'CHANGELOG.md'
38 return out.sort((a, b) => (a === first ? -1 : b === first ? 1 : a.localeCompare(b)))
39}
40
41// Worktree folder names only; each one is scanned when opened.
42const scanWorktrees = async ($: any): Promise<string[]> =>
43 (await $.fs.list(WORKTREES).catch(() => []))
44 .filter((f: any) => f.kind === 'dir')
45 .map((f: any) => f.name as string)
46 .sort((a: string, b: string) => a.localeCompare(b))
47
48// Numeric-aware, so 1.10.0 sorts after 1.9.0.
49const byVersion = (a: string, b: string): number => {
50 const x = a.split(/(\d+)/)
51 const y = b.split(/(\d+)/)
52 for (let i = 0; i < Math.min(x.length, y.length); i++) {
53 if (x[i] === y[i]) continue
54 const n = i % 2 === 1 ? Number(x[i]) - Number(y[i]) : (x[i] ?? '') < (y[i] ?? '') ? -1 : 1
55 if (n !== 0) return n
56 }
57 return x.length - y.length
58}
59
60// A missing or empty folder is just no group.
61const scanNotes = async ($: any): Promise<string[]> => {
62 const home = await $.env.get('HOME')
63 const name = await packageName($)
64 if (!home || !name) return []
65 const dir = `${home}/${NOTES_ROOT}/${name}`
66 const entries = await $.fs.list(dir).catch(() => [])
67 return entries
68 .filter((f: any) => f.kind === 'file' && f.name.endsWith('.md'))
69 .map((f: any) => `${dir}/${f.name}`)
70 .sort((a: string, b: string) => byVersion(b, a))
71}
72
73const packageName = async ($: any): Promise<string> => {
74 try {
75 return String(JSON.parse(await $.fs.read('package.json')).name ?? '')
76 } catch {
77 return ''
78 }
79}
80
81const loadDoc = async ($: any, path: string) => {
82 let text = ''
83 let note = ''
84 try {
85 text = await $.fs.read(path)
86 if (text.length > MAX_CHARS) {
87 text = text.slice(0, MAX_CHARS)
88 note = 'Cut at 90000 characters.'
89 }
90 } catch {
91 note = `Cannot read ${path}.`
92 }
93 await update($, view, old => ({ ...old, path, text, note }))
94}
95
96// Doc paths a reply mentions become links that open in the pane. Only files that exist are linked.
97const LINK = 'file:///__docs__/'
98const MENTION = /(?<![\w/.\]()-])((?:\.{0,2}\/)?[\w.-]+(?:\/[\w.-]+)*\.(?:md|mdx|markdown|html?|txt))(?![\w/\]()-])/g
99const linkDocs = async ($: any, text: string) => {
100 const found = [...new Set([...text.matchAll(MENTION)].map(m => m[1]))].slice(0, 20)
101 const real = (await Promise.all(found.map(p => $.fs.exists(p).then((ok: boolean) => (ok ? p : ''), () => '')))).filter(Boolean)
102 if (!real.length) return { text, paths: [] as string[] }
103 const hit = new Set(real)
104 return { text: text.replace(MENTION, (m: string, p: string) => (hit.has(p) ? `[${m}](${LINK}${encodeURI(p)})` : m)), paths: real }
105}
106
107const docs: Register = on => {
108 on('session.start', async ($, e, next) => {
109 await $.command.register({
110 name: 'docs',
111 description: 'Browse Markdown files (and .html/.txt) in the repo and its worktrees in a pane',
112 })
113 await startReceipt($)
114 await startPark($)
115 await startPet($)
116 await startSpinners($)
117 await startBurn($)
118 return next(e)
119 })
120
121 on('command.run', { command: 'docs' }, async $ => {
122 const files = await scan($)
123 const notes = await scanNotes($)
124 const worktrees = await scanWorktrees($)
125 await update($, view, () => ({ ...EMPTY, files, notes, worktrees }))
126 await $.ui.open({ id: PANE, title: 'Docs' })
127 return { text: `Docs pane opened: ${files.length} files, ${notes.length} release notes, ${worktrees.length} worktrees.` }
128 }).catch(() => ({ text: 'Docs pane could not open.' }))
129
130 // Click a doc a reply mentions to read it in the pane.
131 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
132 const { text, paths } = await linkDocs($, e.props.text)
133 if (!paths.length) return next(e)
134 const { Box, Text, Markdown } = $.ui.resolve(e)
135 return (
136 <Box>
137 {e.props.isFirstOfReply && <Text>● </Text>}
138 <Markdown
139 key="reply"
140 text={text}
141 onLinkPress={async link => {
142 await loadDoc($, decodeURI(link.href.slice(LINK.length)))
143 await $.ui.open({ id: PANE, title: 'Docs' })
144 }}
145 pressableLinks={paths.map(p => `${LINK}${encodeURI(p)}`)}
146 />
147 </Box>
148 )
149 })
150
151 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
152 const { Box, Text, Button, Markdown, Code } = $.ui.resolve(e)
153 // $.state outlives a hot reload, so a value saved by an older version may lack newer fields.
154 const v: DocsView = { ...EMPTY, ...(await read($, view)) }
155
156 const openFile = (path: string) => loadDoc($, path)
157 const back = () => update($, view, old => ({ ...old, path: null, text: '', note: '' }))
158 const wtPrefix = `${WORKTREES}/${v.openWt}/`
159 const refresh = async () => {
160 const files = await scan($)
161 const notes = await scanNotes($)
162 const worktrees = await scanWorktrees($)
163 const openWt = worktrees.includes(v.openWt) ? v.openWt : ''
164 const wtFiles = openWt ? await scan($, `${WORKTREES}/${openWt}`) : []
165 await update($, view, old => ({ ...old, files, notes, worktrees, openWt, wtFiles }))
166 }
167 const toggle = async (name: string) => {
168 if (v.openWt === name) return update($, view, old => ({ ...old, openWt: '', wtFiles: [] }))
169 await update($, view, old => ({ ...old, openWt: name, wtFiles: [] }))
170 const wtFiles = await scan($, `${WORKTREES}/${name}`)
171 await update($, view, old => (old.openWt === name ? { ...old, wtFiles } : old))
172 }
173
174 if (v.path === null) {
175 return (
176 <Box flexDirection="column">
177 <Box>
178 <Text dimColor>{v.files.length + v.notes.length} files. Click one to read it. </Text>
179 <Button key="refresh" label="Refresh" onPress={refresh} />
180 </Box>
181 {v.files.length + v.notes.length === 0 && <Text dimColor>No Markdown files here.</Text>}
182 {v.notes.length > 0 && <Text bold>Release notes</Text>}
183 {v.notes.map(path => (
184 <Button key={path} label={path.split('/').pop()} onPress={() => openFile(path)} />
185 ))}
186 {v.files.length > 0 && <Text bold>Repo docs</Text>}
187 {v.files.map(path => (
188 <Button key={path} label={path} onPress={() => openFile(path)} />
189 ))}
190 {v.worktrees.length > 0 && <Text bold>Worktrees ({v.worktrees.length})</Text>}
191 {v.worktrees.flatMap(name => [
192 <Button key={`wt:${name}`} label={`${v.openWt === name ? 'v' : '>'} ${name}`} onPress={() => toggle(name)} />,
193 ...(v.openWt === name
194 ? v.wtFiles.length === 0
195 ? [<Text key={`wt-empty:${name}`} dimColor> No docs found (or still scanning).</Text>]
196 : v.wtFiles.map(path => (
197 <Button key={path} label={` ${path.slice(wtPrefix.length)}`} onPress={() => openFile(path)} />
198 ))
199 : []),
200 ])}
201 </Box>
202 )
203 }
204
205 return (
206 <Box flexDirection="column">
207 <Box>
208 <Button key="back" label="< Back" variant="primary" onPress={back} />
209 <Text bold> {v.path}</Text>
210 </Box>
211 {v.note !== '' && <Text dimColor>{v.note}</Text>}
212 {DOC.test(v.path) ? <Markdown text={v.text} /> : <Code source={v.text} path={v.path} />}
213 </Box>
214 )
215 })
216}
217
218// The engine takes one hooks module per plugin, so each mod is a file of its own, registered here.
219// secret-shield comes right after /docs so it is in place before any mod that reads tool output.
220export const register: Register = (on, options) => {
221 docs(on, options)
222 secretShield(on, options)
223 turnReceipt(on, options)
224 blastRadius(on, options)
225 park(on, options)
226 codePet(on, options)
227 spinnerPacks(on, options)
228 burn(on, options)
229}
230hooks/secret-shield.tsx 47 lines1import type { Register } from 'claude-code'
2
3// One list: [pattern, replacement]. A `$1` keeps the key of a key=value pair and hides only its value.
4const SECRETS: [RegExp, string][] = [
5 [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED private key]'],
6 [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, '[REDACTED aws key]'],
7 [/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{40,})/g, '[REDACTED github token]'],
8 [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED slack token]'],
9 [/\bsk-ant-[\w-]{20,}/g, '[REDACTED anthropic key]'],
10 [/\bsk-(?:proj-)?[\w-]{32,}/g, '[REDACTED openai key]'],
11 [/\b((?:password|passwd|secret|token|api[_-]?key)\w*["']?\s*[=:]\s*["']?)[^\s"',;]{4,}/gi, '$1[REDACTED]'],
12]
13
14// Returns the cleaned text and how many secrets it held.
15export const redact = (text: string): { text: string; count: number } => {
16 let count = 0
17 for (const [re, to] of SECRETS) {
18 text = text.replace(re, (...m) => {
19 if (m[0].endsWith('[REDACTED]')) return m[0] // already hidden by an earlier pattern
20 count++
21 return to.replace('$1', m[1] ?? '')
22 })
23 }
24 return { text, count }
25}
26
27export const register: Register = on => {
28 // Rewrites the tool result before it is stored, so Claude only ever reads the cleaned form.
29 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
30 let count = 0
31 const content = e.message.content.map((b: any) => {
32 if (b.type !== 'tool_result') return b
33 const fix = (s: string) => {
34 const r = redact(s)
35 count += r.count
36 return r.text
37 }
38 if (typeof b.content === 'string') return { ...b, content: fix(b.content) }
39 if (Array.isArray(b.content))
40 return { ...b, content: b.content.map((c: any) => (c.type === 'text' ? { ...c, text: fix(c.text) } : c)) }
41 return b
42 })
43 if (count > 0) $.ui.toast(`shield: redacted ${count} secret${count > 1 ? 's' : ''}`)
44 return next({ ...e, message: { ...e.message, content } })
45 }).catch(($, e, next) => next(e)) // on failure the row is stored unchanged, never dropped
46}
47hooks/turn-receipt.tsx 52 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4// ponytail: the on/off flag is keyed under 'repo-docs'; types/index.d.ts must declare `isReceiptOn: boolean` there.
5const isOn = atom({ plugin: 'repo-docs', key: 'isReceiptOn' } as const, true)
6
7type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
8
9const k = (n: number): string => (n < 1000 ? String(n) : `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`)
10
11// "Took 12s - 3 tool calls - 1.2k in / 340 out"; tokens are left out when the API gave none.
12export const formatReceipt = (ms: number, tools: number, usage?: Usage): string => {
13 const s = Math.round(ms / 1000)
14 const parts = [`Took ${s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`}`, `${tools} tool call${tools === 1 ? '' : 's'}`]
15 if (usage) {
16 const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
17 parts.push(`${k(input)} in / ${k(usage.output_tokens)} out`)
18 }
19 return parts.join(' - ')
20}
21
22// Called from register.tsx's one session.start hook (the engine takes one unmatched hook per event).
23export async function startReceipt($: any): Promise<void> {
24 await $.command.register({ name: 'receipt', description: 'Turn the one-line receipt under each answer on or off' })
25}
26
27export const register: Register = on => {
28 let tools = 0 // main-loop tool calls since the turn began; subagent turns are not counted apart
29
30 on('command.run', { command: 'receipt' }, async $ => {
31 const next = !(await read($, isOn))
32 await update($, isOn, () => next)
33 return { text: `Turn receipt ${next ? 'on' : 'off'}.` }
34 })
35
36 on('turn.start', async ($, e, next) => {
37 if (e.agentId === undefined) tools = 0
38 return next(e)
39 })
40
41 on('tool.call', ($, e, next) => {
42 tools += 1
43 return next(e)
44 }).catch(($, e, next) => next(e)) // a failing hook passes the event on unchanged
45
46 on('turn.complete', async ($, e, next) => {
47 const res = await next(e)
48 if (e.agentId !== undefined || !(await read($, isOn))) return res
49 return { ...res, text: formatReceipt(e.durationMs, tools, e.usage) }
50 })
51}
52hooks/blast-radius.tsx 54 lines1import type { Register } from 'claude-code'
2
3const ASK_TIMEOUT_MS = 60_000
4const MAX_TARGETS = 5
5const MAX_ENTRIES = 5
6
7// Pure: the name of the destructive thing the command does, or null.
8// ponytail: regex over the raw string; a quoted or aliased command can slip past. Add a shell parser if that matters.
9export const destructive = (cmd: string): string | null => {
10 const rules: [string, RegExp][] = [
11 ['rm -r', /\brm\s+(?:-\w+\s+)*(?:-\w*[rR]\w*|--recursive)\b/],
12 ['git reset --hard', /\bgit\s+reset\b[^;&|]*--hard\b/],
13 ['git push --force', /\bgit\s+push\b[^;&|]*\s(?:--force(?![-\w])|-\w*f\w*(?=\s|$))/],
14 ['git clean -f', /\bgit\s+clean\b[^;&|]*\s-\w*f/],
15 ['DROP TABLE', /\bdrop\s+table\b/i],
16 ['kubectl delete', /\bkubectl\s+delete\b/],
17 ]
18 return rules.find(([, re]) => re.test(cmd))?.[0] ?? null
19}
20
21// Paths after the first `rm` (flags dropped), up to the next ; & |.
22const rmTargets = (cmd: string): string[] =>
23 (cmd.match(/\brm\s+([^;&|]*)/)?.[1] ?? '')
24 .split(/\s+/)
25 .filter(t => t && !t.startsWith('-'))
26 .slice(0, MAX_TARGETS)
27
28export const register: Register = on => {
29 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
30 const what = destructive(e.command)
31 if (!what) return next(e)
32
33 let hit = ''
34 if (what === 'rm -r') {
35 const lines: string[] = []
36 for (const t of rmTargets(e.command)) {
37 const list = await $.fs.list(t).catch(() => null) // null: a file, a glob or missing
38 lines.push(list ? `${t}/ (${list.length} entries: ${list.slice(0, MAX_ENTRIES).map(f => f.name).join(', ')}${list.length > MAX_ENTRIES ? ', ...' : ''})` : t)
39 }
40 hit = lines.length ? `\nHits: ${lines.join('; ')}` : ''
41 }
42
43 const q = `Destructive command (${what}): ${e.command.slice(0, 300)}${hit}\nRun it? Auto-cancels in 60 s. Allow?`
44 const answer = await Promise.race([
45 $.ui.ask(q, ['Allow', 'Cancel']).catch(() => 'Cancel'),
46 $.clock.sleep(ASK_TIMEOUT_MS).then(() => 'timeout', () => 'Cancel'),
47 ])
48 return answer === 'Allow' ? next(e) : { deny: `${$.plugin.name}: ${what} ${answer === 'timeout' ? 'timed out waiting for confirmation' : 'cancelled'}.` }
49 }).catch(($, e, next) =>
50 // Fail closed, but only for what the matcher flags.
51 destructive(e.command) ? { deny: `${$.plugin.name}: could not confirm ${destructive(e.command)}.` } : next(e),
52 )
53}
54hooks/park.tsx 88 lines1import type { Register } from 'claude-code'
2
3const DIR = '.claude/park' // under the home folder, one <repo-name>.json per repo
4
5type Park = { note: string; cwd: string; branch: string; prompt: string; at: number }
6
7export const parkPath = (home: string, cwd: string): string =>
8 `${home}/${DIR}/${cwd.replace(/\/+$/, '').split('/').pop() || 'root'}.json` // ponytail: folder name, not git root; a worktree parks under its own name
9
10export const timeAgo = (at: number, now: number): string => {
11 const m = Math.max(0, Math.floor((now - at) / 60000))
12 if (m < 1) return 'just now'
13 if (m < 60) return `${m}m ago`
14 if (m < 1440) return `${Math.floor(m / 60)}h ago`
15 return `${Math.floor(m / 1440)}d ago`
16}
17
18async function file($: any): Promise<string> {
19 return parkPath(await $.env.get('HOME'), await $.session.cwd())
20}
21
22let parked: Park | null = null
23let lastPrompt = ''
24
25// Called from register.tsx's one session.start hook (the engine takes one unmatched hook per event).
26export async function startPark($: any): Promise<void> {
27 await $.command.register({ name: 'park', description: 'Save where you left off: /park [note], /park clear' })
28 try {
29 parked = JSON.parse(await $.fs.read(await file($)))
30 $.ui.invalidate('ui.render')
31 } catch {
32 parked = null // no file, or unreadable: nothing parked
33 }
34}
35
36export const register: Register = on => {
37 on('prompt.submit', ($, e, next) => {
38 if (!e.command) lastPrompt = e.text // a slash command is not where you left off
39 return next(e)
40 }).catch(($, e, next) => next(e)) // a failing hook passes the event on unchanged
41
42 on('command.run', { command: 'park' }, async ($, e) => {
43 const path = await file($)
44 if (e.args.trim() === 'clear') {
45 await $.process.run(['rm', '-f', path])
46 parked = null
47 $.ui.invalidate('ui.render')
48 return { text: 'Park cleared.' }
49 }
50 const git = await $.process.run(['git', 'branch', '--show-current']).catch(() => null)
51 const park: Park = {
52 note: e.args.trim(),
53 cwd: await $.session.cwd(),
54 branch: git?.exitCode === 0 ? git.stdout.trim() : '',
55 prompt: lastPrompt,
56 at: await $.clock.now(),
57 }
58 await $.fs.write(path, JSON.stringify(park, null, 2))
59 return { text: `Parked${park.note ? `: ${park.note}` : ''} (${park.branch || 'no branch'}).` }
60 })
61
62 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63 if (e.props.hasSurvey || parked === null) return next(e)
64 const { Box, Button, Text } = $.ui.resolve(e)
65 const ago = timeAgo(parked.at, await $.clock.now())
66 const below = await next(e) // stack on top of the bands beneath (pet, burn) instead of hiding them
67 return (
68 <Box flexDirection="column">
69 <Box>
70 <Text dimColor>
71 Parked: {parked.note || '(no note)'} ({ago}){' '}
72 </Text>
73 <Button
74 key="dismiss"
75 label="Dismiss"
76 onPress={async () => {
77 await $.process.run(['rm', '-f', await file($)])
78 parked = null
79 $.ui.invalidate('ui.render')
80 }}
81 />
82 </Box>
83 {below}
84 </Box>
85 )
86 })
87}
88hooks/code-pet.tsx 90 lines1import type { Register } from 'claude-code'
2
3export type Mood = { name: 'idle' | 'eat' | 'sick' | 'happy' | 'sleep'; at: number }
4export type PetEvent = 'tool.start' | 'tool.ok' | 'tool.fail' | 'turn.done' | 'wake' | 'tick'
5
6const SICK_MS = 5000 // sick and happy last this long, then back to idle
7const SLEEP_MS = 60000 // idle this long falls asleep
8
9// The pet's frames: one line per mood.
10export const FRAMES: Record<Mood['name'], string> = {
11 idle: '🐱 ...',
12 eat: '😋🍖 munch munch',
13 sick: '🤢 ugh, that failed',
14 happy: '😸 done!',
15 sleep: '😴 z z Z',
16}
17
18// Pure state machine; `now` is milliseconds since the epoch. Same mood object back means no change.
19export const nextMood = (mood: Mood, event: PetEvent, now: number): Mood => {
20 const to = (name: Mood['name']): Mood => (name === mood.name ? mood : { name, at: now })
21 switch (event) {
22 case 'tool.start':
23 return to('eat')
24 case 'tool.ok':
25 return mood.name === 'eat' ? to('idle') : mood // keep sick/happy showing
26 case 'tool.fail':
27 return to('sick')
28 case 'turn.done':
29 return mood.name === 'sick' ? mood : to('happy') // a failure stays visible
30 case 'wake':
31 return mood.name === 'sleep' ? to('idle') : mood
32 case 'tick':
33 if ((mood.name === 'sick' || mood.name === 'happy') && now - mood.at >= SICK_MS) return { name: 'idle', at: now }
34 if (mood.name === 'idle' && now - mood.at >= SLEEP_MS) return to('sleep')
35 return mood
36 }
37}
38
39// ponytail: module variables, not an atom, so no types/index.d.ts entry; /pet resets on a hot reload.
40let mood: Mood = { name: 'idle', at: 0 }
41let enabled = true
42let timer: { cancel: () => void } | undefined
43
44async function feed($: any, event: PetEvent): Promise<void> {
45 const next = nextMood(mood, event, await $.clock.now())
46 if (next === mood) return
47 mood = next
48 $.ui.invalidate('ui.render')
49}
50
51// Called from register.tsx's one session.start hook (the engine takes one unmatched hook per event).
52export async function startPet($: any): Promise<void> {
53 await $.command.register({ name: 'pet', description: 'Show or hide the pet above the prompt' })
54 mood = { name: 'idle', at: await $.clock.now() }
55 timer?.cancel()
56 timer = $.clock.every(1000, () => feed($, 'tick'))
57}
58
59export const register: Register = on => {
60 on('command.run', { command: 'pet' }, async $ => {
61 enabled = !enabled
62 $.ui.invalidate('ui.render')
63 return { text: `Pet ${enabled ? 'on' : 'off'}.` }
64 })
65
66 on('tool.call', { tool: /./ }, async ($, e, next) => {
67 await feed($, 'tool.start')
68 const res = await next(e)
69 await feed($, res.isError ? 'tool.fail' : 'tool.ok')
70 return res
71 }).catch(($, e, next) => next(e))
72
73 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
74 if (e.agentId === undefined) await feed($, 'turn.done')
75 return next(e)
76 }).catch(($, e, next) => next(e))
77
78 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
79 if (!enabled || e.props.hasSurvey) return next(e)
80 const { Box, Text } = $.ui.resolve(e)
81 const below = await next(e) // stack on top of the bands beneath (burn) instead of hiding them
82 return (
83 <Box flexDirection="column">
84 <Text>{FRAMES[mood.name]}</Text>
85 {below}
86 </Box>
87 )
88 })
89}
90hooks/spinner-packs.tsx 47 lines1import type { Register } from 'claude-code'
2
3const FILE = '.claude/spinner-pack' // under the home folder; holds the pack name, absent for off
4
5export const PACKS: Record<string, string[]> = {
6 pirate: ['Plundering', 'Hoisting', 'Swabbing', 'Charting', 'Boarding', 'Parleying', 'Marauding', 'Careening', 'Pillaging'],
7 shakespeare: ['Pondering', 'Soliloquizing', 'Thou-ing', 'Bewailing', 'Prating', 'Conjuring', 'Beseeching', 'Muttering', 'Wooing'],
8 space: ['Orbiting', 'Docking', 'Launching', 'Thrusting', 'Warping', 'Landing', 'Drifting', 'Rendezvousing', 'Ascending'],
9 noir: ['Sleuthing', 'Lurking', 'Stalking', 'Interrogating', 'Brooding', 'Tailing', 'Trench-coating', 'Smoldering', 'Staking out'],
10}
11
12// Same seed, same word, so a redraw does not flicker. Unknown pack: no word.
13export const pickWord = (pack: string, seed: string | number): string | null => {
14 const words = PACKS[pack]
15 if (!words) return null
16 const n = typeof seed === 'number' ? seed : [...seed].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7)
17 return words[Math.abs(Math.trunc(n)) % words.length]
18}
19
20let current = '' // '' is off
21
22// Called from register.tsx's one session.start hook (the engine takes one unmatched hook per event).
23export async function startSpinners($: any): Promise<void> {
24 await $.command.register({ name: 'spinner', description: 'Themed spinner words: /spinner <pirate|shakespeare|space|noir|off|list>' })
25 const saved = (await $.fs.read(`${await $.env.get('HOME')}/${FILE}`).catch(() => '')).trim()
26 current = saved in PACKS ? saved : ''
27}
28
29export const register: Register = on => {
30 on('command.run', { command: 'spinner' }, async ($, e) => {
31 const arg = e.args.trim().toLowerCase()
32 if (arg === 'list' || arg === '') {
33 return { text: `Packs: ${Object.keys(PACKS).join(', ')}, off. Now: ${current || 'off'}.` }
34 }
35 if (arg !== 'off' && !(arg in PACKS)) return { text: `Unknown pack "${arg}". Try /spinner list.` }
36 current = arg === 'off' ? '' : arg
37 await $.fs.write(`${await $.env.get('HOME')}/${FILE}`, current)
38 return { text: `Spinner pack: ${current || 'off'}.` }
39 }).catch(() => ({ text: 'Spinner pack could not be changed.' }))
40
41 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
42 const word = current ? pickWord(current, e.props.word) : null
43 // a message override (a state's own text) is left alone
44 return word && e.props.message === null ? next({ ...e, props: { ...e.props, word } }) : next(e)
45 }).catch(($, e, next) => next(e)) // a failing hook passes the event on unchanged
46}
47hooks/burn-meter.tsx 59 lines1import type { Register } from 'claude-code'
2
3const BARS = '▁▂▃▄▅▆▇'
4const TIERS = [
5 { max: 50_000, flames: '🔥', color: '#4fc3f7' }, // cool
6 { max: 200_000, flames: '🔥🔥', color: '#ffd54f' }, // warm
7 { max: 500_000, flames: '🔥🔥🔥', color: '#ff9800' }, // hot
8 { max: Infinity, flames: '🔥🔥🔥🔥', color: '#f44336' }, // inferno
9]
10
11const k = (n: number): string => (n < 1000 ? String(n) : `${(n / 1000).toFixed(n < 100_000 ? 1 : 0).replace(/\.0$/, '')}k`)
12
13const tier = (tokens: number) => TIERS.find(t => tokens < t.max) ?? TIERS[TIERS.length - 1]
14
15// "🔥🔥 ▃ 84k tokens"; the bar fills up to 500k. Tokens only, no plan-quota numbers.
16export const meter = (tokens: number): string => {
17 const bar = BARS[Math.min(BARS.length - 1, Math.floor((tokens / 500_000) * BARS.length))]
18 return `${tier(tokens).flames} ${bar} ${k(tokens)} tokens`
19}
20
21let spent = 0 // tokens this session, main loop and subagents; a hot reload starts it over
22let isOn = true
23
24// Called from register.tsx's one session.start hook (the engine takes one unmatched hook per event).
25export async function startBurn($: any): Promise<void> {
26 await $.command.register({ name: 'burn', description: 'Show or hide the session token burn meter above the prompt' })
27}
28
29export const register: Register = on => {
30 on('command.run', { command: 'burn' }, async $ => {
31 isOn = !isOn
32 $.ui.invalidate('ui.render')
33 return { text: `Burn meter ${isOn ? 'on' : 'off'}.` }
34 })
35
36 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
37 const u = e.usage
38 if (u) {
39 // matcher: turn-receipt owns the one unmatched turn.complete hook; aborted/error turns are not counted.
40 // same sum as turn-receipt: fresh input + cache reads + cache writes + output
41 spent += u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
42 $.ui.invalidate('ui.render')
43 }
44 return next(e)
45 }).catch(($, e, next) => next(e)) // a failing hook passes the event on unchanged
46
47 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
48 if (e.props.hasSurvey || !isOn || spent === 0) return next(e)
49 const { Box, Text } = $.ui.resolve(e)
50 const below = await next(e) // keep whatever is beneath this band
51 return (
52 <Box flexDirection="column">
53 <Text color={tier(spent).color}>{meter(spent)}</Text>
54 {below}
55 </Box>
56 )
57 })
58}
59types/index.d.ts 23 lines1export type DocsView = {
2 /** .md/.mdx/.markdown/.html/.txt paths relative to the working directory, scanned when /docs ran. Not node_modules, .git or other build folders. */
3 files: string[]
4 /** Folder names under .claude/worktrees. */
5 worktrees: string[]
6 /** The worktree opened in the list, or ''. */
7 openWt: string
8 /** The opened worktree's files, relative to the working directory. */
9 wtFiles: string[]
10 /** Release notes (*.md) kept outside the repo in ~/.claude/release-notes/<package.json name>, absolute paths, newest version first. */
11 notes: string[]
12 /** The file being read, or null for the list. */
13 path: string | null
14 text: string
15 note: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'repo-docs': { view: DocsView; isReceiptOn: boolean }
21 }
22}
23