SLOPSHOPPER

session-meter

Coloured band above the prompt: context, room before auto-compact, 5h and 7d limits, estimated cost in MYR

newbandstatusnetwork
v0.3.0no licenseupdated 2026-10-09swchooi/claude-mods/plugins/session-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-meter
› 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 ctx 97.4k/200k │ 5h 31% │ ≈RM1.72 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97.4k/200k │ 5h 31% │ ≈RM1.72
README

claude-mods

My collection of Claude-related things: plugins, skills, MCP setups, mods and projects.

pathwhat it is
plugins/session-meterClaude Code plugin: a coloured band above the prompt showing context tokens, room left before auto-compact, 5-hour and 7-day usage limits with reset times, and session cost in MYR (estimated from a daily USD→MYR rate)
animationHand-painted cartoon kit for Claude Code (from ClaudeAnimationBase), with the short "The Last Leaf"

session-meter

The repo is also a Claude Code plugin marketplace (chooi-mods).

Install

claude plugin marketplace add swchooi/claude-mods
claude plugin install session-meter@chooi-mods

Start a new session, in the terminal or the desktop app's Code tab, and the band appears after the first reply.

Update / remove

claude plugin marketplace update chooi-mods
claude plugin update session-meter@chooi-mods
claude plugin uninstall session-meter@chooi-mods

animation

Open animation/ in Claude Code and ask for a video, for example: "Read ANIMATION_GUIDE.md, then make a 15-second video of Clawd …". It needs Node.js, Chrome or Edge, and ffmpeg. To render the current film:

