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

kokot が作った Claude Code 用の mod 集です。
| mod | できること |
|---|---|
| usage-band | 入力欄の上に、残りの使用量(5時間枠・週枠・コンテキスト)とリセットまでの時間を帯で表示する。7日間のトークンを何に使ったかの円グラフも出す。チャット名の色分けもする |
| chat-time | LINE のように、各メッセージの下に送った日時を表示する |
| ja-questions | Claude からの質問が英語なら、日本語に訳して表示する |
| push-to-talk | 決めたキーを押している間だけ録音して、話した内容を入力欄に入れる |
| shield | 危ない操作(フォルダごと削除・履歴の上書き・秘密ファイルの書き換えなど)は止めて確認し、Claude が書いたファイルは自動で構文チェックする。ほかの作業中のチャットも表示し、名前を押すとそのチャットへ飛べる。守るもの・守れないものは shield/SAFETY.md |
ログインはいりません。ほしい 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
Claude に頼むなら「GitHub の kokotosu/claude-mods から chat-time だけ入れて」のように言えば入れてくれる。
claude plugin marketplace update kokot-claude-mods
そのあと claude plugin update <mod名>@kokot-claude-mods を打つ。
~/.claude/mods/chat-colors.json もコピーするhooks/register.tsx 370 lines1import 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}
370hooks/donut.ts 167 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
167hooks/draw.ts 220 lines1import 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}
220hooks/glow.ts 44 lines1// 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}
44types/index.d.ts 34 lines1export 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