Usage ribbon above the prompt: 5h/7d limits, token totals and session cost as colored pills

Claude Code mods (function-hook plugins). Each folder is one mod.
<img width="807" height="132" alt="image" src="https://github.com/user-attachments/assets/ce3d4941-46a0-446b-a085-91e27f61ec22" />
A usage ribbon above the prompt in the Claude Code desktop app (Code tab) and the terminal:
Commands: /kullanim refreshes and prints a one-line summary, /kullanim gizle hides the ribbon, /kullanim goster shows it again.
Token totals come from scripts/tokens.mjs, which reads the session transcript with Node. Without Node the ribbon still works and shows estimates prefixed with ~.
git clone https://github.com/umithavare/claude-mods ~/.claude/mods
Add to the env block of ~/.claude/settings.json (~ works on every OS):
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/usage-band",
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
If CLAUDE_CODE_PLUGIN_DIRS already has a value, append the path with ; on Windows or : on macOS/Linux.
Restart Claude Code, then check:
claude -p "/kullanim"
Update later with git -C ~/.claude/mods pull.
claude plugin validate usage-band
claude plugin test usage-band
For tsc, run /plugin-types usage-band/.claude/types in a Claude Code session first to write the engine's type declarations, then npx -p typescript@5 tsc -p usage-band.
hooks/register.tsx 373 lines1// usage-band: a ribbon of usage pills above the prompt (5h and 7d limits,
2// session token totals, session cost), and /kullanim to refresh, hide or show it.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, SessionMeasureInput, TurnUsage } from 'claude-code'
5
6import type { UsageBandContext, UsageBandTokens, UsageBandWindow } from '../types'
7import { choiceOf, summaryLine } from './format'
8import type { BandView } from './format'
9import { buildPills } from './pills'
10import { drawTerminal } from './terminal'
11import {
12 isNotStarted,
13 MAX_DEPTH,
14 NODE_CANDIDATES,
15 NODE_RETRY_MS,
16 parseReply,
17 RUN_TIMEOUT_MS,
18 separatorOf,
19 signatureOf,
20 subagentDirOf,
21 tokensOf,
22} from './tokens'
23import type { FileStat, ScriptReply } from './tokens'
24
25const fiveHour = atom({ plugin: 'usage-band', key: 'fiveHour' } as const, null)
26const sevenDay = atom({ plugin: 'usage-band', key: 'sevenDay' } as const, null)
27const context = atom({ plugin: 'usage-band', key: 'context' } as const, null)
28const costUsd = atom({ plugin: 'usage-band', key: 'costUsd' } as const, null)
29const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
30const fallback = atom({ plugin: 'usage-band', key: 'fallback' } as const, null)
31const isHidden = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
32const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
33
34const COMMAND = 'kullanim'
35const REFRESH_MS = 30_000
36
37type Measured = Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>
38
39type CountResult =
40 | { kind: 'counted'; sessionId: string; tokens: UsageBandTokens }
41 | { kind: 'unchanged' }
42 | { kind: 'failed'; reason: string }
43
44/** What one refresh read: the figures a summary is built from without reading state back. */
45type Refreshed = { stamp: number; measured: Measured; counted: UsageBandTokens | null }
46
47type CounterState = {
48 sessionId: string | null
49 nodePath: string | null
50 nodeMissingAt: number | null
51 mainPath: string | null
52 lastSignature: string | null
53 lastFailure: string | null
54}
55
56// The module's own memory of the last count; a reload starts it over and recounts once.
57let counter: CounterState = {
58 sessionId: null,
59 nodePath: null,
60 nodeMissingAt: null,
61 mainPath: null,
62 lastSignature: null,
63 lastFailure: null,
64}
65let inFlight: Promise<CountResult> | null = null
66
67const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
68
69const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
70
71function windowOf(measured: Measured, kind: string): UsageBandWindow | null {
72 const found = measured.rateLimits.find(limit => limit.kind === kind)
73 return found === undefined ? null : { percentUsed: found.percentUsed, resetsAt: found.resetsAt ?? null }
74}
75
76function contextOf(measured: Measured): UsageBandContext {
77 return {
78 tokens: measured.context.tokens ?? null,
79 window: measured.context.window,
80 percent: measured.context.percent ?? null,
81 }
82}
83
84function addTurn(prior: UsageBandTokens | null, usage: TurnUsage): UsageBandTokens {
85 const base = prior ?? {
86 uncachedInput: 0,
87 cacheWrite: 0,
88 output: 0,
89 cacheRead: 0,
90 requests: 0,
91 subagentFiles: 0,
92 isEstimate: true,
93 }
94 return {
95 ...base,
96 uncachedInput: base.uncachedInput + usage.input_tokens,
97 cacheWrite: base.cacheWrite + usage.cache_creation_input_tokens,
98 output: base.output + usage.output_tokens,
99 cacheRead: base.cacheRead + usage.cache_read_input_tokens,
100 requests: base.requests + 1,
101 }
102}
103
104/** The band as a refresh just left it, from the figures it read rather than from state. */
105function viewAfter(prior: BandView, refreshed: Refreshed): BandView {
106 const { measured } = refreshed
107 const hasLimits = measured.rateLimits.length > 0
108 const usd = measured.cost?.usd
109 return {
110 fiveHour: hasLimits ? windowOf(measured, 'five_hour') : prior.fiveHour,
111 sevenDay: hasLimits ? windowOf(measured, 'seven_day') : prior.sevenDay,
112 context: contextOf(measured),
113 costUsd: usd !== undefined && usd > 0 ? usd : prior.costUsd,
114 tokens: refreshed.counted ?? prior.tokens,
115 now: refreshed.stamp,
116 }
117}
118
119/** Writes the measured figures, each only when it changed so readers redraw once. */
120async function applyMeasured($: EngineInterface, measured: Measured) {
121 const writes: Promise<unknown>[] = []
122 const nextContext = contextOf(measured)
123 if (!sameJson(await read($, context), nextContext)) writes.push(update($, context, () => nextContext))
124
125 // Limits are read once a model response has reported them; until then the pills stay away.
126 if (measured.rateLimits.length > 0) {
127 const five = windowOf(measured, 'five_hour')
128 const seven = windowOf(measured, 'seven_day')
129 if (!sameJson(await read($, fiveHour), five)) writes.push(update($, fiveHour, () => five))
130 if (!sameJson(await read($, sevenDay), seven)) writes.push(update($, sevenDay, () => seven))
131 }
132
133 const usd = measured.cost?.usd
134 if (usd !== undefined && usd > 0 && (await read($, costUsd)) !== usd) {
135 writes.push(update($, costUsd, () => usd))
136 }
137 await Promise.all(writes)
138}
139
140/** Every *.jsonl under `dir`, workflow agents' nested folders included, with size and mtime. */
141async function collectStats($: EngineInterface, dir: string, depth: number, found: FileStat[]): Promise<FileStat[]> {
142 if (depth > MAX_DEPTH || !(await $.fs.exists(dir))) return found
143 const sep = separatorOf(dir)
144 for (const entry of await $.fs.list(dir)) {
145 if (entry.kind === 'dir') {
146 await collectStats($, `${dir}${sep}${entry.name}`, depth + 1, found)
147 } else if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) {
148 found.push({ size: entry.size, mtimeMs: entry.mtimeMs })
149 }
150 }
151 return found
152}
153
154/** The transcript's signature as $.fs sees it now, in the script's files|bytes|newest form. */
155async function currentSignature($: EngineInterface): Promise<string | null> {
156 const mainPath = counter.mainPath
157 if (mainPath === null) return null
158 try {
159 const main = await $.fs.stat(mainPath)
160 const subagents = await collectStats($, subagentDirOf(mainPath), 1, [])
161 return signatureOf([{ size: main.size, mtimeMs: main.mtimeMs }, ...subagents])
162 } catch {
163 return null
164 }
165}
166
167/**
168 * Runs tokens.mjs with the first Node that starts: PATH, then the usual install places.
169 * The plugin's own folder is the working directory, so a node.exe planted in an opened
170 * repository is never the one Windows finds first.
171 */
172async function runScript($: EngineInterface, sessionId: string, stamp: number): Promise<ScriptReply> {
173 const script = `${$.plugin.root}/scripts/tokens.mjs`
174 const candidates = counter.nodePath === null ? NODE_CANDIDATES : [counter.nodePath]
175 for (const candidate of candidates) {
176 let ran
177 try {
178 ran = await $.process.run([candidate, script, sessionId], { cwd: $.plugin.root, timeoutMs: RUN_TIMEOUT_MS })
179 } catch (error) {
180 // A timeout or a known Node that failed once is no reason to give up on Node.
181 if (!isNotStarted(error) || counter.nodePath !== null) {
182 return { ok: false, error: `tokens.mjs did not finish: ${messageOf(error)}` }
183 }
184 continue
185 }
186 counter = { ...counter, nodePath: candidate, nodeMissingAt: null }
187 return parseReply(ran.stdout, ran.exitCode, ran.stderr)
188 }
189 counter = { ...counter, nodeMissingAt: stamp }
190 return { ok: false, error: 'node was not found (PATH, /usr/local/bin, /opt/homebrew/bin)' }
191}
192
193async function countTokensOnce($: EngineInterface, isForced: boolean): Promise<CountResult> {
194 const sessionId = await $.session.id()
195 if (sessionId !== counter.sessionId) {
196 counter = { ...counter, sessionId, mainPath: null, lastSignature: null, lastFailure: null }
197 }
198
199 const stamp = await $.clock.now()
200 const isNodeResting = counter.nodeMissingAt !== null && stamp - counter.nodeMissingAt < NODE_RETRY_MS
201 if (isNodeResting && !isForced) return { kind: 'failed', reason: 'node was not found' }
202
203 const before = await currentSignature($)
204 if (!isForced && before !== null && before === counter.lastSignature) return { kind: 'unchanged' }
205
206 const reply = await runScript($, sessionId, stamp)
207 if (!reply.ok) return { kind: 'failed', reason: reply.error }
208
209 counter = { ...counter, mainPath: reply.mainPath, lastSignature: reply.signature }
210 return { kind: 'counted', sessionId, tokens: tokensOf(reply) }
211}
212
213async function countTokens($: EngineInterface, isForced: boolean): Promise<CountResult> {
214 try {
215 return await countTokensOnce($, isForced)
216 } catch (error) {
217 return { kind: 'failed', reason: messageOf(error) }
218 }
219}
220
221/** One count at a time; a call made while one runs shares its result. */
222async function refreshTokens($: EngineInterface, isForced = false): Promise<CountResult> {
223 const running = inFlight ?? countTokens($, isForced)
224 inFlight = running
225 const result = await running.finally(() => {
226 inFlight = null
227 })
228
229 // A count that finished after a /clear belongs to the session that ended.
230 const isCurrent = result.kind !== 'counted' || result.sessionId === counter.sessionId
231 if (result.kind === 'counted' && isCurrent && !sameJson(await read($, tokens), result.tokens)) {
232 await update($, tokens, () => result.tokens)
233 }
234 // The band falls back to turn.complete sums ("~"); the reason goes to the debug log once.
235 const failure = result.kind === 'failed' ? result.reason : null
236 if (failure !== null && failure !== counter.lastFailure) {
237 $.ui.log(`token count failed, showing estimates: ${failure}`, { to: 'debug' })
238 }
239 if (result.kind !== 'unchanged') counter = { ...counter, lastFailure: failure }
240 return result
241}
242
243async function refreshAll($: EngineInterface, isForced = false): Promise<Refreshed> {
244 const stamp = await $.clock.now()
245 await update($, now, () => stamp)
246 const measured = await $.session.usage()
247 await applyMeasured($, measured)
248 const result = await refreshTokens($, isForced)
249 return { stamp, measured, counted: result.kind === 'counted' ? result.tokens : null }
250}
251
252/** A refresh started with `void` reports its failure to the debug log instead of an unhandled rejection. */
253async function inBackground($: EngineInterface, what: string, work: Promise<unknown>) {
254 try {
255 await work
256 } catch (error) {
257 $.ui.log(`${what} failed: ${messageOf(error)}`, { to: 'debug' })
258 }
259}
260
261/** After /clear or a resume the process goes on under another session: its totals start over. */
262async function forgetSession($: EngineInterface) {
263 counter = { ...counter, sessionId: null, mainPath: null, lastSignature: null, lastFailure: null }
264 await Promise.all([
265 update($, tokens, () => null),
266 update($, fallback, () => null),
267 update($, costUsd, () => null),
268 update($, context, () => null),
269 ])
270}
271
272async function readView($: EngineInterface): Promise<BandView> {
273 const [five, seven, ctx, usd, counted, estimated, stamp] = await Promise.all([
274 read($, fiveHour),
275 read($, sevenDay),
276 read($, context),
277 read($, costUsd),
278 read($, tokens),
279 read($, fallback),
280 read($, now),
281 ])
282 return {
283 fiveHour: five,
284 sevenDay: seven,
285 context: ctx,
286 costUsd: usd,
287 tokens: counted ?? estimated,
288 now: stamp > 0 ? stamp : await $.clock.now(),
289 }
290}
291
292export const register: Register = on => {
293 on('session.start', async ($, e, next) => {
294 await $.command.register({
295 name: COMMAND,
296 description: 'Kullanım şeridini yeniler ve özet yazar; gizle / goster ile kapatıp açar',
297 argumentHint: '[gizle|goster]',
298 })
299 $.clock.every(REFRESH_MS, () => {
300 void inBackground($, 'refresh', refreshAll($))
301 })
302 void inBackground($, 'refresh', refreshAll($))
303
304 return next(e)
305 })
306
307 on('session.end', async ($, e, next) => {
308 if (e.reason === 'clear' || e.reason === 'resume') await forgetSession($)
309 return next(e)
310 })
311
312 on('session.measure', async ($, e, next) => {
313 await applyMeasured($, e)
314 return next(e)
315 })
316
317 on('turn.complete', async ($, e, next) => {
318 const result = await next(e)
319 const usage = e.usage
320 if (usage !== undefined) await update($, fallback, prior => addTurn(prior, usage))
321 void inBackground($, 'token refresh', refreshTokens($))
322 return result
323 })
324
325 on('command.run', { command: COMMAND }, async ($, e) => {
326 const choice = choiceOf(e.args)
327 if (choice === 'gizle') {
328 await update($, isHidden, () => true)
329 return { text: 'Kullanım şeridi gizlendi. Geri açmak için: /kullanim goster' }
330 }
331 if (choice === 'goster') {
332 await update($, isHidden, () => false)
333 return { text: 'Kullanım şeridi açıldı.' }
334 }
335 if (choice !== '') {
336 return { text: `Bilinmeyen seçenek "${e.args.trim()}". Kullanım: /kullanim [gizle|goster]` }
337 }
338 const prior = await readView($)
339 try {
340 return { text: summaryLine(viewAfter(prior, await refreshAll($, true))) }
341 } catch (error) {
342 return { text: `${summaryLine(prior)} (yenilenemedi: ${messageOf(error)})` }
343 }
344 })
345
346 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
347 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
348
349 const view = await readView($)
350 if (e.surface === 'terminal') {
351 const isEmpty = view.fiveHour === null && view.sevenDay === null && view.tokens === null && view.costUsd === null
352 return isEmpty ? next(e) : drawTerminal($.ui.resolve(e), view)
353 }
354 if (e.surface !== 'desktop' && e.surface !== 'vscode') return next(e)
355
356 const groups = buildPills(view)
357 if (groups.length === 0) return next(e)
358
359 const { Box, Svg } = $.ui.resolve(e)
360 return (
361 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
362 {groups.map(group => (
363 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
364 {group.map(pill => (
365 <Svg source={pill.source} alt={pill.alt} width={pill.width} height={pill.height} isInteractive />
366 ))}
367 </Box>
368 ))}
369 </Box>
370 )
371 })
372}
373hooks/format.ts 198 lines1import type {
2 UsageBandContext,
3 UsageBandTokens,
4 UsageBandWindow,
5} from '../types'
6
7export const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
8export const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
9
10export type Level = 'ok' | 'warn' | 'crit'
11
12/** What one drawing of the band needs, read from $.state. */
13export type BandView = {
14 fiveHour: UsageBandWindow | null
15 sevenDay: UsageBandWindow | null
16 context: UsageBandContext | null
17 costUsd: number | null
18 tokens: UsageBandTokens | null
19 now: number
20}
21
22/** A limit window ready to draw. */
23export type LimitFigures = {
24 label: '5h' | '7d'
25 percent: number
26 remaining: string | null
27 elapsed: number | null
28 level: Level
29}
30
31/** 950, 3.0k, 15.6k, 954.2k, 1.25M, 2.40B */
32export function formatCount(n: number): string {
33 if (!Number.isFinite(n) || n <= 0) return '0'
34 if (n < 1000) return String(Math.round(n))
35 const thousands = Math.round(n / 100) / 10
36 if (thousands < 1000) return `${thousands.toFixed(1)}k`
37 const millions = Math.round(n / 10_000) / 100
38 if (millions < 1000) return `${millions.toFixed(2)}M`
39 return `${(Math.round(n / 10_000_000) / 100).toFixed(2)}B`
40}
41
42/** 40m, 2h 40m, 1d 7h */
43export function formatRemaining(ms: number): string {
44 if (ms <= 0) return '0m'
45 const totalMinutes = Math.ceil(ms / 60_000)
46 const days = Math.floor(totalMinutes / 1440)
47 const hours = Math.floor((totalMinutes % 1440) / 60)
48 const minutes = totalMinutes % 60
49 if (days > 0) return `${days}d ${hours}h`
50 if (hours > 0) return `${hours}h ${minutes}m`
51 return `${minutes}m`
52}
53
54export function formatUsd(usd: number): string {
55 return `$${usd.toFixed(2)}`
56}
57
58export function levelOf(percent: number): Level {
59 if (percent >= 90) return 'crit'
60 if (percent >= 70) return 'warn'
61 return 'ok'
62}
63
64const clamp01 = (n: number) => Math.min(1, Math.max(0, n))
65
66export function limitFigures(
67 label: '5h' | '7d',
68 window: UsageBandWindow,
69 now: number,
70): LimitFigures {
71 const span = label === '5h' ? FIVE_HOURS_MS : SEVEN_DAYS_MS
72 const resetsAt = window.resetsAt === null ? Number.NaN : Date.parse(window.resetsAt)
73 const left = Number.isFinite(resetsAt) ? Math.max(0, resetsAt - now) : null
74
75 return {
76 label,
77 percent: window.percentUsed,
78 remaining: left === null ? null : formatRemaining(left),
79 elapsed: left === null ? null : clamp01(1 - left / span),
80 level: levelOf(window.percentUsed),
81 }
82}
83
84export function inputTotal(tokens: UsageBandTokens): number {
85 return tokens.uncachedInput + tokens.cacheWrite
86}
87
88export function approx(tokens: UsageBandTokens, text: string): string {
89 return tokens.isEstimate ? `~${text}` : text
90}
91
92function contextLine(context: UsageBandContext | null): string {
93 if (context === null || context.tokens === null) return 'Bağlam doluluğu: henüz ölçülmedi'
94 const percent = context.percent ?? Math.round((context.tokens / context.window) * 100)
95 return `Bağlam doluluğu: ${formatCount(context.tokens)} / ${formatCount(context.window)} (%${percent})`
96}
97
98function requestLine(tokens: UsageBandTokens): string {
99 if (tokens.isEstimate) return `Tamamlanan tur: ${tokens.requests}`
100 const subagents = tokens.subagentFiles > 0 ? ` (${tokens.subagentFiles} alt ajan dökümü dahil)` : ''
101 return `İstek sayısı: ${tokens.requests}${subagents}`
102}
103
104function estimateNote(tokens: UsageBandTokens): string[] {
105 return tokens.isEstimate
106 ? ['~ Yaklaşık: döküm okunamadı, tur sonu usage değerleri toplanıyor']
107 : []
108}
109
110export function limitTooltip(figures: LimitFigures, window: UsageBandWindow): string {
111 const name = figures.label === '5h' ? '5 saatlik pencere' : '7 günlük pencere'
112 const lines = [name, `Kullanılan: %${window.percentUsed.toFixed(1)}`]
113 if (figures.remaining !== null) lines.push(`Sıfırlanmaya kalan: ${figures.remaining}`)
114 if (figures.elapsed !== null) {
115 lines.push(`Pencerenin geçen kısmı: %${Math.round(figures.elapsed * 100)} (dikey çizgi)`)
116 }
117 return lines.join('\n')
118}
119
120export function inputTooltip(tokens: UsageBandTokens, context: UsageBandContext | null): string {
121 return [
122 `Giriş token'ı: ${approx(tokens, formatCount(inputTotal(tokens)))}`,
123 ` önbelleksiz giriş: ${formatCount(tokens.uncachedInput)}`,
124 ` önbelleğe yazılan: ${formatCount(tokens.cacheWrite)}`,
125 contextLine(context),
126 requestLine(tokens),
127 ...estimateNote(tokens),
128 ].join('\n')
129}
130
131export function outputTooltip(tokens: UsageBandTokens, context: UsageBandContext | null): string {
132 const perRequest = tokens.requests > 0 ? tokens.output / tokens.requests : 0
133 return [
134 `Çıkış token'ı: ${approx(tokens, formatCount(tokens.output))}`,
135 ` istek başına ortalama: ${formatCount(perRequest)}`,
136 contextLine(context),
137 requestLine(tokens),
138 ...estimateNote(tokens),
139 ].join('\n')
140}
141
142export function cacheTooltip(tokens: UsageBandTokens, context: UsageBandContext | null): string {
143 const allInput = inputTotal(tokens) + tokens.cacheRead
144 const hitRate = allInput > 0 ? Math.round((tokens.cacheRead / allInput) * 100) : 0
145 return [
146 `Önbellekten okunan: ${approx(tokens, formatCount(tokens.cacheRead))}`,
147 ` önbellek isabeti: tüm girişin %${hitRate}'i`,
148 ` önbelleğe yazılan: ${formatCount(tokens.cacheWrite)}`,
149 contextLine(context),
150 requestLine(tokens),
151 ...estimateNote(tokens),
152 ].join('\n')
153}
154
155export function costTooltip(
156 usd: number,
157 tokens: UsageBandTokens | null,
158 context: UsageBandContext | null,
159): string {
160 return [
161 `Oturum maliyeti: ${formatUsd(usd)}`,
162 'API liste fiyatıyla; abonelikte faturanız bu değildir',
163 contextLine(context),
164 ...(tokens === null ? [] : [requestLine(tokens)]),
165 ].join('\n')
166}
167
168/** The /kullanim answer: one line. */
169export function summaryLine(view: BandView): string {
170 const parts: string[] = []
171 for (const [label, window] of [['5h', view.fiveHour], ['7d', view.sevenDay]] as const) {
172 if (window === null) continue
173 const figures = limitFigures(label, window, view.now)
174 const left = figures.remaining === null ? '' : ` (${figures.remaining})`
175 parts.push(`${label} %${Math.round(figures.percent)}${left}`)
176 }
177 if (view.tokens !== null) {
178 const t = view.tokens
179 parts.push(`giriş ${approx(t, formatCount(inputTotal(t)))}`)
180 parts.push(`çıkış ${approx(t, formatCount(t.output))}`)
181 parts.push(`önbellek ${approx(t, formatCount(t.cacheRead))}`)
182 parts.push(`${t.isEstimate ? 'tur' : 'istek'} ${t.requests}`)
183 }
184 if (view.costUsd !== null) parts.push(formatUsd(view.costUsd))
185 if (view.context?.percent != null) parts.push(`bağlam %${view.context.percent}`)
186 return parts.length === 0 ? 'Henüz kullanım verisi yok (ilk model yanıtından sonra gelir).' : parts.join(' · ')
187}
188
189/** /kullanim's argument, case and Turkish dots folded: GİZLE, gızle, GÖSTER → gizle, goster. */
190export function choiceOf(args: string): string {
191 return args
192 .trim()
193 .toLowerCase()
194 .normalize('NFD')
195 .replace(/[\u0300-\u036f]/g, '')
196 .replace(/ı/g, 'i')
197}
198hooks/pills.ts 231 lines1// Builds the desktop band's pills as standalone SVG documents: one per pill,
2// colors in an inner <style> with a prefers-color-scheme: dark override, and a
3// <title> on the whole pill for the hover tooltip.
4import {
5 approx,
6 cacheTooltip,
7 costTooltip,
8 formatCount,
9 formatUsd,
10 inputTooltip,
11 inputTotal,
12 limitFigures,
13 limitTooltip,
14 outputTooltip,
15} from './format'
16import type { BandView, Level, LimitFigures } from './format'
17import type { UsageBandWindow } from '../types'
18
19export type PillSvg = {
20 key: string
21 source: string
22 alt: string
23 width: number
24 height: number
25}
26
27type PaletteName = 'teal' | 'violet' | 'red' | 'green' | 'blue' | 'gold'
28type Tone = { bg: string; border: string; fg: string }
29
30const PALETTES: Record<PaletteName, { light: Tone; dark: Tone }> = {
31 teal: {
32 light: { bg: '#d4f4ec', border: '#a3e1d2', fg: '#0b5045' },
33 dark: { bg: '#0f2b27', border: '#1f4f47', fg: '#9becd8' },
34 },
35 violet: {
36 light: { bg: '#ebe5ff', border: '#cdbffa', fg: '#42288c' },
37 dark: { bg: '#221a3f', border: '#3e3172', fg: '#d2c5ff' },
38 },
39 red: {
40 light: { bg: '#fde3e0', border: '#f4bab3', fg: '#83211b' },
41 dark: { bg: '#371715', border: '#622a25', fg: '#ffb2a9' },
42 },
43 green: {
44 light: { bg: '#ddf4dc', border: '#b0e0ad', fg: '#1a5823' },
45 dark: { bg: '#132c17', border: '#285731', fg: '#a4e7ad' },
46 },
47 blue: {
48 light: { bg: '#dfe9ff', border: '#b6cbf9', fg: '#1b3d8a' },
49 dark: { bg: '#142140', border: '#294272', fg: '#a8c4ff' },
50 },
51 gold: {
52 light: { bg: '#fbefca', border: '#ecd384', fg: '#624400' },
53 dark: { bg: '#30250a', border: '#5e4a13', fg: '#f3d179' },
54 },
55}
56
57const LEVELS: Record<'light' | 'dark', Record<Level, string>> = {
58 light: { ok: '#1e9a53', warn: '#c58a00', crit: '#d63a3a' },
59 dark: { ok: '#3fd07e', warn: '#f2c94c', crit: '#ff6b6b' },
60}
61
62// Lucide icon bodies (ISC), drawn in a 24-unit box.
63const ICONS = {
64 gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
65 calendar:
66 '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
67 hourglass:
68 '<path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>',
69 arrowUp: '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
70 arrowDown: '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
71 layers: '<path d="m12 2-10 5 10 5 10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/>',
72 dollar: '<path d="M12 2v20"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
73} as const
74
75type Part =
76 | { kind: 'icon'; body: string }
77 | { kind: 'text'; text: string; isBold?: boolean }
78 | { kind: 'bar'; percent: number; elapsed: number | null; level: Level }
79 | { kind: 'sep' }
80
81const HEIGHT = 24
82const PILL_TOP = 1
83const PILL_HEIGHT = 22
84const MID = PILL_TOP + PILL_HEIGHT / 2
85const PAD_X = 9
86const GAP = 5
87const SEP_GAP = 7
88const ICON = 13
89const CHAR_W = 7.2
90const BAR_W = 38
91const BAR_H = 6
92const FONT =
93 'ui-monospace,"SF Mono",SFMono-Regular,Menlo,"Cascadia Mono",Consolas,"Liberation Mono",monospace'
94
95const escapeXml = (text: string) =>
96 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
97
98const round = (n: number) => Math.round(n * 100) / 100
99
100function partWidth(part: Part): number {
101 switch (part.kind) {
102 case 'icon':
103 return ICON
104 case 'text':
105 return part.text.length * CHAR_W
106 case 'bar':
107 return BAR_W
108 case 'sep':
109 return 1
110 }
111}
112
113function drawPart(part: Part, x: number): string {
114 switch (part.kind) {
115 case 'icon': {
116 const scale = round(ICON / 24)
117 const y = round(MID - ICON / 2)
118 return `<g class="ic" fill="none" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" transform="translate(${round(x)} ${y}) scale(${scale})">${part.body}</g>`
119 }
120 case 'text': {
121 const width = round(partWidth(part))
122 const weight = part.isBold === true ? ' b' : ''
123 return `<text class="fg${weight}" x="${round(x)}" y="${MID + 4.2}" textLength="${width}" lengthAdjust="spacing">${escapeXml(part.text)}</text>`
124 }
125 case 'bar': {
126 const y = MID - BAR_H / 2
127 const fill = (BAR_W * Math.min(100, Math.max(0, part.percent))) / 100
128 const shown = part.percent > 0 ? Math.max(fill, 2) : 0
129 const track = `<rect class="tr" x="${round(x)}" y="${y}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>`
130 const bar =
131 shown > 0
132 ? `<rect class="${part.level}" x="${round(x)}" y="${y}" width="${round(shown)}" height="${BAR_H}" rx="${BAR_H / 2}"/>`
133 : ''
134 const tick =
135 part.elapsed === null
136 ? ''
137 : `<rect class="tk" x="${round(x + BAR_W * part.elapsed - 0.75)}" y="${MID - 6.5}" width="1.5" height="13" rx="0.75"/>`
138 return track + bar + tick
139 }
140 case 'sep':
141 return `<rect class="sp" x="${round(x)}" y="${MID - 6}" width="1" height="12"/>`
142 }
143}
144
145function styleFor(palette: PaletteName): string {
146 const { light, dark } = PALETTES[palette]
147 const tone = (t: Tone, levels: Record<Level, string>, track: number) =>
148 `.bg{fill:${t.bg};stroke:${t.border}}.fg{fill:${t.fg}}.ic{stroke:${t.fg}}` +
149 `.tr{fill:${t.fg};fill-opacity:${track}}.sp{fill:${t.fg};fill-opacity:.35}.tk{fill:${t.fg}}` +
150 `.ok{fill:${levels.ok}}.warn{fill:${levels.warn}}.crit{fill:${levels.crit}}`
151 return (
152 `svg{color-scheme:light dark}text{font-family:${FONT};font-size:12px}.b{font-weight:700}` +
153 tone(light, LEVELS.light, 0.16) +
154 `@media (prefers-color-scheme:dark){${tone(dark, LEVELS.dark, 0.24)}}`
155 )
156}
157
158function pill(key: string, palette: PaletteName, parts: Part[], tooltip: string, alt: string): PillSvg {
159 let x = PAD_X
160 const drawn: string[] = []
161 parts.forEach((part, index) => {
162 if (index > 0) {
163 const isAroundSep = part.kind === 'sep' || parts[index - 1]?.kind === 'sep'
164 x += isAroundSep ? SEP_GAP : GAP
165 }
166 drawn.push(drawPart(part, x))
167 x += partWidth(part)
168 })
169 const width = Math.ceil(x + PAD_X)
170 const source =
171 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
172 `<style>${styleFor(palette)}</style>` +
173 `<g><title>${escapeXml(tooltip)}</title>` +
174 `<rect class="bg" x="0.5" y="${PILL_TOP + 0.5}" width="${width - 1}" height="${PILL_HEIGHT - 1}" rx="${(PILL_HEIGHT - 1) / 2}"/>` +
175 drawn.join('') +
176 '</g></svg>'
177 return { key, source, alt, width, height: HEIGHT }
178}
179
180function limitPill(figures: LimitFigures, window: UsageBandWindow): PillSvg {
181 const isFiveHour = figures.label === '5h'
182 const percent = `${Math.round(figures.percent)}%`
183 const parts: Part[] = [
184 { kind: 'icon', body: isFiveHour ? ICONS.gauge : ICONS.calendar },
185 { kind: 'text', text: figures.label },
186 { kind: 'bar', percent: figures.percent, elapsed: figures.elapsed, level: figures.level },
187 { kind: 'text', text: percent, isBold: true },
188 ]
189 if (figures.remaining !== null) {
190 parts.push({ kind: 'sep' }, { kind: 'icon', body: ICONS.hourglass }, { kind: 'text', text: figures.remaining })
191 }
192 const left = figures.remaining === null ? '' : `, ${figures.remaining} kaldı`
193 return pill(
194 figures.label,
195 isFiveHour ? 'teal' : 'violet',
196 parts,
197 limitTooltip(figures, window),
198 `${figures.label} limiti ${percent}${left}`,
199 )
200}
201
202/** The band's pills in their three groups: limits, tokens, cost. Empty groups are left out. */
203export function buildPills(view: BandView): PillSvg[][] {
204 const limits: PillSvg[] = []
205 if (view.fiveHour !== null) limits.push(limitPill(limitFigures('5h', view.fiveHour, view.now), view.fiveHour))
206 if (view.sevenDay !== null) limits.push(limitPill(limitFigures('7d', view.sevenDay, view.now), view.sevenDay))
207
208 const tokens: PillSvg[] = []
209 const t = view.tokens
210 if (t !== null) {
211 const input = approx(t, formatCount(inputTotal(t)))
212 const output = approx(t, formatCount(t.output))
213 const cached = approx(t, formatCount(t.cacheRead))
214 tokens.push(
215 pill('input', 'red', [{ kind: 'icon', body: ICONS.arrowUp }, { kind: 'text', text: input }], inputTooltip(t, view.context), `giriş ${input}`),
216 pill('output', 'green', [{ kind: 'icon', body: ICONS.arrowDown }, { kind: 'text', text: output }], outputTooltip(t, view.context), `çıkış ${output}`),
217 pill('cache', 'blue', [{ kind: 'icon', body: ICONS.layers }, { kind: 'text', text: cached }], cacheTooltip(t, view.context), `önbellekten ${cached}`),
218 )
219 }
220
221 const cost: PillSvg[] = []
222 if (view.costUsd !== null) {
223 const usd = formatUsd(view.costUsd)
224 cost.push(
225 pill('cost', 'gold', [{ kind: 'icon', body: ICONS.dollar }, { kind: 'text', text: usd, isBold: true }], costTooltip(view.costUsd, t, view.context), `maliyet ${usd}`),
226 )
227 }
228
229 return [limits, tokens, cost].filter(group => group.length > 0)
230}
231hooks/terminal.tsx 102 lines1// The band on the terminal: the same pills as colored Text runs, the bar as
2// █ and ░ with │ marking how much of the window has passed.
3import type { ElementTable } from 'claude-code'
4
5import { approx, formatCount, formatUsd, inputTotal, limitFigures } from './format'
6import type { BandView, Level, LimitFigures } from './format'
7
8const COLORS = {
9 teal: '#2dd4bf',
10 violet: '#a78bfa',
11 red: '#f87171',
12 green: '#4ade80',
13 blue: '#60a5fa',
14 gold: '#fbbf24',
15 track: '#4b5563',
16} as const
17
18const LEVEL_COLORS: Record<Level, string> = { ok: '#22c55e', warn: '#eab308', crit: '#ef4444' }
19
20const BAR_CELLS = 10
21
22type Run = { text: string; color: string }
23
24/** The bar as runs of one glyph and color each. */
25function barRuns(figures: LimitFigures, tickColor: string): Run[] {
26 const filled = Math.round(Math.min(100, Math.max(0, figures.percent)) / (100 / BAR_CELLS))
27 const tick = figures.elapsed === null ? -1 : Math.min(BAR_CELLS - 1, Math.floor(figures.elapsed * BAR_CELLS))
28 const runs: Run[] = []
29 for (let cell = 0; cell < BAR_CELLS; cell += 1) {
30 const next: Run =
31 cell === tick
32 ? { text: '│', color: tickColor }
33 : cell < filled
34 ? { text: '█', color: LEVEL_COLORS[figures.level] }
35 : { text: '░', color: COLORS.track }
36 const last = runs[runs.length - 1]
37 if (last !== undefined && last.color === next.color && last.text.endsWith(next.text)) {
38 runs[runs.length - 1] = { ...last, text: last.text + next.text }
39 } else {
40 runs.push(next)
41 }
42 }
43 return runs
44}
45
46export function drawTerminal(elements: ElementTable<'terminal'>, view: BandView) {
47 const { Box, Text } = elements
48
49 const limitPill = (figures: LimitFigures, color: string, icon: string) => (
50 <Box flexDirection="row">
51 <Text color={color}>
52 {icon} {figures.label}{' '}
53 </Text>
54 {barRuns(figures, color).map(run => (
55 <Text color={run.color}>{run.text}</Text>
56 ))}
57 <Text color={color} bold>
58 {' '}
59 {Math.round(figures.percent)}%
60 </Text>
61 {figures.remaining !== null && <Text color={color}> ↻ {figures.remaining}</Text>}
62 </Box>
63 )
64
65 const valuePill = (color: string, icon: string, value: string, isBold = false) => (
66 <Box flexDirection="row">
67 <Text color={color} bold={isBold}>
68 {icon} {value}
69 </Text>
70 </Box>
71 )
72
73 const limits = [
74 view.fiveHour === null ? null : limitPill(limitFigures('5h', view.fiveHour, view.now), COLORS.teal, '◔'),
75 view.sevenDay === null ? null : limitPill(limitFigures('7d', view.sevenDay, view.now), COLORS.violet, '▦'),
76 ].filter(pill => pill !== null)
77
78 const t = view.tokens
79 const tokens =
80 t === null
81 ? []
82 : [
83 valuePill(COLORS.red, '↑', approx(t, formatCount(inputTotal(t)))),
84 valuePill(COLORS.green, '↓', approx(t, formatCount(t.output))),
85 valuePill(COLORS.blue, '≋', approx(t, formatCount(t.cacheRead))),
86 ]
87
88 const cost = view.costUsd === null ? [] : [valuePill(COLORS.gold, '$', formatUsd(view.costUsd).slice(1), true)]
89
90 const groups = [limits, tokens, cost].filter(group => group.length > 0)
91
92 return (
93 <Box flexDirection="row" flexWrap="wrap" columnGap={4}>
94 {groups.map(group => (
95 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
96 {group}
97 </Box>
98 ))}
99 </Box>
100 )
101}
102hooks/tokens.ts 78 lines1// Pure helpers for scripts/tokens.mjs: where Node may live, how its reply is
2// read, and the files|bytes|newest-mtime signature that lets a refresh skip
3// an unchanged transcript (the script computes the same one).
4import type { UsageBandTokens } from '../types'
5
6export type FileStat = { size: number; mtimeMs: number }
7
8export type ScriptReply =
9 | {
10 ok: true
11 mainPath: string | null
12 signature: string
13 totals: { uncachedInput: number; cacheWrite: number; output: number; cacheRead: number }
14 requests: number
15 subagentFiles: number
16 }
17 | { ok: false; error: string }
18
19export const NODE_CANDIDATES: readonly string[] = [
20 'node',
21 '/usr/local/bin/node',
22 '/opt/homebrew/bin/node',
23 'C:\\Program Files\\nodejs\\node.exe',
24]
25
26export const RUN_TIMEOUT_MS = 20_000
27/** How long a "node was not found" stands before the next timer tick tries again. */
28export const NODE_RETRY_MS = 10 * 60_000
29/** subagents/<agent>.jsonl and subagents/workflows/<run>/<agent>.jsonl: walk this deep. */
30export const MAX_DEPTH = 4
31
32export function signatureOf(stats: readonly FileStat[]): string {
33 const bytes = stats.reduce((sum, stat) => sum + stat.size, 0)
34 const newest = stats.reduce((max, stat) => Math.max(max, Math.floor(stat.mtimeMs)), 0)
35 return `${stats.length}|${bytes}|${newest}`
36}
37
38export function separatorOf(path: string): string {
39 return path.includes('\\') ? '\\' : '/'
40}
41
42/** <dir>/<id>.jsonl → <dir>/<id>/subagents */
43export function subagentDirOf(mainPath: string): string {
44 return `${mainPath.slice(0, -'.jsonl'.length)}${separatorOf(mainPath)}subagents`
45}
46
47/** A rejection of $.process.run that means the executable never started, as opposed to a timeout. */
48export function isNotStarted(error: unknown): boolean {
49 const message = error instanceof Error ? error.message : String(error)
50 return !/timed? ?out|still running/i.test(message)
51}
52
53export function parseReply(stdout: string, exitCode: number, stderr: string): ScriptReply {
54 try {
55 const parsed: unknown = JSON.parse(stdout)
56 if (typeof parsed === 'object' && parsed !== null) {
57 const reply = parsed as { ok?: unknown; signature?: unknown; totals?: unknown; error?: unknown }
58 if (reply.ok === true && typeof reply.signature === 'string' && typeof reply.totals === 'object') {
59 return parsed as ScriptReply
60 }
61 if (reply.ok === false) return { ok: false, error: String(reply.error ?? 'unknown error') }
62 }
63 } catch {
64 // not JSON: reported below
65 }
66 return { ok: false, error: `tokens.mjs exited ${exitCode}: ${stderr.slice(0, 200)}` }
67}
68
69export function tokensOf(reply: Extract<ScriptReply, { ok: true }>): UsageBandTokens {
70 return {
71 ...reply.totals,
72 requests: reply.requests,
73 subagentFiles: reply.subagentFiles,
74 isEstimate: false,
75 }
76}
77
78types/index.d.ts 44 lines1/** One rate-limit window as the band draws it. */
2export type UsageBandWindow = {
3 /** 0 to 100, one decimal at most. */
4 percentUsed: number
5 /** ISO 8601 reset time; null when the engine did not report one. */
6 resetsAt: string | null
7}
8
9/** The live context window's fill. */
10export type UsageBandContext = {
11 tokens: number | null
12 window: number
13 percent: number | null
14}
15
16/** Token totals of the session, main transcript and subagents together. */
17export type UsageBandTokens = {
18 uncachedInput: number
19 cacheWrite: number
20 output: number
21 cacheRead: number
22 /** Distinct model requests counted (turns, when estimated). */
23 requests: number
24 /** Subagent transcripts that were summed in. */
25 subagentFiles: number
26 /** True when summed from turn.complete because the script could not run. */
27 isEstimate: boolean
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'usage-band': {
33 fiveHour: UsageBandWindow | null
34 sevenDay: UsageBandWindow | null
35 context: UsageBandContext | null
36 costUsd: number | null
37 tokens: UsageBandTokens | null
38 fallback: UsageBandTokens | null
39 isHidden: boolean
40 now: number
41 }
42 }
43}
44