SLOPSHOPPER

usage-band

プロンプト上に、7日間のトークンを何に使ったかの円グラフと、残り使用量(5時間枠・週枠・コンテキスト)の帯を常時表示

newbandpromptprocesstimer
v0.21.1no licenseupdated 2026-10-26kokotosu/claude-mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 5H 69% left CTX 51% left ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5H 69% left CTX 51% left
README

claude-mods

kokot が作った Claude Code 用の mod 集です。

modできること
usage-band入力欄の上に、残りの使用量(5時間枠・週枠・コンテキスト)とリセットまでの時間を帯で表示する。7日間のトークンを何に使ったかの円グラフも出す。チャット名の色分けもする
chat-timeLINE のように、各メッセージの下に送った日時を表示する
ja-questionsClaude からの質問が英語なら、日本語に訳して表示する
push-to-talk決めたキーを押している間だけ録音して、話した内容を入力欄に入れる
shield危ない操作(フォルダごと削除・履歴の上書き・秘密ファイルの書き換えなど)は止めて確認し、Claude が書いたファイルは自動で構文チェックする。ほかの作業中のチャットも表示し、名前を押すとそのチャットへ飛べる。守るもの・守れないものは shield/SAFETY.md

別のパソコンに入れる方法

ログインはいりません。ほしい mod だけ選んで入れられます。

  1. ターミナルで、まず棚の登録を打つ(1回だけ)
  2. そのあと、ほしい mod の行だけ打つ
claude plugin marketplace add kokotosu/claude-mods
claude plugin install usage-band@kokot-claude-mods
claude plugin install chat-time@kokot-claude-mods
claude plugin install ja-questions@kokot-claude-mods
claude plugin install push-to-talk@kokot-claude-mods
claude plugin install shield@kokot-claude-mods
  1. Claude を開き直す

Claude に頼むなら「GitHub の kokotosu/claude-mods から chat-time だけ入れて」のように言えば入れてくれる。

更新したとき

claude plugin marketplace update kokot-claude-mods

そのあと claude plugin update <mod名>@kokot-claude-mods を打つ。

注意

  • chat-time と usage-band の円グラフは Node.js が要る
  • push-to-talk は Windows 専用
  • shield のチャットへ飛ぶボタンは、パソコンの Claude アプリで押したときだけ効く
  • チャット名の色分けを前のパソコンと同じにしたいときは、~/.claude/mods/chat-colors.json もコピーする
