SLOPSHOPPER

clawd-buddy

A small Claude Code mascot above the prompt, with usage, context, cache, directory and git status

newpanebandguardstatusprocess
★ 1v0.6.0MITupdated 2026-10-05prabowosd/clawd-buddy/plugins/clawd-buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-buddy
│ ┃ clawd-image ✕ › fix the failing auth test and add an audit log call │ ┃ No image yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ 5h ━━──── 31% 7d – ctx 49% cache 29333393:20 ▗▟▛███▛█ /work/app ▐██████▀ Opus 5.5 · 200K ▝▝ ▝▝ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ━━──── 31% 7d – ctx 49% cache 29333393:20 ▗▟▛███▛█ /work/app ▐██████▀ Opus 5.5 · 200K ▝▝ ▝▝
Pane · clawd-image
No image yet.
README

clawd-buddy

A small mascot and a two line status band above the Claude Code prompt.

The left side shows how much of your usage window is gone, how full the context is, how long the prompt cache stays warm, which directory you are in, and the git state of that directory. The right side is a small mascot that blinks while idle and waves while Claude works. It is three rows tall, so the band is three rows too.

5h ━━────  3%   7d ━─────  7%   ctx 30%   cache 59:12
~/www/app   main ~1 ?2 ↑1 wt:app-topic

The mascot is the same one Claude Code draws at startup, in quadrant-block characters and 24-bit colour, so there are no images and nothing to download.

Install

Inside Claude Code:

/plugin marketplace add prabowosd/clawd-buddy
/plugin install clawd-buddy@clawd-buddy
/reload-plugins

To try it without installing, clone the repo and start Claude Code with:

claude --plugin-dir plugins/clawd-buddy

It was built and tested on Claude Code 2.1.289, on macOS, in a terminal. The band is only drawn by Claude Code on the terminal and desktop surfaces.

What it shows

ItemMeaning
modelThe main loop's model, as /model shows it, in the mascot's colour on its own row under the directory and git state, then the context window and the effort level in grey: Opus 4.8 · 1M · high. The id is shortened, so claude-opus-4-8[1m] reads Opus 4.8, and a name that is no known family is cut at 28 characters. The effort shows after the first request of the session. A band with room for only two rows puts all of it at the end of the second row.
5h, 7dRate limit usage for the 5 hour and 7 day windows, as a bar and a percentage. Green below 70%, amber from 70%, red from 90%. A dash means Claude Code has not reported a value yet.
ctxShare of the context window in use, coloured the same way.
cacheCountdown of the prompt cache. It restarts after each response and turns amber in the last minute. cold means it has expired or nothing has been sent yet.
directoryThe session directory, with your home folder shortened to ~.
gitBranch, then ✓ when clean. Otherwise +n staged, ~n modified, ?n untracked, ↑n ahead and ↓n behind. Inside a linked worktree the worktree name follows as wt:name.

The mascot has three moods: idle (blinks every couple of seconds), working (waves and shuffles its feet while a turn runs) and done (waves for three seconds after a turn finishes).

Behaviour worth knowing

  • The cache countdown assumes a 1 hour cache. There is no setting for the 5 minute cache yet.
  • Git is read with git status --porcelain=v1 -b and git rev-parse. Both are read only, --no-optional-locks keeps status from taking the index lock, and nothing touches the network. It refreshes when a turn ends and every 30 seconds, so a cd can take up to 30 seconds to show.
  • Outside a git repository only the directory is shown.
  • If the terminal gives the band less room than the mascot needs, only its top row is drawn.
  • The band steps aside while Claude Code is showing a survey.
  • The font needs the block elements and box drawing ranges (U+2580 and U+2500). Most terminal fonts have them.

Images

When Claude sends you an image with the SendUserFile tool (PNG, JPEG, GIF, WebP, HEIC, BMP or TIFF, at most five per call):

  • In a terminal that can draw pictures (kitty, Ghostty) the last image shows in an Image pane. A PNG is read as it is, anything else is converted once with sips into /tmp/clawd-buddy. Preview opens too, but only when Claude sent the file with display: render.
  • In any other terminal (Warp, iTerm, Terminal) every image opens in the default viewer with open, so on macOS it lands in Preview.

To force the pane in a terminal that is not guessed right, start Claude Code with CLAWD_BUDDY_IMAGES=pane. CLAWD_BUDDY_IMAGES=viewer always uses the default viewer.

