SLOPSHOPPER

usage-band

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

newbandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-04umithavare/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 ● usage-band: token count failed, showing estimates: tokens.mjs exited 0: ⏺ 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 › /kullanim ⎿ usage-band: 5h %31 · giriş ~6.4k · çıkış ~1.5k · önbellek ~91.0k · tur 1 · $0.42 · bağlam %49 ◔ 5h ███░░░░░░░ 31% ↑ ~6.4k ↓ ~1.5k ≋ ~91.0k $ 0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 5h ███░░░░░░░ 31% ↑ ~6.4k ↓ ~1.5k ≋ ~91.0k $ 0.42
README

claude-mods

Claude Code mods (function-hook plugins). Each folder is one mod.

usage-band

<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:

  • 5h and 7d limit pills: usage bar (green < 70%, yellow ≥ 70%, red ≥ 90%), a vertical line for how much of the window has passed, and the time left until reset.
  • Input (uncached + cache write), output and cache read token totals of the session, subagents and workflow agents included.
  • Session cost at API list prices.
  • Hover a pill for the breakdown, context fill and request count.

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 ~.

Install (once per machine)

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.

Develop

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.

Source 6 files
hooks/register.tsx 373 lines
1// 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}
373
hooks/format.ts 198 lines
1import 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}
198
hooks/pills.ts 231 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
231
hooks/terminal.tsx 102 lines
1// 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}
102
hooks/tokens.ts 78 lines
1// 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
78
types/index.d.ts 44 lines
1/** 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