Source 5 files
hooks/register.tsx 370 lines
1import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
2
3import type { Chart } from '../types'
4import { describeChart, donut } from './donut'
5import { FULL_HEIGHT, cells, describe, svg, toMeters } from './draw'
6
7const meters = { plugin: 'usage-band', key: 'meters' } as const
8const chart = { plugin: 'usage-band', key: 'chart' } as const
9const hint = { plugin: 'usage-band', key: 'hint' } as const
10// How long after a turn ends the chat's name is looked at for its mark.
11const NAME_DELAY_MS = 1800
12
13// What the tokens went on is added up this often, by one session for all:
14// a newer sum another session left in the store is used as it is.
15const CHART_KEY = 'chart'
16const CHART_DAYS = 7
17const CHART_EVERY_MS = 5 * 60_000
18// The roles the chart can name; the rest are only summed.
19const CHART_GROUPS = 12
20
21const RASTER_KEY = 'bar'
22const FRAME_MS = 150
23const CELL_PX = 7.8
24// The rate-limit windows are the account's, not one session's: the newest
25// reading any session had is kept here for the sessions that have none yet.
26const SHARED_KEY = 'rateLimits'
27const SHARED_POLL_MS = 30_000
28
29type Shared = { at: number; limits: SessionRateLimit[] }
30
31const isShared = (value: unknown): value is Shared =>
32  typeof value === 'object' && value !== null && Array.isArray((value as Shared).limits)
33
34// A window whose reset time has passed is full again.
35const afterResets = (limits: SessionRateLimit[], now: number) =>
36  limits.map(limit =>
37    limit.resetsAt !== undefined && Date.parse(limit.resetsAt) <= now
38      ? { ...limit, percentUsed: 0 }
39      : limit,
40  )
41
42// Module state: a reload starts both over, and session.start fills them again.
43let context: SessionContextUsage = { window: 0 }
44let sharedAt = 0
45let isTallying = false
46
47const isChart = (value: unknown): value is Chart =>
48  typeof value === 'object' && value !== null && Array.isArray((value as Chart).groups)
49
50// Adds up the transcripts with the script beside this module, unless a sum
51// fresh enough is in the store already, and shows it. Failing leaves the last
52// sum showing, or none.
53async function tally($: EngineInterface) {
54  if (isTallying) return
55  isTallying = true
56  try {
57    const now = await $.clock.now()
58    const stored = await $.store.get(CHART_KEY)
59    let sum = isChart(stored) && now - stored.at < CHART_EVERY_MS - 10_000 ? stored : null
60
61    if (sum === null) {
62      const { exitCode, stdout } = await $.process.run(
63        ['node', `${$.plugin.root}/hooks/tally.mjs`, String(CHART_DAYS)],
64        { timeoutMs: 5 * 60_000 },
65      )
66      const out: unknown = exitCode === 0 ? JSON.parse(stdout) : null
67      if (!isChart(out)) return
68      sum = { at: out.at, days: out.days, groups: out.groups.slice(0, CHART_GROUPS) }
69      // The rest are summed into one, so the shares stay true.
70      const rest = out.groups.slice(CHART_GROUPS).reduce((total, group) => total + group.tokens, 0)
71      if (rest > 0) sum.groups.push({ name: 'その他', tokens: rest })
72      await $.store.set(CHART_KEY, sum)
73    }
74
75    await $.state.set(chart, sum)
76  } catch {
77    // No node, or the transcripts unreadable: the meters show on their own.
78  } finally {
79    isTallying = false
80  }
81}
82
83// The marks a chat's name may begin with, one color each, and the table that
84// says which chat has which: a chat keeps the color it was first given.
85const MARKS = ['🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '🟤', '⚫', '⚪']
86
87type ColorTable = { byCli?: Record<string, string> } & Record<string, unknown>
88
89// This chat's mark: the one the table has for it, else one drawn at random
90// from the marks fewest chats have, and written to the table for good.
91async function markOf($: EngineInterface): Promise<string> {
92  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
93  const file = `${home}/.claude/mods/chat-colors.json`
94  const id = await $.session.id()
95  const table = JSON.parse(String(await $.fs.read(file))) as ColorTable
96  const byCli = table.byCli ?? {}
97  const known = byCli[id]
98  if (known !== undefined) return known
99
100  const used = MARKS.map(mark => Object.values(byCli).filter(given => given === mark).length)
101  const least = MARKS.filter((_, i) => used[i] === Math.min(...used))
102  const drawn = least[crypto.getRandomValues(new Uint32Array(1))[0]! % least.length] ?? MARKS[0]!
103  await $.fs.write(file, `${JSON.stringify({ ...table, byCli: { ...byCli, [id]: drawn } }, null, 2)}\n`)
104
105  return drawn
106}
107
108// The app's tool that renames a chat in the sidebar.
109const RENAME = 'mcp__ccd_session_mgmt__set_session_title'
110
111// The app's record of one session, when it is this session's.
112async function titleIn($: EngineInterface, file: string, id: string): Promise<string | null> {
113  try {
114    const record = JSON.parse(String(await $.fs.read(file))) as { cliSessionId?: unknown; title?: unknown }
115
116    return record.cliSessionId === id && typeof record.title === 'string' ? record.title : null
117  } catch {
118    return null
119  }
120}
121
122// The chat's name as the sidebar has it: the desktop app keeps it in its own
123// record of the session, found once among its records and remembered.
124async function sidebarName($: EngineInterface): Promise<string | null> {
125  const id = await $.session.id()
126  const remembered = await $.store.get(`record:${id}`)
127  if (typeof remembered === 'string') {
128    const title = await titleIn($, remembered, id)
129    if (title !== null) return title
130  }
131
132  const appData = await $.env.get('APPDATA')
133  const home = await $.env.get('HOME')
134  const root = `${appData !== undefined ? `${appData}/Claude` : `${home}/Library/Application Support/Claude`}/claude-code-sessions`
135  for (const account of await $.fs.list(root)) {
136    for (const space of await $.fs.list(`${root}/${account.name}`)) {
137      const folder = `${root}/${account.name}/${space.name}`
138      for (const entry of await $.fs.list(folder)) {
139        if (entry.kind !== 'file' || !entry.name.startsWith('local_')) continue
140        const title = await titleIn($, `${folder}/${entry.name}`, id)
141        if (title !== null) {
142          await $.store.set(`record:${id}`, `${folder}/${entry.name}`)
143
144          return title
145        }
146      }
147    }
148  }
149
150  return null
151}
152
153// Puts the chat's mark in front of its name in the sidebar, once it has a
154// name and whenever a rename has dropped the mark. Failing changes nothing.
155async function colorName($: EngineInterface) {
156  try {
157    const title = (await sidebarName($))?.trim() ?? ''
158    if (title === '') return
159
160    const mark = await markOf($)
161    if (title.startsWith(`${mark} `)) return
162
163    const bare = MARKS.reduce((name, other) => (name.startsWith(other) ? name.slice(other.length) : name), title).trim()
164    if (bare !== '') await $.tool.call({ tool: RENAME, session_id: 'self', title: `${mark} ${bare}` })
165  } catch {
166    // No record, no table or no such tool here: the name stays as it is.
167  }
168}
169
170// The desktop app samples the account's usage itself, every few minutes, into
171// a file of its own: what a session reads before its first response.
172async function appSample($: EngineInterface): Promise<Shared | null> {
173  try {
174    const appData = await $.env.get('APPDATA')
175    const home = await $.env.get('HOME')
176    const folder =
177      appData !== undefined ? `${appData}/Claude` : `${home}/Library/Application Support/Claude`
178    const text = await $.fs.read(`${folder}/plan-usage-history.json`)
179    const samples: unknown = JSON.parse(String(text)).samples
180    const last = Array.isArray(samples) ? samples[samples.length - 1] : undefined
181    if (typeof last?.t !== 'number') return null
182
183    const now = await $.clock.now()
184    const limits: SessionRateLimit[] = []
185    // A five-hour reading older than its window says nothing any more.
186    if (typeof last.u?.fh === 'number' && now - last.t < 5 * 3_600_000) {
187      limits.push({ kind: 'five_hour', percentUsed: last.u.fh })
188    }
189    if (typeof last.u?.sd === 'number') limits.push({ kind: 'seven_day', percentUsed: last.u.sd })
190
191    return limits.length > 0 ? { at: last.t, limits } : null
192  } catch {
193    return null
194  }
195}
196
197// The newest reading from outside this session: another session's, or the app's.
198async function latestShared($: EngineInterface): Promise<Shared | null> {
199  const stored = await $.store.get(SHARED_KEY)
200  const fromStore = isShared(stored) ? stored : null
201  const fromApp = await appSample($)
202
203  if (fromStore === null || fromApp === null) return fromStore ?? fromApp
204  return fromApp.at > fromStore.at ? fromApp : fromStore
205}
206
207async function show($: EngineInterface, reported: SessionRateLimit[]) {
208  const now = await $.clock.now()
209  let limits = reported
210
211  // Sharing the reading is a nicety: the meters are drawn whether or not it works.
212  try {
213    if (reported.length > 0) {
214      // A window this reading leaves out (Fable's, which only a session on
215      // Fable hears of) is kept from the last reading that had it.
216      const stored = await $.store.get(SHARED_KEY)
217      const kept = isShared(stored)
218        ? stored.limits.filter(old => !reported.some(fresh => fresh.kind === old.kind))
219        : []
220      limits = [...reported, ...kept]
221      sharedAt = now
222      await $.store.set(SHARED_KEY, { at: now, limits })
223    } else {
224      const shared = await latestShared($)
225      if (shared !== null) {
226        sharedAt = shared.at
227        limits = shared.limits
228      }
229    }
230  } catch {
231    limits = reported
232  }
233
234  await $.state.set(meters, toMeters(context, afterResets(limits, now)))
235}
236
237export const register: Register = on => {
238  // The terminal's mounted Raster, for the frame timer to repaint; the
239  // desktop's SVG animates itself.
240  let mounted: { requestId: string; columns: number } | null = null
241  let tick = 0
242
243  on('session.start', async ($, e, next) => {
244    // A failure here must not cost the session its band: the first
245    // measurement fills the meters in if this cannot.
246    try {
247      const usage = await $.session.usage()
248      context = usage.context
249      await show($, usage.rateLimits)
250    } catch {
251      await $.state.set(meters, toMeters(context, []))
252    }
253
254    // Another session's newer reading moves this one's bars too.
255    $.clock.every(SHARED_POLL_MS, async () => {
256      const shared = await latestShared($)
257      if (shared !== null && shared.at > sharedAt) await show($, [])
258    })
259
260    // A chat reopened under a name with no mark gets it back.
261    $.clock.after(0, () => {
262      void colorName($)
263      void tally($)
264    })
265    $.clock.every(CHART_EVERY_MS, () => tally($))
266    // The time to each reset counts down by the minute.
267    $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
268
269    $.clock.every(FRAME_MS, async () => {
270      if (mounted === null) return
271
272      tick += 1
273      const { requestId, columns } = mounted
274      const { value = [] } = await $.state.get(meters)
275      const result = await $.ui.blit({
276        requestId,
277        key: RASTER_KEY,
278        cells: cells(value, columns, tick),
279      })
280
281      if ('deny' in result) mounted = null
282    })
283
284    return next(e)
285  })
286
287  on('session.measure', async ($, e, next) => {
288    context = e.context
289    await show($, e.rateLimits)
290
291    return next(e)
292  })
293
294  // A turn begins with the prompt and ends at `turn.complete`.
295  // The desktop box will not send while it is empty, so the suggestion it
296  // would show dim is written into it instead: Enter then sends it as it
297  // stands. It is remembered too, for a surface that does send an empty box.
298  on('prompt.suggest', async ($, e, next) => {
299    const shown = await next(e)
300    await $.state.set(hint, shown.isShown ? e.text : '')
301    if (shown.isShown && (await $.session.surface()) === 'desktop') {
302      await $.prompt.fill({ text: e.text, mode: 'replace' })
303    }
304
305    return shown
306  })
307
308  on('prompt.submit', async ($, e, next) => {
309    const { value: suggested = '' } = await $.state.get(hint)
310    if (suggested !== '') await $.state.set(hint, '')
311
312    return next(e.text.trim() === '' && suggested !== '' ? { ...e, text: suggested } : e)
313  })
314
315  on('turn.complete', async ($, e, next) => {
316    // A subagent's turn ending is not the chat's.
317    if (e.agentId !== undefined) return next(e)
318
319    // The app names a chat during its first turns: the mark follows the name.
320    $.clock.after(NAME_DELAY_MS, () => {
321      void colorName($)
322    })
323
324    return next(e)
325  })
326
327  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
328    const { value: current = [] } = await $.state.get(meters)
329
330    if (e.props.hasSurvey) {
331      return next(e)
332    }
333
334    if (e.surface === 'terminal') {
335      const { Raster } = $.ui.resolve(e)
336      const columns = Math.max(1, Math.min(512, e.props.bodyColumns))
337      mounted = { requestId: e.requestId, columns }
338
339      return (
340        <Raster key={RASTER_KEY} columns={columns} rows={1} cells={cells(current, columns, tick)} />
341      )
342    }
343
344    if (e.surface === 'desktop') {
345      const { Box, Svg } = $.ui.resolve(e)
346
347      // The slot's width is known only in cells; a cell is about this wide.
348      const width = Math.max(240, Math.round(e.props.bodyColumns * CELL_PX))
349      const { value: sum = null } = await $.state.get(chart)
350      const said = [describeChart(sum), describe(current, await $.clock.now())].filter(part => part !== '').join(' / ')
351
352      // Another mod's strip (shield's) may be drawn beneath this one's.
353      return (
354        <Box flexDirection="column">
355          {await next(e)}
356          <Svg
357            source={svg(current, width, donut(sum, width, e.props.isWorking), e.props.isWorking, await $.clock.now())}
358            alt={said}
359            width={width}
360            height={FULL_HEIGHT}
361            isInteractive
362          />
363        </Box>
364      )
365    }
366
367    return next(e)
368  })
369}
370
hooks/donut.ts 167 lines
1// What the tokens went on, as an infographic above the meters: a ring cut into
2// the roles that used the most over the last days, the total in its middle,
3// and beside it each role's share, largest first.
4
5import type { Chart, ChartGroup } from '../types'
6import { sheen } from './glow'
7
8// One color per place in the order, each with a lighter tint its piece fades
9// from; the rest share a quiet slate.
10const COLORS = ['#FF7A59', '#FFB547', '#3DD6B5', '#5AA9FF', '#A78BFA']
11const TINTS = ['#FFB199', '#FFD58F', '#8CEBD6', '#A3CFFF', '#CDB9FF']
12const REST = '#4B4A47'
13const REST_TINT = '#6A6863'
14const TRACK = '#2E2D2A'
15const LABEL = '#A8A39A'
16const DIM = '#6F6B63'
17const NUMBER = '#FAF9F5'
18const FONT = `'Yu Gothic UI','Meiryo','Hiragino Sans',sans-serif`
19// Figures in a face whose digits share one width, so columns of them line up.
20const DIGITS = `'Segoe UI','Helvetica Neue',Arial,sans-serif`
21
22export const CHART_HEIGHT = 84
23const RADIUS = 30
24const RING = 8
25const CENTER = { x: 8 + RADIUS + RING / 2, y: CHART_HEIGHT / 2 - 3 }
26// The largest role, set large beside the ring, then the others in a list.
27const HERO_LEFT = CENTER.x + RADIUS + RING / 2 + 20
28const HERO_WIDTH = 128
29const LIST_LEFT = HERO_LEFT + HERO_WIDTH + 18
30// A list column's width: the narrowest that still shows a name, the widest
31// before the columns drift apart.
32const MIN_COLUMN = 170
33const MAX_COLUMN = 230
34const ROW = 24
35// Ring length left blank between two pieces.
36const GAP = 3.5
37
38const escape = (text: string) =>
39  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
40
41// 47億, 3.2億, 8,500万: the way the total reads at a glance.
42export function amount(tokens: number): string {
43  if (tokens >= 1e9) return `${Math.round(tokens / 1e8)}億`
44  if (tokens >= 1e8) return `${(tokens / 1e8).toFixed(1)}億`
45  if (tokens >= 1e4) return `${Math.round(tokens / 1e4).toLocaleString('en-US')}万`
46  return `${tokens}`
47}
48
49// The largest roles by name, at most `named` of them, and the others as one piece.
50export function pieces(
51  groups: readonly ChartGroup[],
52  named = COLORS.length,
53): { name: string; tokens: number; color: string; tint: string }[] {
54  const count = Math.min(named, COLORS.length)
55  const top = groups.slice(0, count).map((group, i) => ({ ...group, color: COLORS[i]!, tint: TINTS[i]! }))
56  const rest = groups.slice(count).reduce((sum, group) => sum + group.tokens, 0)
57
58  return rest > 0 ? [...top, { name: 'その他', tokens: rest, color: REST, tint: REST_TINT }] : top
59}
60
61export function describeChart(chart: Chart | null): string {
62  if (chart === null || chart.groups.length === 0) return ''
63  const total = chart.groups.reduce((sum, group) => sum + group.tokens, 0)
64
65  return `${chart.days}日間のトークン ${amount(total)}: ${pieces(chart.groups)
66    .map(piece => `${piece.name} ${Math.round((piece.tokens / total) * 100)}%`)
67    .join('、')}`
68}
69
70// A name cut to what fits, by a rough width: full-width characters count two.
71function fit(name: string, room: number): string {
72  let width = 0
73  let out = ''
74  for (const char of name) {
75    width += /[\u0000-ÿ]/.test(char) ? 1 : 2
76    if (width > room) return `${out}…`
77    out += char
78  }
79  return out
80}
81
82// The total, the largest share and the bars glow, the glow drifting while
83// Claude is at work.
84export function donut(chart: Chart | null, width: number, isMoving = false): string {
85  if (chart === null || chart.groups.length === 0) return ''
86  const total = chart.groups.reduce((sum, group) => sum + group.tokens, 0)
87  if (total <= 0) return ''
88
89  // Beside the largest role, a list of two columns when there is room, else
90  // one; three rows each, the last place the rest's.
91  const room = width - LIST_LEFT
92  const columns = room >= 2 * MIN_COLUMN ? 2 : 1
93  const parts = pieces(chart.groups, columns * 3)
94  const share = (tokens: number) => Math.round((tokens / total) * 100)
95  const { x: cx, y: cy } = CENTER
96  // Ids named after the layout, so two drawings on one page never share them.
97  const id = (name: string) => `ub${columns}-${name}`
98
99  // Each piece fades from its tint at its start to its color at its end, and
100  // the whole ring glows faintly.
101  let defs = `<filter id="${id('glow')}" x="-30%" y="-30%" width="160%" height="160%">` +
102    `<feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
103  parts.forEach((part, i) => {
104    defs += `<linearGradient id="${id(`p${i}`)}" x1="0" y1="0" x2="1" y2="1">` +
105      `<stop offset="0" stop-color="${part.tint}"/><stop offset="1" stop-color="${part.color}"/></linearGradient>` +
106      sheen(id(`s${i}`), part.color, part.tint, isMoving, i * 0.8)
107  })
108  defs += sheen(id('total'), LABEL, NUMBER, isMoving, 2)
109
110  const circumference = 2 * Math.PI * RADIUS
111  let ring =
112    `<circle cx="${cx}" cy="${cy}" r="${RADIUS + RING / 2 + 3.5}" fill="none" stroke="${TRACK}" stroke-width="1"/>` +
113    `<circle cx="${cx}" cy="${cy}" r="${RADIUS}" fill="none" stroke="${TRACK}" stroke-width="${RING}"/>`
114  let start = 0
115  for (const [i, part] of parts.entries()) {
116    const length = (part.tokens / total) * circumference
117    const drawn = Math.max(0.5, length - GAP)
118    // Pieces start at twelve o'clock and run clockwise.
119    ring +=
120      `<circle cx="${cx}" cy="${cy}" r="${RADIUS}" fill="none" stroke="url(#${id(`p${i}`)})" ` +
121      `stroke-width="${RING}" stroke-dasharray="${drawn.toFixed(2)} ${circumference.toFixed(2)}" ` +
122      `stroke-dashoffset="${(-start).toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"/>`
123    start += length
124  }
125
126  const middle =
127    `<text x="${cx}" y="${cy + 4}" text-anchor="middle" font-family="${FONT}" font-size="15" ` +
128    `font-weight="700" fill="url(#${id('total')})">${amount(total)}</text>` +
129    `<text x="${cx}" y="${cy + 16}" text-anchor="middle" font-family="${DIGITS}" font-size="7.5" ` +
130    `letter-spacing="1" font-weight="600" fill="${DIM}">TOKENS</text>`
131
132  // The largest role: its share large in its own color, its name under it.
133  const [lead, ...others] = parts
134  const hero = lead === undefined ? '' :
135    `<text x="${HERO_LEFT}" y="18" font-family="${DIGITS}" font-size="9" letter-spacing="1.6" ` +
136    `font-weight="600" fill="${DIM}">TOP · ${chart.days}D</text>` +
137    `<text x="${HERO_LEFT - 1}" y="52" font-family="${DIGITS}" font-size="36" font-weight="700" ` +
138    `fill="url(#${id('s0')})">${share(lead.tokens)}<tspan font-size="16" dx="2" fill="${lead.color}">%</tspan></text>` +
139    `<text x="${HERO_LEFT}" y="70" font-family="${FONT}" font-size="12.5" font-weight="700" ` +
140    `fill="${NUMBER}">${escape(fit(lead.name, 19))}</text>`
141
142  // The others, ranked: a number, the name, the share, and a hairline bar
143  // as long as the share is of the largest's.
144  const column = Math.min(room / columns, MAX_COLUMN)
145  const top = (CHART_HEIGHT - 3 * ROW) / 2 + 2
146  const largest = lead?.tokens ?? 1
147  let list = `<line x1="${LIST_LEFT - 12}" y1="${top + 2}" x2="${LIST_LEFT - 12}" y2="${top + 3 * ROW - 4}" stroke="${TRACK}"/>`
148  others.forEach((part, i) => {
149    const x = LIST_LEFT + Math.floor(i / 3) * column
150    const y = top + (i % 3) * ROW
151    const end = x + column - 16
152    const bar = Math.max(2, (end - x - 22) * Math.min(1, part.tokens / largest))
153    const rank = part.name === 'その他' ? '··' : String(i + 2).padStart(2, '0')
154    list +=
155      `<text x="${x}" y="${y + 12}" font-family="${DIGITS}" font-size="9.5" font-weight="700" ` +
156      `fill="${part.color}">${rank}</text>` +
157      `<text x="${x + 22}" y="${y + 12}" font-family="${FONT}" font-size="12" ` +
158      `fill="${LABEL}">${escape(fit(part.name, Math.max(4, Math.floor((column - 84) / 6.2))))}</text>` +
159      `<text x="${end}" y="${y + 12}" text-anchor="end" font-family="${DIGITS}" font-size="12.5" ` +
160      `font-weight="700" fill="${NUMBER}">${share(part.tokens)}<tspan font-size="9" fill="${DIM}">%</tspan></text>` +
161      `<rect x="${x + 22}" y="${y + 18}" width="${(end - x - 22).toFixed(1)}" height="2" rx="1" fill="${TRACK}"/>` +
162      `<rect x="${x + 22}" y="${y + 18}" width="${bar.toFixed(1)}" height="2" rx="1" fill="url(#${id(`s${i + 1}`)})"/>`
163  })
164
165  return `<defs>${defs}</defs><g filter="url(#${id('glow')})">${ring}</g>${middle}${hero}${list}`
166}
167
hooks/draw.ts 220 lines
1import type { SessionContextUsage, SessionRateLimit } from 'claude-code'
2
3import type { Meter } from '../types'
4import { CHART_HEIGHT } from './donut'
5import { breathe, crossing, sheen } from './glow'
6
7// Claude's palette, tuned for a dark background: a bright clay for what is
8// left, amber when it runs low, a coral red at the end, oat for the labels
9// and a warm charcoal for the spent track.
10const CLAY = '#E8825F'
11const CLAY_LIGHT = '#FBD9C9'
12const KRAFT = '#F0B44C'
13const KRAFT_LIGHT = '#FCE7B8'
14const DANGER = '#F0625A'
15const DANGER_LIGHT = '#FBC1BC'
16const TRACK = '#45433E'
17const LABEL = '#C9C4B5'
18const NUMBER = '#FAF9F5'
19
20const LABELS: Record<string, string> = {
21  five_hour: '5H',
22  seven_day: '7D',
23  spend_limit: '$',
24}
25
26const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)))
27
28export function toMeters(
29  context: SessionContextUsage,
30  rateLimits: readonly SessionRateLimit[],
31): Meter[] {
32  const limits = rateLimits.map(limit => {
33    const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
34    return {
35      // A model's own weekly window (Fable's) is named after the model.
36      label: LABELS[limit.kind] ?? (/fable/i.test(limit.kind) ? 'FB' : '?'),
37      remaining: clamp(100 - limit.percentUsed),
38      ...(Number.isNaN(resetsAt) ? {} : { resetsAt }),
39    }
40  })
41
42  return [...limits, { label: 'CTX', remaining: clamp(100 - (context.percent ?? 0)) }]
43}
44
45const MINUTE = 60_000
46const HOUR = 60 * MINUTE
47const DAY = 24 * HOUR
48
49// How long until a window resets: in days while a day or more, else in hours
50// and minutes, and minutes alone under an hour. Nothing once passed.
51export function resetIn(meter: Meter, now: number): string {
52  if (meter.resetsAt === undefined || meter.resetsAt <= now) return ''
53  const left = meter.resetsAt - now
54  const minutes = Math.ceil(left / MINUTE)
55  if (left >= DAY) return `${Math.floor(left / DAY)}日`
56  const hours = Math.floor(minutes / 60)
57  return hours === 0 ? `${minutes}分` : `${hours}時間${minutes % 60}分`
58}
59
60export function describe(meters: readonly Meter[], now = 0): string {
61  return meters
62    .map(m => {
63      const reset = resetIn(m, now)
64      return `${m.label} 残り${m.remaining}%${reset === '' ? '' : ` リセットまで${reset}`}`
65    })
66    .join(' / ')
67}
68
69function tone(remaining: number): { base: string; light: string; blink: number } {
70  if (remaining <= 10) return { base: DANGER, light: DANGER_LIGHT, blink: 0.4 }
71  if (remaining <= 25) return { base: KRAFT, light: KRAFT_LIGHT, blink: 0.8 }
72  return { base: CLAY, light: CLAY_LIGHT, blink: 1.2 }
73}
74
75const filled = (remaining: number, segments: number) =>
76  remaining === 0 ? 0 : Math.max(1, Math.round((remaining / 100) * segments))
77
78// The word set before each percent, so it reads as what is left, not what is
79// spent.
80const LEFT_WORD = '残り'
81
82// ---- desktop: one SVG, in the same look as the chart above it ----
83
84// What is left, by how much: a coral gradient, amber when it runs low, red at
85// the end, which pulses.
86const SHADES = {
87  ok: { from: '#FFB199', to: '#FF7A59' },
88  low: { from: '#FFD58F', to: '#FFB547' },
89  out: { from: '#FF9A94', to: '#FF5A52' },
90}
91const RAIL = '#2E2D2A'
92const DIM = '#6F6B63'
93const FACE = `'Yu Gothic UI','Meiryo','Hiragino Sans',sans-serif`
94const DIGITS = `'Segoe UI','Helvetica Neue',Arial,sans-serif`
95
96const METER_GAP = 22
97const BAND_HEIGHT = 30
98const BAR = 4
99
100// Laid out in pixels for the width it is given: each meter takes an equal
101// share, its name and what is left above a bar as wide as the share.
102// The chart's strip sits above the meters; `svg` is handed the chart to draw in it.
103export const FULL_HEIGHT = BAND_HEIGHT + CHART_HEIGHT
104
105// The numbers and bars glow, the glow drifting while Claude is at work.
106// Beside each window's name, how long until it resets.
107export function svg(meters: readonly Meter[], width: number, chart = '', isMoving = false, now = 0): string {
108  const count = Math.max(1, meters.length)
109  const share = (width - 16 - METER_GAP * (count - 1)) / count
110  let defs = crossing('ub-rail', SHADES.ok.to, RAIL, isMoving)
111  // A hairline parts the chart from the meters.
112  let body = `<rect x="8" y="0" width="${width - 16}" height="1" fill="url(#ub-rail)"/>`
113
114  meters.forEach((meter, index) => {
115    const shade = meter.remaining <= 10 ? SHADES.out : meter.remaining <= 25 ? SHADES.low : SHADES.ok
116    const left = 8 + index * (share + METER_GAP)
117    const right = left + share
118    const fill = meter.remaining === 0 ? 0 : Math.max(BAR, (share * meter.remaining) / 100)
119    const id = `ub-m${index}`
120
121    defs += sheen(id, shade.to, shade.from, isMoving, index * 0.8) + sheen(`ub-n${index}`, shade.from, NUMBER, isMoving, index * 0.8)
122    // At the end it pulses, so it is noticed.
123    const pulse =
124      shade === SHADES.out
125        ? '<animate attributeName="opacity" values="1;0.35;1" dur="1.2s" repeatCount="indefinite"/>'
126        : ''
127    // A narrow share keeps the time and drops the words before it.
128    const reset = resetIn(meter, now)
129    const until = reset === '' ? '' : share >= 200 ? `リセットまで ${reset}` : `↻ ${reset}`
130    body +=
131      `<text x="${left}" y="17" font-family="${DIGITS}" font-size="9.5" font-weight="700" ` +
132      `letter-spacing="1.4" fill="${DIM}">${meter.label}` +
133      (until === '' ? '' : `<tspan dx="8" font-family="${FACE}" font-size="10" font-weight="400" letter-spacing="0" fill="${LABEL}">${until}</tspan>`) +
134      `</text>` +
135      `<text x="${right}" y="17" text-anchor="end" font-family="${DIGITS}" font-size="14" font-weight="700" ` +
136      `fill="${NUMBER}"><tspan font-family="${FACE}" font-size="9.5" font-weight="400" fill="${DIM}">${LEFT_WORD} </tspan>` +
137      `<tspan fill="url(#ub-n${index})">${meter.remaining}</tspan><tspan font-size="9" fill="${DIM}">%</tspan></text>` +
138      `<rect x="${left}" y="22" width="${share.toFixed(1)}" height="${BAR}" rx="${BAR / 2}" fill="${RAIL}"/>` +
139      `<rect x="${left}" y="22" width="${fill.toFixed(1)}" height="${BAR}" rx="${BAR / 2}" fill="url(#${id})" ` +
140      `filter="url(#ub-meter-glow)">${pulse}</rect>`
141  })
142
143  defs += breathe('ub-meter-glow', isMoving)
144
145  const height = FULL_HEIGHT
146  const scene = `<defs>${defs}</defs><g transform="translate(0 ${CHART_HEIGHT})">${body}</g>` + chart
147
148  // The viewBox only matters if the frame comes out narrower than asked: the
149  // drawing then shrinks whole instead of being cut off. The frame keeps a
150  // transparent canvas only while its color scheme matches the page around it.
151  return (
152    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="100%" ` +
153    `height="${height}" preserveAspectRatio="xMinYMid meet" ` +
154    `style="color-scheme:light dark;background:transparent">` +
155    `<style>:root,html,body{color-scheme:light dark;background:transparent;margin:0}</style>` +
156    `${scene}</svg>`
157  )
158}
159
160// ---- terminal: one row of Raster cells, repainted by $.ui.blit ----
161
162const DEFAULT = 0x01000000
163const hex = (color: string) => parseInt(color.slice(1), 16)
164
165const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
166
167function base64(bytes: Uint8Array): string {
168  let out = ''
169  for (let i = 0; i < bytes.length; i += 3) {
170    const a = bytes[i] ?? 0
171    const b = bytes[i + 1] ?? 0
172    const c = bytes[i + 2] ?? 0
173    out += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]!
174    out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)]! : '='
175    out += i + 2 < bytes.length ? B64[c & 63]! : '='
176  }
177
178  return out
179}
180
181export function cells(meters: readonly Meter[], columns: number, tick: number): string {
182  const words = new Uint32Array(columns * 3)
183  for (let i = 0; i < columns; i++) words.set([0x20, DEFAULT, DEFAULT], i * 3)
184
185  let x = 0
186  const put = (glyph: string, fg: number, bg = DEFAULT) => {
187    if (x < columns) words.set([glyph.codePointAt(0) ?? 0x20, fg, bg], x * 3)
188    x += 1
189  }
190  const type = (text: string, fg: number) => {
191    for (const glyph of text) put(glyph, fg)
192  }
193
194  // Each meter gets an equal share; a segment is one coloured cell and a gap.
195  const share = Math.floor(columns / Math.max(1, meters.length))
196  const segments = Math.max(0, Math.floor((share - 15) / 2))
197
198  for (const meter of meters) {
199    const { base, light, blink } = tone(meter.remaining)
200    const on = filled(meter.remaining, segments)
201    // The highlight sweeps the filled run, then rests for as long again.
202    const sweep = tick % Math.max(1, segments * 2)
203    const isTipLit = Math.floor((tick * 0.15) / (blink / 2)) % 2 === 0
204
205    type(`${meter.label} `, hex(LABEL))
206    for (let i = 0; i < segments; i++) {
207      let bg = hex(TRACK)
208      if (i < on) bg = hex(i === sweep ? light : base)
209      if (i === on - 1 && !isTipLit) bg = hex(TRACK)
210      put(' ', DEFAULT, bg)
211      x += 1
212    }
213    type(`${meter.remaining}%`.padStart(4), hex(NUMBER))
214    type(' left', hex(LABEL))
215    x += 2
216  }
217
218  return base64(new Uint8Array(words.buffer))
219}
220
hooks/glow.ts 44 lines
1// The slow glow kokot's mods share: a light drifting sideways through a
2// gradient, and a soft halo that swells and fades.
3//
4// They move only while `isMoving`. A page with anything moving on it is drawn
5// again every frame, which measured about a quarter of a core however small
6// the motion, and about the same for four strips as for one. So they move
7// while the app is moving anyway (Claude at work, a render running) and rest,
8// still lit, otherwise.
9
10// What it fills seems to glow: `base` at its ends, `bright` in its middle.
11// `shift` starts each at its own point of the drift.
12export function sheen(id: string, base: string, bright: string, isMoving: boolean, shift = 0): string {
13  const drift = isMoving
14    ? `<animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="6s" begin="-${shift}s" repeatCount="indefinite"/>`
15    : ''
16  return (
17    `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0" spreadMethod="reflect">` +
18    `<stop offset="0" stop-color="${base}"/><stop offset="0.5" stop-color="${bright}"/><stop offset="1" stop-color="${base}"/>` +
19    `${drift}</linearGradient>`
20  )
21}
22
23// A soft halo around what it is put on, swelling and fading while moving.
24export function breathe(id: string, isMoving: boolean): string {
25  const swell = isMoving ? `<animate attributeName="stdDeviation" values="0.6;2.4;0.6" dur="4s" repeatCount="indefinite"/>` : ''
26  return (
27    `<filter id="${id}" x="-50%" y="-80%" width="200%" height="260%">` +
28    `<feGaussianBlur stdDeviation="1.2" result="b">${swell}</feGaussianBlur>` +
29    `<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
30  )
31}
32
33// A line with one patch of light crossing it slowly, left to right; at rest,
34// the plain line.
35export function crossing(id: string, color: string, track: string, isMoving: boolean): string {
36  if (!isMoving) return `<linearGradient id="${id}"><stop offset="0" stop-color="${track}"/></linearGradient>`
37  return (
38    `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0">` +
39    `<stop offset="0.3" stop-color="${track}"/><stop offset="0.5" stop-color="${color}"/><stop offset="0.7" stop-color="${track}"/>` +
40    `<animateTransform attributeName="gradientTransform" type="translate" from="-0.7 0" to="0.7 0" dur="9s" repeatCount="indefinite"/>` +
41    `</linearGradient>`
42  )
43}
44
types/index.d.ts 34 lines
1export type Meter = {
2  /** The pixel-font label: `5H`, `7D`, `$`, `CTX`. */
3  label: string
4  /** What is left of the window, 0 to 100. */
5  remaining: number
6  /** When the window resets, in ms; none for the context. */
7  resetsAt?: number
8}
9
10/** One role's tokens: chats of the same name, without mark or number, added up. */
11export type ChartGroup = {
12  name: string
13  tokens: number
14}
15
16/** What the tokens went on over the last days, largest first. */
17export type Chart = {
18  /** When it was added up, in ms. */
19  at: number
20  days: number
21  groups: ChartGroup[]
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'usage-band': {
27      meters: Meter[]
28      chart: Chart
29      /** The dim suggestion the prompt box is showing, or empty. */
30      hint: string
31    }
32  }
33}
34