Other files are left alone. If a viewer is missing or fails the file still reaches you and nothing is shown. Images are handled after the tool call succeeds. The pane needs a wide terminal to seat on its own, so on a narrow one it waits.

Layout

.claude-plugin/marketplace.json     marketplace entry
plugins/clawd-buddy/
  .claude-plugin/plugin.json        plugin manifest
  hooks/register.tsx                hooks, state and the band
  hooks/sprite.ts                   the mascot frames
  hooks/stats.ts                    bars, colours, the clock
  hooks/git.ts                      git and worktree parsing
  hooks/images.ts                   which sent files count as images
  hooks/*.test.ts                   tests
  types/index.d.ts                  state types

The mascot lives in sprite.ts as plain strings, one character per pixel, so changing it is a matter of editing a few rows.

Development

claude plugin validate .
claude plugin test plugins/clawd-buddy

For a live loop, keep the plugin in a folder Claude Code watches for mods and edit it while a session is open. It reloads when a turn ends.

License

MIT

Source 6 files
hooks/register.tsx 287 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ctx, GitInfo, Limit, Mood, Picture } from '../types'
5import { parseGit, parseWorktree, shortPath } from './git'
6import { asAbsolute, drawsImages, imagesToOpen, isPng } from './images'
7import { BODY, BODY_W, sprite } from './sprite'
8import { AMBER, GREEN, bar, clockText, levelColor, shortModel, windowText } from './stats'
9
10
11const mood = atom({ plugin: 'clawd-buddy', key: 'mood' } as const, 'idle' as Mood)
12const moodAt = atom({ plugin: 'clawd-buddy', key: 'moodAt' } as const, 0)
13const now = atom({ plugin: 'clawd-buddy', key: 'now' } as const, 0)
14const cacheAt = atom({ plugin: 'clawd-buddy', key: 'cacheAt' } as const, null as number | null)
15const ctx = atom({ plugin: 'clawd-buddy', key: 'ctx' } as const, null as Ctx | null)
16const limits = atom({ plugin: 'clawd-buddy', key: 'limits' } as const, [] as Limit[])
17const cwd = atom({ plugin: 'clawd-buddy', key: 'cwd' } as const, '')
18const git = atom({ plugin: 'clawd-buddy', key: 'git' } as const, null as GitInfo | null)
19const picture = atom({ plugin: 'clawd-buddy', key: 'picture' } as const, null as Picture | null)
20const model = atom({ plugin: 'clawd-buddy', key: 'model' } as const, '')
21const effort = atom({ plugin: 'clawd-buddy', key: 'effort' } as const, '')
22
23const PANE = 'clawd-image'
24
25const BRANCH = '#8AB4F8'
26const WORKTREE = '#C792EA'
27const FRAME_MS = 250
28const GIT_REFRESH_MS = 30_000
29const DONE_MS = 3_000
30// Fixed 1h prompt cache TTL. Add a userConfig option if 5m is needed.
31const CACHE_TTL_MS = 60 * 60_000
32
33// Reads the directory and its git state; a missing repo or a slow git just leaves it blank.
34async function refreshGit($: EngineInterface) {
35  const dir = await $.session.cwd()
36  await update($, cwd, () => dir)
37  try {
38    const r = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v1', '-b'], { cwd: dir, timeoutMs: 5_000 })
39    const w = await $.process.run(['git', 'rev-parse', '--git-dir', '--git-common-dir'], { cwd: dir, timeoutMs: 5_000 })
40    const info = r.exitCode === 0 ? parseGit(r.stdout) : null
41    await update($, git, () => (info ? { ...info, worktree: w.exitCode === 0 ? parseWorktree(w.stdout) : null } : null))
42  } catch {
43    await update($, git, () => null)
44  }
45}
46
47// The model as /model shows it; a failed read keeps the last name.
48async function refreshModel($: EngineInterface) {
49  try {
50    const name = await $.session.model()
51    await update($, model, () => name)
52  } catch {
53    // keep what is shown
54  }
55}
56
57// Only the main loop sets the effort shown; a subagent's own level is left out.
58async function noteEffort($: EngineInterface, level: string | undefined, agentId: string | undefined) {
59  if (level && !agentId) await update($, effort, () => level)
60}
61
62async function terminalDraws($: EngineInterface) {
63  try {
64    const r = await $.process.run(['sh', '-c', 'printf "%s|%s|%s|%s" "$TERM_PROGRAM" "$TERM" "$KITTY_WINDOW_ID" "$CLAWD_BUDDY_IMAGES"'], { timeoutMs: 2_000 })
65    const [program = '', term = '', kitty = '', force = ''] = r.stdout.split('|')
66    return drawsImages({ program, term, kitty, force })
67  } catch {
68    return false
69  }
70}
71
72// The pane draws PNG only: anything else is converted once with sips into a temp file.
73async function pngFor($: EngineInterface, file: string) {
74  if (isPng(file)) return file
75  const out = `/tmp/clawd-buddy/${await $.clock.now()}.png`
76  try {
77    await $.process.run(['mkdir', '-p', '/tmp/clawd-buddy'], { timeoutMs: 2_000 })
78    const r = await $.process.run(['sips', '-s', 'format', 'png', file, '--out', out], { timeoutMs: 10_000 })
79    return r.exitCode === 0 ? out : null
80  } catch {
81    return null
82  }
83}
84
85export const register: Register = on => {
86  on('session.start', async ($, e, next) => {
87    await $.ui.status(undefined)
88    const u = await $.session.usage()
89    await update($, ctx, () => u.context)
90    await update($, limits, () => u.rateLimits)
91    $.clock.every(FRAME_MS, async () => {
92      const t = await $.clock.now()
93      await update($, now, () => t)
94    })
95    $.clock.every(GIT_REFRESH_MS, () => Promise.all([refreshGit($), refreshModel($)]))
96    void refreshGit($)
97    void refreshModel($)
98    return next(e)
99  })
100
101  on('session.measure', async ($, e, next) => {
102    await update($, ctx, () => e.context)
103    if (e.rateLimits.length) await update($, limits, () => e.rateLimits)
104    if (e.changed.includes('context')) {
105      const t = await $.clock.now()
106      await update($, cacheAt, () => t)
107    }
108    return next(e)
109  })
110
111  // The main loop's effort, read off each model request; a subagent's own is left out.
112  on('turn.step', async function* ($, e, next) {
113    if (!e.agentId && e.effort !== undefined) await update($, effort, () => String(e.effort))
114    return yield* next(e)
115  })
116
117  // A second source: the classic hooks carry the effective level too, after any downgrade for the model.
118  on('classic.PostToolUse', async ($, e, next) => {
119    await noteEffort($, e.effort?.level, e.agent_id)
120    return next(e)
121  })
122
123  on('classic.Stop', async ($, e, next) => {
124    await noteEffort($, e.effort?.level, e.agent_id)
125    return next(e)
126  })
127
128  on('turn.start', async ($, e, next) => {
129    await update($, mood, () => 'working' as Mood)
130    void refreshModel($)
131    return next(e)
132  })
133
134  on('turn.complete', async ($, e, next) => {
135    if (e.agentId) return next(e)
136    const t = await $.clock.now()
137    await update($, moodAt, () => t)
138    await update($, mood, () => (e.isAborted || e.reason === 'error' ? 'idle' : 'done') as Mood)
139    void refreshGit($)
140    void refreshModel($)
141    return next(e)
142  })
143
144  // Where the terminal can draw, a sent image shows in a pane and Preview opens only for display=render.
145  // Elsewhere every image opens in the default viewer. Best effort: no viewer, no harm.
146  on('tool.call', { tool: 'SendUserFile' }, async ($, e, next) => {
147    const sent = await next(e)
148    const images = imagesToOpen(e.files)
149    if (sent.deny !== undefined || sent.isError === true || images.length === 0) return sent
150
151    let viewer = images
152    if (await terminalDraws($)) {
153      const shown = asAbsolute(await $.session.cwd(), images[images.length - 1])
154      const png = await pngFor($, shown)
155      if (png) {
156        await update($, picture, p => ({ file: png, name: shown.split('/').pop() ?? shown, n: (p?.n ?? 0) + 1 }))
157        void $.ui.open({ id: PANE, title: 'Image' })
158        viewer = e.display === 'render' ? images : []
159      }
160    }
161    if (viewer.length) {
162      try {
163        await $.process.run(['open', ...viewer], { timeoutMs: 5_000 })
164      } catch {
165        // open is missing or refused: the file still reached the user.
166      }
167    }
168    return sent
169  })
170
171  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
172    const { Box, Text, Image } = $.ui.resolve(e)
173    const p = await read($, picture)
174    if (!p) return <Text dimColor>No image yet.</Text>
175    const columns = Math.max(10, Math.min(120, (e.viewport?.columns ?? 60) - 2))
176    const rows = Math.max(4, Math.min(60, (e.viewport?.rows ?? 24) - 4))
177    return (
178      <Box flexDirection="column">
179        <Text dimColor>{p.name}</Text>
180        <Image key="view" source={{ file: p.file, format: 'png', generation: p.n }} columns={columns} rows={rows} alt={p.name} />
181      </Box>
182    )
183  })
184
185  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186    if (e.props.hasSurvey) return next(e)
187    const { Box, Text } = $.ui.resolve(e)
188    const t = await read($, now)
189    const stored = await read($, mood)
190    const doneAt = await read($, moodAt)
191    const shown: Mood = e.props.isWorking ? 'working' : stored === 'done' && t - doneAt < DONE_MS ? 'done' : 'idle'
192    const tick = Math.floor(t / FRAME_MS)
193    const rows = sprite(shown, tick)
194    // A band shorter than the mascot shows its top row only, so it is not scrolled away.
195    if (e.props.maxRows < 1) return next(e)
196    const shownRows = e.props.maxRows < rows.length ? rows.slice(0, 1) : rows
197
198    const rl = await read($, limits)
199    const c = await read($, ctx)
200    const at = await read($, cacheAt)
201    const left = at === null ? 0 : CACHE_TTL_MS - (t - at)
202    const warm = at !== null && left > 0
203    const meters = [
204      { kind: 'five_hour', label: '5h' },
205      { kind: 'seven_day', label: '7d' },
206    ].map(s => ({ ...s, lim: rl.find(l => l.kind === s.kind) }))
207    const dir = await read($, cwd)
208    const g = await read($, git)
209    const modelName = await read($, model)
210    // A third row needs the band to be 3 rows tall; otherwise the model joins the second row.
211    const modelOwnRow = e.props.maxRows >= 3
212    const effortName = await read($, effort)
213    const detail = [c?.window ? windowText(c.window) : '', effortName].filter(Boolean).join(' · ')
214    const modelText =
215      modelName === '' && detail === '' ? null : (
216        <Text>
217          <Text color={BODY}>{modelName === '' ? '' : shortModel(modelName)}</Text>
218          {detail === '' ? null : <Text dimColor>{modelName === '' ? detail : ` · ${detail}`}</Text>}
219        </Text>
220      )
221    const isClean = g !== null && g.staged + g.modified + g.untracked === 0
222
223    return (
224      <Box flexDirection="row" alignItems="center" justifyContent="space-between" paddingX={1}>
225        <Box flexDirection="column">
226        <Box flexDirection="row" gap={3}>
227          {meters.map(s => (
228            <Text key={s.kind}>
229              <Text dimColor>{s.label} </Text>
230              {s.lim ? (
231                <Text>
232                  <Text color={levelColor(s.lim.percentUsed)}>{bar(s.lim.percentUsed).on}</Text>
233                  <Text dimColor>{bar(s.lim.percentUsed).off}</Text>
234                  <Text color={levelColor(s.lim.percentUsed)}> {Math.round(s.lim.percentUsed)}%</Text>
235                </Text>
236              ) : (
237                <Text dimColor>–</Text>
238              )}
239            </Text>
240          ))}
241          <Text>
242            <Text dimColor>ctx </Text>
243            <Text color={c?.percent === undefined ? undefined : levelColor(c.percent)} dimColor={c?.percent === undefined}>
244              {c?.percent === undefined ? '–' : `${c.percent}%`}
245            </Text>
246          </Text>
247          <Text>
248            <Text dimColor>cache </Text>
249            <Text color={!warm ? undefined : left < 60_000 ? AMBER : GREEN} dimColor={!warm}>
250              {warm ? clockText(left) : 'cold'}
251            </Text>
252          </Text>
253        </Box>
254        <Box flexDirection="row" gap={2}>
255          <Text dimColor>{dir === '' ? '–' : shortPath(dir)}</Text>
256          {g && (
257            <Text>
258              <Text color={BRANCH}>{g.branch}</Text>
259              {g.worktree ? <Text color={WORKTREE}> wt:{g.worktree}</Text> : null}
260              {isClean ? <Text color={GREEN}> ✓</Text> : null}
261              {g.staged > 0 ? <Text color={GREEN}> +{g.staged}</Text> : null}
262              {g.modified > 0 ? <Text color={AMBER}> ~{g.modified}</Text> : null}
263              {g.untracked > 0 ? <Text dimColor> ?{g.untracked}</Text> : null}
264              {g.ahead > 0 ? <Text dimColor> ↑{g.ahead}</Text> : null}
265              {g.behind > 0 ? <Text dimColor> ↓{g.behind}</Text> : null}
266            </Text>
267          )}
268          {modelOwnRow ? null : modelText}
269        </Box>
270        {modelOwnRow ? modelText : null}
271        </Box>
272        <Box width={BODY_W} flexDirection="column">
273          {shownRows.map((row, i) => (
274            <Text key={i}>
275              {row.map((cell, x) => (
276                <Text key={x} color={cell.color} backgroundColor={cell.backgroundColor}>
277                  {cell.ch}
278                </Text>
279              ))}
280            </Text>
281          ))}
282        </Box>
283      </Box>
284    )
285  })
286}
287
hooks/git.ts 30 lines
1import type { GitInfo } from '../types'
2
3/** Home directory prefix becomes `~`. */
4export const shortPath = (cwd: string) => cwd.replace(/^(\/Users|\/home)\/[^/]+/, '~')
5
6/** Reads `git status --porcelain=v1 -b` output. */
7export function parseGit(stdout: string): GitInfo | null {
8  const [head, ...files] = stdout.split('\n')
9  if (!head?.startsWith('## ')) return null
10  const branch = head.slice(3).replace(/^No commits yet on /, '').split('...')[0]?.split(' ')[0] ?? ''
11  const count = (re: RegExp) => Number(re.exec(head)?.[1] ?? 0)
12  const info: GitInfo = { branch, staged: 0, modified: 0, untracked: 0, ahead: count(/ahead (\d+)/), behind: count(/behind (\d+)/), worktree: null }
13  for (const line of files) {
14    if (!line) continue
15    if (line.startsWith('??')) info.untracked += 1
16    else {
17      if (line[0] !== ' ') info.staged += 1
18      if (line[1] !== ' ') info.modified += 1
19    }
20  }
21  return info
22}
23
24/** A linked worktree has a git dir apart from the shared one; its admin folder carries the name. */
25export function parseWorktree(stdout: string): string | null {
26  const [gitDir, common] = stdout.split('\n')
27  if (!gitDir || !common || gitDir === common) return null
28  return gitDir.split('/').filter(Boolean).pop() ?? null
29}
30
hooks/images.ts 21 lines
1const IMAGE = /\.(png|jpe?g|gif|webp|heic|bmp|tiff?)$/i
2const MAX_OPEN = 5
3
4// Image paths worth opening: a known extension, never one that could pass for an option.
5export function imagesToOpen(files: readonly string[]): string[] {
6  return files.filter(f => IMAGE.test(f) && !f.startsWith('-')).slice(0, MAX_OPEN)
7}
8
9export const isPng = (file: string): boolean => /\.png$/i.test(file)
10
11export function asAbsolute(dir: string, file: string): string {
12  return file.startsWith('/') ? file : `${dir.replace(/\/$/, '')}/${file}`
13}
14
15// Only kitty and Ghostty draw an Image element. CLAWD_BUDDY_IMAGES=pane or viewer overrides the guess.
16export function drawsImages(env: { program: string; term: string; kitty: string; force?: string }): boolean {
17  if (env.force === 'pane') return true
18  if (env.force === 'viewer') return false
19  return env.kitty !== '' || /ghostty|kitty/i.test(`${env.program} ${env.term}`)
20}
21
hooks/sprite.ts 58 lines
1import type { Mood } from '../types'
2
3export type Cell = { ch: string; color?: string; backgroundColor?: string }
4
5export const BODY = '#D77757'
6export const BODY_W = 9
7const EYE_BG = '#000000'
8
9// Index bits UL=1 UR=2 LL=4 LR=8: the quadrant block that draws those pixels.
10const QUADRANT = [' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█']
11
12// The Claude Code mascot as 18x6 pixels, two per cell each way: o is body, e an eye, . nothing.
13const BASE = [
14  '...ooooooooooooo..',
15  '...ooeoooooooeoo..',
16  '.ooooooooooooooooo',
17  '...ooooooooooooo..',
18  '...o.o.......o.o..',
19  '..................',
20]
21
22function frame(mood: Mood, tick: number): string[] {
23  const g = BASE.map(r => [...r])
24  const set = (r: number, xs: number[], v: string) => xs.forEach(x => (g[r][x] = v))
25
26  if (mood === 'idle') {
27    if (tick % 8 === 7) set(1, [5, 13], 'o')
28  } else {
29    // One arm up, then the other: it waves while Claude works and when it finishes.
30    const left = tick % 2 === 0
31    set(2, left ? [1, 2] : [16, 17], '.')
32    set(1, left ? [1, 2] : [16, 17], 'o')
33    if (mood === 'working') {
34      set(4, [3, 5, 13, 15], '.')
35      set(4, tick % 4 < 2 ? [4, 6, 12, 14] : [2, 4, 14, 16], 'o')
36    }
37  }
38  return g.map(r => r.join(''))
39}
40
41/** Draws the mascot as 3 rows of BODY_W quadrant-block cells. */
42export function sprite(mood: Mood, tick: number): Cell[][] {
43  const g = frame(mood, tick)
44  const on = (r: number, x: number) => (g[r][x] === 'o' ? 1 : 0)
45  const rows: Cell[][] = []
46  for (let r = 0; r < g.length; r += 2) {
47    const row: Cell[] = []
48    for (let c = 0; c < BODY_W; c++) {
49      const bits = on(r, c * 2) + on(r, c * 2 + 1) * 2 + on(r + 1, c * 2) * 4 + on(r + 1, c * 2 + 1) * 8
50      const hasEye = g[r][c * 2] === 'e' || g[r][c * 2 + 1] === 'e' || g[r + 1][c * 2] === 'e' || g[r + 1][c * 2 + 1] === 'e'
51      // The eyes are the empty quadrants of the first row, on a black ground like the real one.
52      row.push(r === 0 && c >= 2 && c <= 7 ? { ch: QUADRANT[bits], color: BODY, backgroundColor: EYE_BG } : bits === 0 && !hasEye ? { ch: ' ' } : { ch: QUADRANT[bits], color: BODY })
53    }
54    rows.push(row)
55  }
56  return rows
57}
58
hooks/stats.ts 35 lines
1const cut = (name: string): string => (name.length > 28 ? `${name.slice(0, 27)}…` : name)
2
3// claude-opus-4-8[1m] becomes "Opus 4.8"; the context size is shown apart. An unknown name is only cut short.
4export function shortModel(name: string): string {
5  const low = name.toLowerCase()
6  const m = low.match(/(opus|sonnet|haiku|fable)[\s-]*(\d{1,2})(?!\d)(?:[\s.-]+(\d{1,2})(?!\d))?/)
7  if (!m) return cut(name)
8  const family = m[1][0].toUpperCase() + m[1].slice(1)
9  const version = m[3] ? `${m[2]}.${m[3]}` : m[2]
10  return `${family} ${version}`
11}
12
13// 1000000 reads 1M, 200000 reads 200K.
14export function windowText(tokens: number): string {
15  if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
16  return `${Math.round(tokens / 1000)}K`
17}
18
19export const GREEN = '#4cc35a'
20export const AMBER = '#e5a33a'
21export const RED = '#e5534b'
22
23export const levelColor = (p: number) => (p >= 90 ? RED : p >= 70 ? AMBER : GREEN)
24
25/** A thin line: heavy for the used part, light for the rest. */
26export function bar(percent: number, width = 6): { on: string; off: string } {
27  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
28  return { on: '━'.repeat(filled), off: '─'.repeat(width - filled) }
29}
30
31export function clockText(ms: number): string {
32  const s = Math.max(0, Math.floor(ms / 1000))
33  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
34}
35
types/index.d.ts 24 lines
1export type Mood = 'idle' | 'working' | 'done'
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3export type Ctx = { tokens?: number; window: number; percent?: number }
4export type Picture = { file: string; name: string; n: number }
5export type GitInfo = { branch: string; staged: number; modified: number; untracked: number; ahead: number; behind: number; worktree: string | null }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'clawd-buddy': {
10      mood: Mood
11      moodAt: number
12      now: number
13      cacheAt: number | null
14      ctx: Ctx | null
15      limits: Limit[]
16      cwd: string
17      git: GitInfo | null
18      picture: Picture | null
19      model: string
20      effort: string
21    }
22  }
23}
24