SLOPSHOPPER

elizabeth-progress

Elizabeth (Gintama) walks a context-window progress bar above the prompt, with the usage breakdown, turns left to auto-compact, and rate limits. Unofficial fan…

newband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · elizabeth-progress
› fix the failing auth test and add an audit log call ⏺ 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 ⟨Claude Code's own drawing⟩ ━━━━━━━━━━━━───────────── 97.4k / 200.0k (49%) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ━━━━━━━━━━━━───────────── 97.4k / 200.0k (49%)
README

elizabeth-progress

Elizabeth walks your context window. A Claude Code mod: above the prompt, Elizabeth (from Gintama) stands on a progress bar of the context window, holding up a sign with how full it is, and walks right as the conversation grows.

Elizabeth's band at 28%, 72% and 81% of the context window, light theme

  • The bar is coloured by what fills the window (messages, system tools, MCP tools, skills, system prompt), with the auto-compact zone hatched at its end.
  • Her mood follows the way to auto-compact: calm, then angry from 80% of the way there (the sign flashes 💢), then in tears from 95%.
  • The figures on the right: context used, turns left before auto-compact (from the average rise of the last five turns, with a sparkline), the 5-hour and weekly limits with the time until each resets (↻), and the session's cost. In a window narrower than 900px the cost column and the last two legend entries drop out.
  • In the terminal it is one line: a bar and tokens / window (percent).

It shares the band: whatever other plugins draw above the prompt stays, and Elizabeth goes underneath. Dark mode is supported.

The same band, dark theme

Both pictures are drawn from made-up figures.

Install

Needs Claude Code 2.1.287 or later. No key and no options.

/plugin marketplace add Yaxin9Luo/spending-effort-with-jev
/plugin install elizabeth-progress@spending-effort-with-jev

Then /reload-plugins. The bar appears after Claude's first reply, when Claude Code first measures the context. The breakdown, the auto-compact zone and turns left fill in as turns end.

It is independent of the effort advisor in the same marketplace: install either or both.

Where the figures come from

All from Claude Code itself; nothing leaves your machine.

  • Context, rate limits and cost: the session.measure event, which Claude Code raises whenever it measures new figures (after each API reply). Nothing is polled.
  • The breakdown and the auto-compact threshold: $.session.usage({ breakdown: 'summary' }) at the end of each main-loop turn, a local estimate that makes no request.
  • Turns left: the context fill at the end of each main-loop turn, the last 40 kept in memory for the session. Subagents' turns are not counted.

Development

claude plugin test plugins/elizabeth-progress
claude plugin validate plugins/elizabeth-progress

hooks/elizabeth.ts draws the character (a pure function returning an SVG group), hooks/band.ts lays out the band, and hooks/register.tsx gathers the figures and draws the band on each surface.

About the character

Elizabeth is a character from Gintama by Hideaki Sorachi (Shueisha). The drawing here is hand-made, unofficial fan art; this plugin is not affiliated with or endorsed by the rights holders, and its MIT license covers the code and drawing only, granting no rights in the character. If you hold those rights and want it taken down, open an issue.

License

MIT, for the code and drawing; see LICENSE.

