SLOPSHOPPER

repo-docs

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

newpanebandspinnerrowsguard
v0.3.0no licenseupdated 2026-10-15prithivrajmu/claude-docs-viewer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-docs
│ ┃ Docs ✕ › fix the failing auth test and add an ╭───────────────────────────╮ │ ┃ 0 files. Click one to read it. [ Refresh ] │ repo-docs │ │ ┃ No Markdown files here. ⏺ Read(src/auth.ts) │ shield: redacted 1 secret │ │ ⎿ Read 6 lines ╰───────────────────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by repo-docs: repo-docs: rm -r cancelled. │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ Took 42s - 9 tool calls - 97.4k in / 1.5k out │ │ › /docs │ ⎿ repo-docs: Docs pane opened: 0 files, 0 release notes, 0 worktre │ │ 🔥🔥 ▂ 98.9k tokens ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🔥🔥 ▂ 98.9k tokens ⟨Claude Code's own drawing⟩
Pane · Docs
0 files. Click one to read it. [ Refresh ] No Markdown files here.
README

claude-docs-viewer

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".

/receipt

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.

secret-shield

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.

blast-radius

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.

/park [note]

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.

/pet

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.

/spinner <pirate|shakespeare|space|noir|off|list>

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.

/burn

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.

Install

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.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 9 files
hooks/register.tsx 230 lines
1import { 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}
230
hooks/secret-shield.tsx 47 lines
1import 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}
47
hooks/turn-receipt.tsx 52 lines
1import { 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}
52
hooks/blast-radius.tsx 54 lines
1import 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}
54
hooks/park.tsx 88 lines
1import 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}
88
hooks/code-pet.tsx 90 lines
1import 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}
90
hooks/spinner-packs.tsx 47 lines
1import 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}
47
hooks/burn-meter.tsx 59 lines
1import 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}
59
types/index.d.ts 23 lines
1export 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