npm install
node render.mjs --frames --workers=4
node render.mjs --encode --out=out/video.mp4
Source 2 files
hooks/register.tsx 156 lines
1import type { EngineInterface as Engine, Register, SessionContextUsage, SessionRateLimit, SessionCost } from 'claude-code'
2
3import type { Limit, Meter } from '../types'
4
5type Figures = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
6
7const meter = { plugin: 'session-meter', key: 'meter' } as const
8
9const GREEN = 'green'
10const AMBER = 'yellow'
11const RED = 'red'
12
13// Colour for a % used: green, amber from 70, red from 90.
14const usedColor = (pct: number) => (pct >= 90 ? RED : pct >= 70 ? AMBER : GREEN)
15
16// Colour for a % of room left: red at 10 or under, amber at 25 or under.
17const leftColor = (pct: number) => (pct <= 10 ? RED : pct <= 25 ? AMBER : GREEN)
18
19const trim = (s: string) => s.replace(/\.0$/, '')
20
21// USD -> MYR: a live rate fetched at most once a day and kept in the store across
22// sessions; the last good rate (or a rough default) stands in when offline.
23const RATE_URL = 'https://open.er-api.com/v6/latest/USD'
24const RATE_KEY = 'usdMyr'
25const RATE_TTL = 24 * 60 * 60 * 1000
26const FALLBACK_RATE = 4.1
27
28type Rate = { rate: number; at: number }
29
30let rateCache: Rate | undefined
31
32async function usdToMyr($: Engine): Promise<number> {
33  const now = await $.clock.now()
34  if (rateCache && now - rateCache.at < RATE_TTL) return rateCache.rate
35
36  const stored = (await $.store.get(RATE_KEY)) as Rate | undefined
37  if (stored && now - stored.at < RATE_TTL) return (rateCache = stored).rate
38
39  try {
40    const res = await $.http.fetch(RATE_URL)
41    const rate = res.ok ? JSON.parse(res.text)?.rates?.MYR : undefined
42    if (typeof rate === 'number' && rate > 0) {
43      rateCache = { rate, at: now }
44      await $.store.set(RATE_KEY, rateCache)
45      return rate
46    }
47  } catch {
48    // offline or blocked: fall through to the last known rate
49  }
50  // Retry no sooner than an hour from now.
51  rateCache = { rate: stored?.rate ?? FALLBACK_RATE, at: now - RATE_TTL + 60 * 60 * 1000 }
52  return rateCache.rate
53}
54
55const k = (n: number) =>
56  n >= 1_000_000 ? `${trim((n / 1_000_000).toFixed(1))}M` : n >= 1000 ? `${trim((n / 1000).toFixed(1))}k` : `${n}`
57
58// One coarse unit only: "40m", "4h", "2d".
59const resetsIn = (iso: string | undefined, now: number) => {
60  const at = iso ? Date.parse(iso) : NaN
61  if (!Number.isFinite(at) || !Number.isFinite(now)) return ''
62  const mins = Math.max(0, (at - now) / 60000)
63  const span = mins < 60 ? `${Math.round(mins)}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`
64  return ` resets in ${span}`
65}
66
67async function measure($: Engine, f: Figures): Promise<Meter> {
68  const { tokens, window } = f.context
69  let compactLeft: number | null | undefined
70
71  try {
72    const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
73    if (b && !b.isAutoCompactEnabled) compactLeft = null
74    else if (b?.autoCompactThreshold && tokens !== undefined)
75      compactLeft = Math.max(0, Math.round((1 - tokens / b.autoCompactThreshold) * 100))
76  } catch {
77    // breakdown unavailable: leave the auto-compact figure out
78  }
79
80  const byKind = (kind: string, label: string): Limit[] => {
81    const r = f.rateLimits.find(l => l.kind === kind)
82    return r ? [{ label, percentUsed: r.percentUsed, resetsAt: r.resetsAt }] : []
83  }
84
85  return {
86    ctxTokens: tokens,
87    window,
88    compactLeft,
89    limits: [...byKind('five_hour', '5h'), ...byKind('seven_day', '7d')],
90    costMyr: f.cost?.usd === undefined ? undefined : f.cost.usd * (await usdToMyr($)),
91  }
92}
93
94export const register: Register = on => {
95  on('session.start', async ($, e, next) => {
96    const r = await next(e)
97    $.ui.status(undefined)
98    await $.state.set(meter, await measure($, await $.session.usage()))
99    return r
100  })
101
102  on('session.measure', async ($, e, next) => {
103    await $.state.set(meter, await measure($, e))
104    return next(e)
105  })
106
107  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
108    const { value: m } = await $.state.get(meter)
109    if (e.props.hasSurvey || !m) return next(e)
110
111    const { Box, Text } = $.ui.resolve(e)
112    const now = await $.clock.now()
113    const sep = <Text dimColor>{'  │  '}</Text>
114
115    return (
116      <Box>
117        <Text wrap="truncate">
118          <Text dimColor>ctx </Text>
119          <Text>{m.ctxTokens === undefined ? '–' : k(m.ctxTokens)}</Text>
120          <Text dimColor>/{k(m.window)}</Text>
121          {m.compactLeft === null ? (
122            <Text>
123              {sep}
124              <Text dimColor>auto-compact off</Text>
125            </Text>
126          ) : m.compactLeft !== undefined ? (
127            <Text>
128              {sep}
129              <Text color={leftColor(m.compactLeft)} bold>
130                {m.compactLeft}%
131              </Text>
132              <Text dimColor> until compact</Text>
133            </Text>
134          ) : null}
135          {m.limits.map(l => (
136            <Text key={l.label}>
137              {sep}
138              <Text dimColor>{l.label} </Text>
139              <Text color={usedColor(l.percentUsed)} bold>
140                {Math.round(l.percentUsed)}%
141              </Text>
142              <Text dimColor>{resetsIn(l.resetsAt, now)}</Text>
143            </Text>
144          ))}
145          {m.costMyr !== undefined ? (
146            <Text>
147              {sep}
148              <Text>≈RM{m.costMyr.toFixed(2)}</Text>
149            </Text>
150          ) : null}
151        </Text>
152      </Box>
153    )
154  })
155}
156
types/index.d.ts 18 lines
1export type Limit = { label: string; percentUsed: number; resetsAt?: string }
2
3export type Meter = {
4  ctxTokens?: number
5  window: number
6  // % of room left before auto-compact; null when auto-compact is off
7  compactLeft?: number | null
8  limits: Limit[]
9  // session cost converted to MYR at an estimated rate
10  costMyr?: number
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'session-meter': { meter: Meter | null }
16  }
17}
18