Source 4 files
hooks/register.tsx 91 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Ctx, Limit, Part } from '../types'
5import { H, k, svg, tone } from './band'
6
7const ctx = atom({ plugin: 'elizabeth-progress', key: 'ctx' } as const, null as Ctx | null)
8const parts = atom({ plugin: 'elizabeth-progress', key: 'parts' } as const, [] as Part[])
9const limits = atom({ plugin: 'elizabeth-progress', key: 'limits' } as const, [] as Limit[])
10const cost = atom({ plugin: 'elizabeth-progress', key: 'cost' } as const, null as number | null)
11const hist = atom({ plugin: 'elizabeth-progress', key: 'hist' } as const, [] as number[])
12const compactAt = atom({ plugin: 'elizabeth-progress', key: 'compactAt' } as const, null as number | null)
13
14export const register: Register = on => {
15  // The engine sends new figures each time it measures them (every API reply): no polling.
16  on('session.measure', async ($, e, next) => {
17    const c = e.context
18    if (c.tokens !== undefined && c.percent !== undefined) {
19      await update($, ctx, () => ({ tokens: c.tokens ?? 0, window: c.window, percent: c.percent ?? 0 }))
20    }
21    await update($, limits, () => e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })))
22    if (e.cost) {
23      const usd = e.cost.usd
24      await update($, cost, () => usd)
25    }
26    return next(e)
27  })
28
29  // After each main-loop turn: the breakdown (a local estimate, no request),
30  // the auto-compact threshold, and one more point of history.
31  on('turn.complete', async ($, e, next) => {
32    const result = await next(e)
33    if (e.agentId !== undefined) return result // a subagent's turn leaves the main context as it was
34    const { context } = await $.session.usage({ breakdown: 'summary' })
35    const b = context.breakdown
36    const rows = (b?.categories ?? [])
37      .filter(r => r.kind === 'used' && r.tokens > 0)
38      .sort((x, y) => y.tokens - x.tokens)
39      .slice(0, 5)
40      .map(r => ({ name: r.name, tokens: r.tokens }))
41    await update($, parts, () => rows)
42    const threshold = b?.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : null
43    await update($, compactAt, () => (threshold !== null ? (threshold / context.window) * 100 : null))
44    if (context.percent !== undefined) {
45      const p = context.percent
46      await update($, hist, h => [...h, p].slice(-40))
47    }
48    return result
49  })
50
51  // The band holds one tree, and plugins draw it in a chain: this one goes
52  // under whatever the plugins beneath drew, never in its place.
53  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
54    const c = await read($, ctx)
55    if (e.props.hasSurvey || c === null) return next(e)
56
57    const below = await next(e)
58    const ui = $.ui.resolve(e)
59    const { Box, Text } = ui
60
61    if (e.surface !== 'terminal' && 'Svg' in ui && ui.Svg) {
62      const source = svg({
63        c,
64        ps: await read($, parts),
65        ls: await read($, limits),
66        usd: await read($, cost),
67        h: await read($, hist),
68        at: await read($, compactAt),
69        now: await $.clock.now(),
70      })
71      return (
72        <Box flexDirection="column">
73          {below ?? null}
74          <ui.Svg key="eliz" source={source} alt={`context ${c.percent}%`} height={H} isInteractive />
75        </Box>
76      )
77    }
78
79    const n = Math.round(c.percent / 4)
80    return (
81      <Box flexDirection="column">
82        {below ?? null}
83        <Text key="eliz" color={tone(c.percent / 100)}>
84          {'━'.repeat(n)}
85          <Text dimColor>{'─'.repeat(25 - n)} {k(c.tokens)} / {k(c.window)} ({c.percent}%)</Text>
86        </Text>
87      </Box>
88    )
89  })
90}
91
hooks/band.ts 143 lines
1// The band's picture: one SVG at 100% width and 1:1 pixels, a fixed height,
2// every horizontal position a percentage so it fits any window width.
3// Left: Elizabeth standing on the progress bar. Right: a row of compact figures.
4import type { Ctx, Limit, Part } from '../types'
5import { elizabeth } from './elizabeth'
6import type { Mood } from './elizabeth'
7
8export const H = 84
9const L0 = 1.6
10const L1 = 53
11const R0 = 56
12const R1 = 99.4
13const TY = 54
14const SCALE = 0.4
15const PALETTE = ['#5b8def', '#e07a5f', '#3d9a8b', '#e0a43c', '#8c7ae6', '#9aa0a6']
16
17export const k = (n: number) =>
18  n >= 1e6 ? `${(n / 1e6).toFixed(2).replace(/\.?0+$/, '')}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
19export const tone = (r: number) => (r < 0.6 ? '#5b8def' : r < 0.85 ? '#e0a43c' : '#d4523c')
20const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
21const until = (iso: string | undefined, now: number) => {
22  if (!iso) return ''
23  const m = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
24  return m >= 1440 ? `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h` : m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`
25}
26const LIMIT_NAME: Record<string, string> = { five_hour: '5h limit', seven_day: 'Weekly', spend_limit: 'Spend' }
27
28const STYLE = `<style>
29  html,body{margin:0;padding:0;overflow:hidden;background:transparent}
30  text{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
31  .t{fill:#2a2a2a}.m{fill:#8b8780}.trk{fill:#e2dfd8}.div{stroke:#dcd8d0}.panel{fill:#fff;fill-opacity:.62}
32  @media (max-width: 900px){.wide,.only-wide{display:none}}
33  @media (min-width: 901px){.only-narrow{display:none}}
34  @media (prefers-color-scheme: dark){.t{fill:#ece9e2}.m{fill:#9b978f}.trk{fill:#3a3835}.div{stroke:#45423e}.panel{fill:#3a3835;fill-opacity:.55}}
35</style>`
36
37export type View = { c: Ctx; ps: Part[]; ls: Limit[]; usd: number | null; h: number[]; at: number | null; now: number }
38
39export const svg = ({ c, ps, ls, usd, h, at, now }: View) => {
40  const p = Math.min(100, c.percent)
41  const span = L1 - L0
42  const px = (v: number) => L0 + (Math.min(100, v) / 100) * span
43  const pc = (v: number) => `${v.toFixed(3)}%`
44  const ratio = p / (at ?? 100)
45  const out: string[] = [STYLE]
46
47  // Two light panels hold the left and right halves.
48  out.push(`<rect class="panel" x="0.3%" y="4" width="${pc(L1 + 1.4)}" height="${H - 8}" rx="12"/>`)
49  out.push(`<rect class="panel" x="${pc(R0 - 0.9)}" y="4" width="${pc(R1 - R0 + 1.2)}" height="${H - 8}" rx="12"/>`)
50
51  // The bar: the track, the used part coloured by category, the auto-compact zone
52  // (hatched), and ticks at 25/50/75.
53  out.push(`<defs><clipPath id="trk"><rect x="${pc(L0)}" y="${TY}" width="${pc(span)}" height="7" rx="3.5"/></clipPath>
54    <pattern id="hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="#d4523c" fill-opacity="0.08"/><line x1="0" y1="0" x2="0" y2="5" stroke="#d4523c" stroke-opacity="0.4" stroke-width="1.6"/></pattern></defs>`)
55  const g: string[] = [`<rect class="trk" x="${pc(L0)}" y="${TY}" width="${pc(span)}" height="7"/>`]
56  if (at !== null && at < 100) g.push(`<rect x="${pc(px(at))}" y="${TY}" width="${pc(L1 - px(at))}" height="7" fill="url(#hatch)"/>`)
57  const sum = ps.reduce((a, r) => a + r.tokens, 0)
58  if (sum > 0) {
59    let x = L0
60    ps.forEach((r, i) => {
61      const w = (r.tokens / sum) * (px(p) - L0)
62      g.push(`<rect x="${pc(x)}" y="${TY}" width="${pc(w + 0.05)}" height="7" fill="${PALETTE[i % PALETTE.length]}"/>`)
63      x += w
64    })
65  } else {
66    g.push(`<rect x="${pc(L0)}" y="${TY}" width="${pc(px(p) - L0)}" height="7" fill="${tone(ratio)}"/>`)
67  }
68  for (const t of [25, 50, 75]) g.push(`<rect x="${pc(px(t))}" y="${TY}" width="1.2" height="7" fill="#fff" fill-opacity="0.65"/>`)
69  out.push(`<g clip-path="url(#trk)">${g.join('')}</g>`)
70  // Steps aside as Elizabeth comes near, so the two never overlap.
71  if (at !== null && at < 100 && p < at - 22) {
72    out.push(`<text class="m" x="${pc(L1)}" y="${TY - 7}" font-size="10" text-anchor="end">auto-compact ${Math.round(at)}%</text>`)
73  }
74
75  // The legend: one line under the bar; a narrow window keeps the first three.
76  const lg: string[] = []
77  let lx = 0
78  ps.forEach((r, i) => {
79    const name = r.name.replace(/^System /, 'Sys ')
80    lg.push(`<g${i >= 3 ? ' class="wide"' : ''}><circle cx="${lx + 3}" cy="${TY + 18.5}" r="3" fill="${PALETTE[i % PALETTE.length]}"/>
81      <text x="${lx + 10}" y="${TY + 22}" font-size="10.5"><tspan class="m">${esc(name)} </tspan><tspan class="t" font-weight="600">${k(r.tokens)}</tspan></text></g>`)
82    lx += 22 + (name.length + 1 + k(r.tokens).length) * 5.6
83  })
84  out.push(`<svg x="${pc(L0)}" y="0" overflow="visible">${lg.join('')}</svg>`)
85
86  // Elizabeth stands on the top edge of the bar and walks with the usage.
87  const mood: Mood = ratio >= 0.95 ? 'crying' : ratio >= 0.8 ? 'angry' : 'calm'
88  const ex = Math.min(L1 - 3.2, Math.max(L0 + 2.6, px(p)))
89  out.push(`<svg x="${pc(ex)}" y="0" overflow="visible"><g transform="translate(0 ${TY - 1.5}) scale(${SCALE})">${elizabeth({ sign: `${c.percent}%`, mood, lw: 2.2, signSize: 20 })}</g></svg>`)
90
91  // The row of figures on the right.
92  const deltas = h.slice(1).map((v, i) => v - (h[i] ?? v)).filter(d => d > 0)
93  const recent = deltas.slice(-5)
94  const avg = recent.length ? recent.reduce((a, b) => a + b, 0) / recent.length : 0
95  const room = (at ?? 100) - p
96  type Col = { label: string; value: string; sub: string; frac?: number; spark?: number[] }
97  const cols: Col[] = [
98    { label: 'Context', value: k(c.tokens), sub: `${c.percent}% of ${k(c.window)}`, frac: ratio },
99    {
100      label: 'Turns left',
101      value: avg > 0 ? (room / avg < 1 ? (room > 0 ? '<1' : '0') : `≈${Math.floor(room / avg)}`) : '—',
102      sub: avg > 0 ? `+${avg.toFixed(1)}%/turn` : 'need 2 turns',
103      spark: h.slice(-24),
104    },
105    ...ls.slice(0, 2).map(l => ({
106      label: LIMIT_NAME[l.kind] ?? l.kind,
107      value: `${l.percentUsed}%`,
108      sub: l.resetsAt ? `↻ ${until(l.resetsAt, now)}` : '',
109      frac: l.percentUsed / 100,
110    })),
111    ...(usd !== null ? [{ label: 'Cost', value: `$${usd.toFixed(2)}`, sub: 'this session' }] : []),
112  ]
113  const drawCols = (list: Col[], cls: string) => {
114    const g: string[] = []
115    const cw = (R1 - R0) / list.length
116    list.forEach((col, i) => {
117      const x0 = R0 + i * cw
118      const x = x0 + (i ? 1.3 : 0.6)
119      const inner = cw - (i ? 2.6 : 1.9)
120      if (i) g.push(`<line class="div" x1="${pc(x0)}" y1="16" x2="${pc(x0)}" y2="${H - 16}"/>`)
121      const hot = col.frac !== undefined && col.frac >= 0.85
122      g.push(`<text class="m" x="${pc(x)}" y="23" font-size="9.5" font-weight="500" letter-spacing="0.5">${esc(col.label.toUpperCase())}</text>`)
123      g.push(`<text ${hot ? `fill="${tone(col.frac ?? 0)}"` : 'class="t"'} x="${pc(x)}" y="45" font-size="19" font-weight="600" letter-spacing="-0.2">${esc(col.value)}</text>`)
124      g.push(`<text class="m" x="${pc(x)}" y="60" font-size="10.5">${esc(col.sub)}</text>`)
125      if (col.frac !== undefined) {
126        g.push(`<rect class="trk" x="${pc(x)}" y="66" width="${pc(inner)}" height="3" rx="1.5"/>`)
127        g.push(`<rect x="${pc(x)}" y="66" width="${pc(inner * Math.min(1, col.frac))}" height="3" rx="1.5" fill="${tone(col.frac)}"/>`)
128      }
129      if (col.spark && col.spark.length > 1) {
130        const sp = col.spark
131        const top = Math.max(...sp, 1)
132        const pts = sp.map((v, j) => `${((j / (sp.length - 1)) * 100).toFixed(1)},${(10 - (v / top) * 9).toFixed(1)}`).join(' ')
133        g.push(`<svg x="${pc(x)}" y="61" width="${pc(inner)}" height="10" viewBox="0 0 100 10" preserveAspectRatio="none" overflow="visible"><polyline points="${pts}" fill="none" stroke="#5b8def" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/></svg>`)
134      }
135    })
136    return `<g class="${cls}">${g.join('')}</g>`
137  }
138  out.push(drawCols(cols, 'only-wide'))
139  out.push(drawCols(cols.filter(col => col.label !== 'Cost'), 'only-narrow'))
140
141  return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${H}">${out.join('')}</svg>`
142}
143
hooks/elizabeth.ts 173 lines
1// Elizabeth (Gintama), drawn by hand from a front-view acrylic stand and
2// screenshots of the anime, with two-step cel shading. Unofficial fan art.
3// Coordinates: the origin is between the feet; the top of the head is at
4// y=-111, the hem at y≈-11; the body is about 62 wide.
5// The sign: the hand on the right of the picture holds up a white stick, the
6// board above it to the right, behind the head.
7
8export type Mood = 'calm' | 'angry' | 'crying'
9
10export type ElizabethOptions = {
11  sign: string
12  mood?: Mood
13  /** Line width, in the character's units; raise it when drawn small so lines stay about 1px on screen. */
14  lw?: number
15  animate?: boolean
16  /** Font size on the sign, in the character's units; raise it when drawn small so it stays legible. */
17  signSize?: number
18}
19
20const OUT = '#4a4a4a'
21const SHADE = '#e1e5ec'
22const SHADE_DEEP = '#d3d8e0'
23const BEAK_LINE = '#6a4b1a'
24const TOE_LINE = '#8a5f17'
25const SIGN_FONT = `'Marker Felt', 'Chalkboard SE', 'Comic Sans MS', sans-serif`
26
27const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
28
29const DEFS = `<defs>
30  <linearGradient id="eliz-toe" x1="0" y1="-16" x2="0" y2="4" gradientUnits="userSpaceOnUse">
31    <stop offset="0" stop-color="#f6c24c"/><stop offset="0.6" stop-color="#eba636"/><stop offset="1" stop-color="#d4891f"/>
32  </linearGradient>
33  <linearGradient id="eliz-beak-top" x1="0" y1="-88.4" x2="0" y2="-79.2" gradientUnits="userSpaceOnUse">
34    <stop offset="0" stop-color="#f9cf63"/><stop offset="1" stop-color="#f1b33c"/>
35  </linearGradient>
36  <linearGradient id="eliz-beak-low" x1="0" y1="-80.6" x2="0" y2="-73.8" gradientUnits="userSpaceOnUse">
37    <stop offset="0" stop-color="#eda93b"/><stop offset="1" stop-color="#d68c26"/>
38  </linearGradient>
39</defs>`
40
41// An eye: a slightly tall circle, a small black dot in the middle, three lashes flicked outward on top.
42const eye = (cx: number, cy: number, lw: number) => {
43  const rx = 5.9
44  const ry = 6.3
45  const lashes = [-122, -90, -58]
46    .map(d => {
47      const t = (d * Math.PI) / 180
48      const x1 = cx + rx * Math.cos(t)
49      const y1 = cy + ry * Math.sin(t)
50      const tilt = t + (d + 90) * 0.006
51      return `M${x1.toFixed(2)} ${y1.toFixed(2)} l${(2.4 * Math.cos(tilt)).toFixed(2)} ${(2.4 * Math.sin(tilt)).toFixed(2)}`
52    })
53    .join(' ')
54  return `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="#fff" stroke="${OUT}" stroke-width="${lw * 0.8}"/>
55<circle cx="${cx}" cy="${cy + 0.2}" r="0.75" fill="#151515"/>
56<path d="${lashes}" stroke="${OUT}" stroke-width="${lw * 0.7}" stroke-linecap="round" fill="none"/>`
57}
58
59// A toe: a plump ellipse from the heel (bx,by) to the tip (tx,ty), as ellipse attributes.
60const toe = (bx: number, by: number, tx: number, ty: number, w: number) => {
61  const len = Math.hypot(bx - tx, by - ty)
62  const ang = (Math.atan2(by - ty, bx - tx) * 180) / Math.PI
63  const cx = ((bx + tx) / 2).toFixed(2)
64  const cy = ((by + ty) / 2).toFixed(2)
65  return `cx="${cx}" cy="${cy}" rx="${(len / 2).toFixed(2)}" ry="${w / 2}" transform="rotate(${ang.toFixed(1)} ${cx} ${cy})"`
66}
67
68const TOES = [toe(-16, -12.8, -39.6, -6.8, 7.8), toe(-15, -10.2, -36.6, 0.6, 8.2), toe(-13.6, -7.6, -28.8, 3.4, 7.8)]
69const WEB = 'M-11 -14.4 L-37 -8.6 L-35 -0.4 L-28 3 C-20 1.6,-13.4 -0.6,-10.4 -4 C-8.8 -7,-9 -11,-11 -14.4 Z'
70
71// The left foot (on the left of the picture): a big webbed fan with three round toes
72// spread down and to the left. The right foot is its mirror image.
73const foot = (mirror: boolean, lw: number) => {
74  const parts = (attrs: string) =>
75    `<path d="${WEB}" ${attrs}/>` + TOES.map(t => `<ellipse ${t} ${attrs}/>`).join('')
76  return `<g${mirror ? ' transform="scale(-1 1)"' : ''}>
77  ${parts(`fill="${TOE_LINE}" stroke="${TOE_LINE}" stroke-width="${lw * 1.5}" stroke-linejoin="round"`)}
78  ${parts('fill="url(#eliz-toe)"')}
79  <path d="M-33.2 -3.2 C-30 -5,-26.6 -6.6,-23 -7.6 M-28 0.6 C-25.4 -1.8,-22.6 -3.6,-19.6 -4.8" stroke="#c27c1e" stroke-width="${lw * 0.7}" fill="none" stroke-linecap="round" opacity="0.75"/>
80  <path d="M-36.6 -9.4 C-31 -11.4,-24 -12.8,-18 -13.4" stroke="#fbd877" stroke-width="${lw * 1.4}" fill="none" stroke-linecap="round" opacity="0.9"/>
81  <path d="M-34.6 -2.6 C-29.6 -5.2,-24.6 -7,-19.6 -8.2" stroke="#fbd877" stroke-width="${lw * 1.2}" fill="none" stroke-linecap="round" opacity="0.7"/>
82</g>`
83}
84
85// 💢 the anger mark: four arcs curling toward the centre.
86const anger = (cx: number, cy: number, s: number, lw: number, color: string) => {
87  const q = (sx: number, sy: number) =>
88    `M${cx + sx * s} ${cy + sy * s * 0.28} Q${cx + sx * s * 0.28} ${cy + sy * s * 0.28} ${cx + sx * s * 0.28} ${cy + sy * s}`
89  return `<path d="${q(-1, -1)} ${q(1, -1)} ${q(-1, 1)} ${q(1, 1)}" stroke="${color}" stroke-width="${lw * 1.6}" fill="none" stroke-linecap="round"/>`
90}
91
92// The beak: a lemon shape pointed at both ends. The upper half is bright yellow,
93// larger, with a highlight; the lower half is more orange and darker toward the
94// bottom; a shadow at the right end gives it thickness.
95const beak = (lw: number) => {
96  const top = 'M-14 -80.6 C-12.6 -86.4,-5.6 -88.6,1 -88.4 C8 -88.2,13.8 -85.6,14.2 -81'
97  const lipFwd = 'M-14 -80.6 C-9 -80.4,-3 -78.8,2 -79.2 C7 -79.6,11 -80.8,14.2 -81'
98  const lipBack = 'C11 -80.8,7 -79.6,2 -79.2 C-3 -78.8,-9 -80.4,-14 -80.6'
99  const bottom = 'C13.4 -76.2,8 -74.2,0.6 -74.2 C-6.4 -74.2,-11.8 -76.4,-14 -80.6'
100  return `<g transform="translate(0 1.4)">
101  <clipPath id="eliz-beak"><path d="${top} ${bottom} Z"/></clipPath>
102  <path d="M-9 -73.2 C-4 -71.4,5 -71.4,10 -73.2 C6 -72,-4 -72,-9 -73.2 Z" fill="${SHADE_DEEP}"/>
103  <ellipse cx="0.6" cy="-72.6" rx="9.6" ry="1.5" fill="${SHADE_DEEP}" opacity="0.6"/>
104  <g clip-path="url(#eliz-beak)">
105    <path d="${lipFwd} ${bottom} Z" fill="url(#eliz-beak-low)"/>
106    <path d="M15 -84 C15 -78,12 -74.6,7 -73.4 L16 -72 Z" fill="#c47b1c" opacity="0.5"/>
107    <path d="M-12 -76.6 C-7 -74.6,1 -74.4,7 -75.4" stroke="#c47b1c" stroke-width="${lw * 0.7}" fill="none" stroke-linecap="round" opacity="0.45"/>
108    <path d="${top} ${lipBack} Z" fill="url(#eliz-beak-top)"/>
109    <path d="M-4 -86.6 C1 -87.6,7 -87.2,10.6 -85.4 C6.6 -85,1.4 -85.2,-4 -86.6 Z" fill="#fde7a6" opacity="0.9"/>
110    <path d="M-14.4 -80 C-13 -84,-10 -86.4,-6.4 -87.4" stroke="#dc9d2e" stroke-width="${lw * 0.8}" fill="none" stroke-linecap="round" opacity="0.55"/>
111    <path d="M14.6 -82.6 C14 -80.6,13.2 -80,12 -79.6" stroke="#c47b1c" stroke-width="${lw * 0.8}" fill="none" opacity="0.6"/>
112  </g>
113  <path d="${lipFwd}" stroke="${BEAK_LINE}" stroke-width="${lw * 0.65}" fill="none" stroke-linecap="round"/>
114  <path d="${top} ${bottom} Z" fill="none" stroke="${BEAK_LINE}" stroke-width="${lw * 0.75}" stroke-linejoin="round"/>
115</g>`
116}
117
118const BODY =
119  'M-27 -11 C-30.5 -14,-31.5 -24,-31.2 -36 C-30.8 -52,-29.4 -72,-28.4 -86 C-27.2 -102,-15.6 -111,0 -111 C15.6 -111,27.2 -102,28.4 -86 C29.4 -72,30.8 -52,31.2 -36 C31.5 -24,30.5 -14,27 -11 C20 -8.6,9 -9.6,4 -13.4 C2.4 -14.6,1.2 -15.2,0 -15.2 C-1.2 -15.2,-2.4 -14.6,-4 -13.4 C-9 -9.6,-20 -8.6,-27 -11 Z'
120
121export const elizabeth = ({ sign, mood = 'calm', lw = 1, animate = true, signSize = 14 }: ElizabethOptions) => {
122  const bob = animate
123    ? '<animateTransform attributeName="transform" type="translate" values="0 0;0 -1.6;0 0" dur="1.6s" repeatCount="indefinite"/>'
124    : ''
125  const sway = animate
126    ? '<animateTransform attributeName="transform" type="rotate" values="-2.5 30 -66;2.5 30 -66;-2.5 30 -66" dur="2.6s" repeatCount="indefinite"/>'
127    : ''
128
129  // The sign: when angry it alternates between the percentage and 💢.
130  const text = `<text x="45" y="${-116.8 + signSize * 0.36}" font-size="${signSize}" font-weight="700" text-anchor="middle" fill="#1d1d1d" font-family="${SIGN_FONT}">${esc(sign)}</text>`
131  const signFace =
132    mood === 'calm' || !animate
133      ? text + (mood === 'calm' ? '' : anger(66, -127, 3.2, lw, '#c8402d'))
134      : `<g>${text}<animate attributeName="opacity" values="1;1;0;0;1" keyTimes="0;0.55;0.6;0.95;1" dur="3.2s" repeatCount="indefinite"/></g>
135<g opacity="0">${anger(45, -116.8, 7, lw, '#c8402d')}<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.55;0.6;0.95;1" dur="3.2s" repeatCount="indefinite"/></g>`
136
137  const tears =
138    mood === 'crying'
139      ? `<path d="M-15.4 -86.6 c-2.6 0.4,-3.4 3,-1.2 3.6 c1.6 0.4,3 -0.6,3.2 -2 c0.2 -1.2,-0.8 -1.8,-2 -1.6 Z" fill="#a9c8f0" opacity="0.9"/>
140<path d="M15.4 -86.6 c2.6 0.4,3.4 3,1.2 3.6 c-1.6 0.4,-3 -0.6,-3.2 -2 c-0.2 -1.2,0.8 -1.8,2 -1.6 Z" fill="#a9c8f0" opacity="0.9"/>`
141      : ''
142
143  return `<g class="elizabeth">${DEFS}
144  <ellipse cx="0" cy="1.6" rx="41" ry="3.4" fill="#000" opacity="0.09"/>
145  <g>${bob}
146  ${foot(false, lw)}
147  ${foot(true, lw)}
148  <g>${sway}
149    <rect x="43.6" y="-104" width="2.2" height="20" rx="0.9" fill="#fff" stroke="${OUT}" stroke-width="${lw * 0.7}"/>
150    <rect x="44.6" y="-104" width="1.2" height="20" fill="${SHADE}"/>
151    <path d="M20 -130 H70 V-107.5 L66.5 -103.5 H20 Z" fill="#fff" stroke="${OUT}" stroke-width="${lw * 0.9}" stroke-linejoin="round"/>
152    <path d="M20.6 -105.6 H66.2 L64.6 -104 H20.6 Z" fill="${SHADE}"/>
153    <path d="M70 -107.5 L66.5 -103.5 L66.9 -107.2 Z" fill="#dfe2e7" stroke="${OUT}" stroke-width="${lw * 0.6}" stroke-linejoin="round"/>
154    ${signFace}
155    <path d="M28.4 -81 C31.6 -85.6,35.4 -90.4,39.2 -93.6 C41.8 -95.8,46.8 -95.2,47.8 -91.6 C48.6 -88.6,47.2 -85.8,44.8 -84 C42.6 -78.4,38.4 -70.4,31 -61 Z" fill="#fff" stroke="${OUT}" stroke-width="${lw}" stroke-linejoin="round"/>
156    <path d="M47.6 -90 C47.4 -87.6,46.2 -85.6,44.8 -84 C42.6 -78.4,38.4 -70.4,31 -61 L30.4 -66.6 C35.4 -72.4,40.2 -79.6,43 -85.6 C44.6 -86.4,46.4 -87.8,47.6 -90 Z" fill="${SHADE}"/>
157  </g>
158  <path d="M-28.4 -79 C-35 -72.4,-41.8 -58,-45 -43 C-45.4 -40.6,-43.4 -39.6,-41.8 -41 C-38 -44.6,-34 -49.4,-30.9 -54 Z" fill="#fff" stroke="${OUT}" stroke-width="${lw}" stroke-linejoin="round"/>
159  <path d="M-44.6 -41.4 C-43.6 -40,-42.6 -40.2,-41.8 -41 C-38 -44.6,-34 -49.4,-30.9 -54 L-30.4 -60.4 C-33.6 -55,-38.6 -48,-44.6 -41.4 Z" fill="${SHADE}"/>
160  <path d="${BODY}" fill="#fff"/>
161  <path d="M19.6 -105 C25.8 -99,28 -92,28.4 -86 C29.4 -72,30.8 -52,31.2 -36 C31.5 -24,30.5 -14,27 -11 C24.6 -10.2,22.2 -9.8,20 -9.8 C23.4 -16,25 -28,25.2 -40 C25.4 -58,24.8 -76,24.2 -86 C23.8 -94,22.4 -100,19.6 -105 Z" fill="${SHADE}"/>
162  <path d="M-30.9 -54 C-30.6 -50,-30 -47.6,-28.6 -45.6 C-28.8 -50,-28.8 -55,-28.6 -60 Z" fill="${SHADE}"/>
163  <path d="M-30 -18 C-27 -13.4,-20 -11.8,-12 -12.6 C-18 -14.4,-24 -15.6,-30 -18 Z" fill="${SHADE}"/>
164  <path d="M-9 -24 C-5.4 -18,-2.2 -15.6,0 -15.4 C2.2 -15.6,5.4 -18,9 -24 C5 -20.2,2.4 -18.8,0 -18.8 C-2.4 -18.8,-5 -20.2,-9 -24 Z" fill="${SHADE_DEEP}" opacity="0.7"/>
165  <path d="${BODY}" fill="none" stroke="${OUT}" stroke-width="${lw * 1.1}" stroke-linejoin="round"/>
166  ${eye(-12.8, -94.2, lw)}
167  ${eye(12.8, -94.2, lw)}
168  ${tears}
169  ${beak(lw)}
170  </g>
171</g>`
172}
173
types/index.d.ts 17 lines
1export type Ctx = { tokens: number; window: number; percent: number }
2export type Part = { name: string; tokens: number }
3export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'elizabeth-progress': {
8      ctx: Ctx | null
9      parts: Part[]
10      limits: Limit[]
11      cost: number | null
12      hist: number[]
13      compactAt: number | null
14    }
15  }
16}
17