SLOPSHOPPER

token-usage

輸入框上方的用量條:5h/7d 額度、本 session 的 token 加總與估算花費

newbandtimer
v0.1.0no licenseupdated 2026-10-09wangch15/agent-assets-kit/mods/token-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM 5h ━━────── 31% │ ⇧ 6.4k ⇩ 1.5k ▤ 91.0k │ $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ━━────── 31% │ ⇧ 6.4k ⇩ 1.5k ▤ 91.0k │ $0.42
README

token-usage

Claude Code mod:在輸入框上方顯示一條用量列。功能參考 letswritetw/claude-mod-token-usage,程式在本 repo 重寫。

  • 5h/7d 額度: 已用百分比、長條上的直線表示這個時段已經過了多少時間、距離重置的倒數。75% 以上變黃、90% 以上變紅。
  • Token: 本 session 的輸入(含寫入快取)、輸出、快取讀取加總。
  • 花費: 引擎依 API 牌價估算的本 session 花費(美元)。
  • 畫面:
  • Desktop 是一排有 icon 的圓角膠囊,滑過有 tooltip。
  • 終端是一行文字:5h ━━━┃──── 42% ↻ 3h 05m │ ⇧ 12.3k ⇩ 678 ▤ 2.5M │ $1.23。
  • 第一次回應之前只顯示「等第一次回應後顯示額度…」。5h/7d 額度只有訂閱帳號才有。

運作方式

不呼叫模型,也不讀檔案,只聽引擎事件:

資料來源
5h/7d 額度、花費session.measure 的 rateLimits(five_hour、seven_day)與 cost.usd
token 加總每輪 turn.complete 的 usage 累加(含 subagent 的輪次)
倒數重畫session.start 起每 5 分鐘 $.clock.every 觸發一次重畫

數值存在 session 的 $.state(key snapshot、tick),hot reload 後保留;新開的 session 從 0 開始。

檔案

mods/token-usage/
  .claude-plugin/plugin.json   # mod manifest
  hooks/hooks.json             # 指向 register.tsx
  hooks/register.tsx           # 事件 → state,AbovePrompt 畫用量列
  hooks/view.tsx               # Desktop/終端的畫面分流
  hooks/strip.ts               # Desktop 的 SVG 膠囊條(icon、長條、tooltip)
  hooks/usage.ts               # 純邏輯:讀數合併、token 加總、倒數、格式化
  types/index.d.ts             # $.state 型別契約
  tests/*.test.ts              # claude plugin test 跑的測試

安裝

  1. 驗證與測試:
   claude plugin validate <repo>/mods/token-usage
   claude plugin test <repo>/mods/token-usage
  1. 在 ~/.claude/settings.json 的 env.CLAUDE_CODE_PLUGIN_DIRS 加上這個資料夾,和其他 mod 用 : 串接:
   "env": {
     "CLAUDE_CODE_PLUGIN_DIRS": "~/GitHub/agent-assets-kit/mods/session-board:~/GitHub/agent-assets-kit/mods/token-usage"
   }
  1. 新開一個 session,輸入框上方會出現用量列。已經開著的 session 要重開才會載入。

停用:從 CLAUDE_CODE_PLUGIN_DIRS 移除這個路徑,重開 session。

注意

  • 輸入框上方(AbovePrompt)同時只適合放一個 mod 的畫面;session-board 已改到右側 Pane,兩者不衝突。
  • Desktop 才畫 SVG;終端的 element table 雖然也有 Svg,但畫不出東西,所以以 e.surface === 'desktop' 判斷。
  • Desktop 膠囊條的寬度是依字數估算的固定寬度,視窗很窄時可能被裁掉。
Source 5 files
hooks/register.tsx 36 lines
1// token-usage:在輸入框上方顯示 5h/7d 額度、本 session 的 token 加總與估算花費。
2// 額度與花費來自 session.measure,token 來自每輪的 turn.complete;不額外呼叫模型。
3import { atom, read, update } from 'claude-code'
4import type { Register } from 'claude-code'
5
6import { EMPTY_SNAPSHOT, REDRAW_MS, addTurnTokens, applyMeasure } from './usage'
7import { UsageBand } from './view'
8
9const snapshotAtom = atom({ plugin: 'token-usage', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
10const tickAtom = atom({ plugin: 'token-usage', key: 'tick' } as const, 0)
11
12export const register: Register = on => {
13  // 定時重畫,讓重置倒數與時間標記持續走動
14  on('session.start', async ($, e, next) => {
15    $.clock.every(REDRAW_MS, () => void update($, tickAtom, n => n + 1))
16    return next(e)
17  })
18
19  on('session.measure', async ($, e, next) => {
20    await update($, snapshotAtom, snapshot => applyMeasure(snapshot, e.rateLimits, e.cost?.usd))
21    return next(e)
22  })
23
24  on('turn.complete', async ($, e, next) => {
25    const usage = e.usage
26    if (usage) await update($, snapshotAtom, snapshot => addTurnTokens(snapshot, usage))
27    return next(e)
28  })
29
30  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
31    if (e.props.hasSurvey) return next(e)
32    await read($, tickAtom) // 只為了訂閱定時重畫
33    return UsageBand($.ui.resolve(e), await read($, snapshotAtom), await $.clock.now(), e.surface === 'desktop')
34  })
35}
36
hooks/usage.ts 199 lines
1// 純邏輯:額度視窗、token 加總、格式化。不碰 `$`,方便測試。
2import type { UsageSnapshot, UsageWindow } from '../types'
3
4const MINUTE_MS = 60_000
5const HOUR_MS = 60 * MINUTE_MS
6const DAY_MS = 24 * HOUR_MS
7const MINUTES_PER_HOUR = 60
8const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR
9
10/** 倒數與時間標記多久重畫一次(不呼叫模型) */
11export const REDRAW_MS = 5 * MINUTE_MS
12
13export type WindowKind = 'fiveHour' | 'sevenDay'
14
15/** 引擎的 rateLimits.kind → 本 mod 的視窗 */
16const RATE_LIMIT_KIND: Readonly<Record<WindowKind, string>> = {
17  fiveHour: 'five_hour',
18  sevenDay: 'seven_day',
19}
20
21/** 每個視窗的長度,用來算「這個時段過了多少」 */
22export const WINDOW_SPAN_MS: Readonly<Record<WindowKind, number>> = {
23  fiveHour: 5 * HOUR_MS,
24  sevenDay: 7 * DAY_MS,
25}
26
27/** 用量警示門檻(百分比) */
28export const LEVEL_THRESHOLD = { warning: 75, critical: 90 } as const
29export type UsageLevel = 'normal' | 'warning' | 'critical'
30
31export const EMPTY_SNAPSHOT: UsageSnapshot = {
32  fiveHour: null,
33  sevenDay: null,
34  costUsd: null,
35  tokens: { input: 0, output: 0, cacheRead: 0 },
36}
37
38type RateLimitReading = { readonly kind: string; readonly percentUsed: number; readonly resetsAt?: string }
39type TurnTokens = {
40  readonly input_tokens: number
41  readonly output_tokens: number
42  readonly cache_read_input_tokens: number
43  readonly cache_creation_input_tokens: number
44}
45
46function findWindow(readings: readonly RateLimitReading[], kind: WindowKind): UsageWindow | null {
47  const reading = readings.find(r => r.kind === RATE_LIMIT_KIND[kind])
48  if (!reading) return null
49  return reading.resetsAt
50    ? { percentUsed: reading.percentUsed, resetsAt: reading.resetsAt }
51    : { percentUsed: reading.percentUsed }
52}
53
54/** session.measure:有新讀數的視窗才更新,沒讀到的保留上次的值 */
55export function applyMeasure(
56  snapshot: UsageSnapshot,
57  readings: readonly RateLimitReading[],
58  costUsd: number | undefined,
59): UsageSnapshot {
60  return {
61    ...snapshot,
62    fiveHour: findWindow(readings, 'fiveHour') ?? snapshot.fiveHour,
63    sevenDay: findWindow(readings, 'sevenDay') ?? snapshot.sevenDay,
64    costUsd: costUsd ?? snapshot.costUsd,
65  }
66}
67
68/** turn.complete:累加這一輪的 token。輸入含寫入快取的部分,快取讀取另外算 */
69export function addTurnTokens(snapshot: UsageSnapshot, usage: TurnTokens): UsageSnapshot {
70  return {
71    ...snapshot,
72    tokens: {
73      input: snapshot.tokens.input + usage.input_tokens + usage.cache_creation_input_tokens,
74      output: snapshot.tokens.output + usage.output_tokens,
75      cacheRead: snapshot.tokens.cacheRead + usage.cache_read_input_tokens,
76    },
77  }
78}
79
80export function hasAnyData(snapshot: UsageSnapshot): boolean {
81  const { input, output } = snapshot.tokens
82  return snapshot.fiveHour !== null || snapshot.sevenDay !== null || snapshot.costUsd !== null || input + output > 0
83}
84
85export function usageLevel(percentUsed: number): UsageLevel {
86  if (percentUsed >= LEVEL_THRESHOLD.critical) return 'critical'
87  if (percentUsed >= LEVEL_THRESHOLD.warning) return 'warning'
88  return 'normal'
89}
90
91/** 0~1:已用比例,超過 100% 也只畫滿 */
92export function usedFraction(window: UsageWindow): number {
93  return Math.max(0, Math.min(1, window.percentUsed / 100))
94}
95
96/** 0~1:這個時段已經過了多少時間;不知道重置時間就回傳 null */
97export function elapsedFraction(window: UsageWindow, spanMs: number, now: number): number | null {
98  if (!window.resetsAt) return null
99  const remaining = Date.parse(window.resetsAt) - now
100  if (Number.isNaN(remaining)) return null
101  return Math.max(0, Math.min(1, 1 - remaining / spanMs))
102}
103
104export function formatPercent(window: UsageWindow): string {
105  return `${Math.round(window.percentUsed)}%`
106}
107
108const THOUSAND = 1_000
109const MILLION = 1_000_000
110
111/** 以四捨五入後的值判斷單位,避免 999,950 顯示成 `1000.0k` */
112const ROUNDS_UP_TO_NEXT_UNIT = 999.95
113
114export function formatTokens(count: number): string {
115  if (count / THOUSAND >= ROUNDS_UP_TO_NEXT_UNIT) return `${(count / MILLION).toFixed(1)}M`
116  if (count >= ROUNDS_UP_TO_NEXT_UNIT) return `${(count / THOUSAND).toFixed(1)}k`
117  return `${count}`
118}
119
120export function formatUsd(usd: number): string {
121  return `$${usd.toFixed(2)}`
122}
123
124type Countdown = { readonly days: number; readonly hours: number; readonly minutes: number }
125
126/** 重置時間已過、還沒有新讀數時顯示的字 */
127export const RESET_PASSED_SHORT = '已重置'
128const RESET_PASSED_LONG = '已重置,下一次回應後更新用量'
129
130function hasResetPassed(resetsAt: string | undefined, now: number): boolean {
131  return resetsAt !== undefined && Date.parse(resetsAt) <= now
132}
133
134function countdown(resetsAt: string | undefined, now: number): Countdown | null {
135  if (!resetsAt) return null
136  const ms = Date.parse(resetsAt) - now
137  if (Number.isNaN(ms)) return null
138  const total = Math.max(0, Math.round(ms / MINUTE_MS))
139  return {
140    days: Math.floor(total / MINUTES_PER_DAY),
141    hours: Math.floor((total % MINUTES_PER_DAY) / MINUTES_PER_HOUR),
142    minutes: total % MINUTES_PER_HOUR,
143  }
144}
145
146/** 膠囊上的短倒數:`2d 4h`、`3h 05m`;不知道重置時間就是空字串 */
147export function shortCountdown(resetsAt: string | undefined, now: number): string {
148  if (hasResetPassed(resetsAt, now)) return RESET_PASSED_SHORT
149  const left = countdown(resetsAt, now)
150  if (!left) return ''
151  if (left.days > 0) return `${left.days}d ${left.hours}h`
152  return `${left.hours}h ${String(left.minutes).padStart(2, '0')}m`
153}
154
155/** tooltip 用的完整說法 */
156export function longCountdown(resetsAt: string | undefined, now: number): string {
157  if (hasResetPassed(resetsAt, now)) return RESET_PASSED_LONG
158  const left = countdown(resetsAt, now)
159  if (!left) return '重置時間未知'
160  if (left.days > 0) return `${left.days} 天 ${left.hours} 小時後重置`
161  if (left.hours > 0) return `${left.hours} 小時 ${left.minutes} 分後重置`
162  return `${left.minutes} 分後重置`
163}
164
165const TEXT_BAR_CELLS = 8
166const TEXT_BAR = { filled: '━', empty: '─', marker: '┃' } as const
167
168/** 終端的文字長條:已用的部分加粗,直線是時段已經過的時間 */
169export function textBar(window: UsageWindow, spanMs: number, now: number): string {
170  const filled = Math.round(usedFraction(window) * TEXT_BAR_CELLS)
171  const elapsed = elapsedFraction(window, spanMs, now)
172  const marker = elapsed === null ? -1 : Math.min(TEXT_BAR_CELLS - 1, Math.floor(elapsed * TEXT_BAR_CELLS))
173  return Array.from({ length: TEXT_BAR_CELLS }, (_, i) => {
174    if (i === marker) return TEXT_BAR.marker
175    return i < filled ? TEXT_BAR.filled : TEXT_BAR.empty
176  }).join('')
177}
178
179const WINDOW_LABEL: Readonly<Record<WindowKind, string>> = { fiveHour: '5h', sevenDay: '7d' }
180
181function textWindow(kind: WindowKind, window: UsageWindow | null, now: number): string | null {
182  if (!window) return null
183  const reset = shortCountdown(window.resetsAt, now)
184  const bar = textBar(window, WINDOW_SPAN_MS[kind], now)
185  return `${WINDOW_LABEL[kind]} ${bar} ${formatPercent(window)}${reset ? ` ↻ ${reset}` : ''}`
186}
187
188/** 終端整行:5h │ 7d │ token │ 花費 */
189export function terminalLine(snapshot: UsageSnapshot, now: number): string {
190  const { input, output, cacheRead } = snapshot.tokens
191  const parts = [
192    textWindow('fiveHour', snapshot.fiveHour, now),
193    textWindow('sevenDay', snapshot.sevenDay, now),
194    `⇧ ${formatTokens(input)}  ⇩ ${formatTokens(output)}  ▤ ${formatTokens(cacheRead)}`,
195    snapshot.costUsd === null ? null : formatUsd(snapshot.costUsd),
196  ]
197  return parts.filter((part): part is string => part !== null).join('  │  ')
198}
199
hooks/view.tsx 36 lines
1// 用量條畫面:Desktop 畫一張 SVG 膠囊條,其他(終端等)畫一行文字。不碰 `$`。
2// 注意:終端的 element table 也有 Svg,但畫不出東西,所以要看 surface 而不是看 table。
3import type { ElementTable } from 'claude-code'
4
5import type { UsageSnapshot } from '../types'
6import { STRIP_HEIGHT, usageStrip } from './strip'
7import { hasAnyData, terminalLine } from './usage'
8
9export const WAITING_TEXT = 'token-usage:等第一次回應後顯示額度…'
10export const STRIP_ALT = '5h/7d 額度、token 與花費'
11
12export function UsageBand(els: ElementTable, snapshot: UsageSnapshot, now: number, isDesktop: boolean) {
13  const { Box, Text } = els
14  if (!hasAnyData(snapshot)) {
15    return (
16      <Box>
17        <Text dimColor>{WAITING_TEXT}</Text>
18      </Box>
19    )
20  }
21  if (isDesktop && 'Svg' in els) {
22    const { Svg } = els
23    const { svg, width } = usageStrip(snapshot, now)
24    return (
25      <Box key="usage-strip">
26        <Svg source={svg} alt={STRIP_ALT} width={width} height={STRIP_HEIGHT} isInteractive />
27      </Box>
28    )
29  }
30  return (
31    <Box key="usage-line">
32      <Text>{terminalLine(snapshot, now)}</Text>
33    </Box>
34  )
35}
36
hooks/strip.ts 234 lines
1// Desktop 的用量條:一張 SVG,裡面是一排圓角膠囊(額度視窗+token+花費),滑過有 tooltip。不碰 `$`。
2import type { UsageSnapshot, UsageWindow } from '../types'
3import type { UsageLevel, WindowKind } from './usage'
4import {
5  WINDOW_SPAN_MS,
6  elapsedFraction,
7  formatPercent,
8  formatTokens,
9  formatUsd,
10  longCountdown,
11  shortCountdown,
12  usageLevel,
13  usedFraction,
14} from './usage'
15
16/** 尺寸:等寬字 13px 的字寬約 7.9 */
17const SIZE = {
18  height: 28,
19  radius: 14,
20  fontSize: 13,
21  charWidth: 7.9,
22  boldExtra: 0.5,
23  paddingX: 11,
24  icon: 16,
25  iconGap: 5,
26  bar: 56,
27  barHeight: 5,
28  markerHeight: 11,
29  gap: 8,
30  groupGap: 18,
31  textBaseline: 18.3,
32} as const
33
34const FONT = 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace'
35
36/** 深色膠囊底:淺色與深色 app 主題上都讀得清楚 */
37const PALETTE = {
38  card: '#1B2336',
39  text: '#F8FAFC',
40  muted: '#94A3B8',
41  track: '#334155',
42  level: { warning: '#FBBF24', critical: '#EF4444' } satisfies Record<Exclude<UsageLevel, 'normal'>, string>,
43} as const
44
45/** 每顆膠囊的主色(外框/底色)與 icon 色 */
46type Tint = { readonly base: string; readonly icon: string }
47const TINT = {
48  fiveHour: { base: '#22C55E', icon: '#4ADE80' },
49  sevenDay: { base: '#8B5CF6', icon: '#A78BFA' },
50  input: { base: '#F43F5E', icon: '#FB7185' },
51  output: { base: '#10B981', icon: '#34D399' },
52  cacheRead: { base: '#6366F1', icon: '#818CF8' },
53  cost: { base: '#F59E0B', icon: '#FBBF24' },
54} as const satisfies Record<string, Tint>
55
56type IconName = 'gauge' | 'calendar' | 'clock' | 'input' | 'output' | 'cache' | 'coin'
57
58/** 16×16 線條 icon */
59const ICON_PATH: Readonly<Record<IconName, string>> = {
60  gauge: '<path d="M2.6 12.4a6 6 0 1 1 10.8 0"/><path d="M8 10.8 10.4 7.6"/><circle cx="8" cy="11" r=".6"/>',
61  calendar: '<rect x="2.5" y="3.5" width="11" height="10" rx="2"/><path d="M2.5 6.8h11M5.5 2v3M10.5 2v3"/>',
62  clock: '<circle cx="8" cy="8" r="5.5"/><path d="M8 5v3.2l2.1 1.3"/>',
63  input: '<path d="M8 10.5V2.8M5.2 5.4 8 2.6l2.8 2.8"/><path d="M3 10.5v2.5h10v-2.5"/>',
64  output: '<path d="M8 2.8v7.7M5.2 7.9 8 10.7l2.8-2.8"/><path d="M3 10.5v2.5h10v-2.5"/>',
65  cache: '<path d="M8 2.5 13.5 5.3 8 8.1 2.5 5.3z"/><path d="M2.5 8.2 8 11l5.5-2.8M2.5 11 8 13.8l5.5-2.8"/>',
66  coin: '<circle cx="8" cy="8" r="5.8"/><path d="M8 4.6v6.8M9.8 6.2c-.4-.6-1-.9-1.8-.9-1 0-1.8.5-1.8 1.3 0 1.8 3.6.8 3.6 2.6 0 .8-.8 1.3-1.8 1.3-.8 0-1.5-.3-1.9-.9"/>',
67}
68
69type Pill = { readonly svg: string; readonly width: number }
70
71function escapeXml(text: string): string {
72  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
73}
74
75function textWidth(text: string, isBold = false): number {
76  return text.length * (SIZE.charWidth + (isBold ? SIZE.boldExtra : 0))
77}
78
79function icon(name: IconName, x: number, color: string): string {
80  const y = (SIZE.height - SIZE.icon) / 2
81  return (
82    `<g transform="translate(${x} ${y})" fill="none" stroke="${color}" stroke-width="1.4" ` +
83    `stroke-linecap="round" stroke-linejoin="round">${ICON_PATH[name]}</g>`
84  )
85}
86
87function label(x: number, text: string, color: string, isBold = false): string {
88  const weight = isBold ? ' font-weight="700"' : ''
89  return (
90    `<text x="${x}" y="${SIZE.textBaseline}" font-family="${FONT}" font-size="${SIZE.fontSize}"${weight} ` +
91    `fill="${color}">${escapeXml(text)}</text>`
92  )
93}
94
95/** 膠囊外框:深色底+主色半透明疊層;<title> 是原生 tooltip(Svg 需 isInteractive) */
96function frame(x: number, width: number, tint: Tint, body: string, tooltip: string): string {
97  const tip = tooltip.split('\n').map(escapeXml).join('&#10;')
98  const { height, radius } = SIZE
99  return (
100    `<g class="pill"><title>${tip}</title>` +
101    `<rect x="${x}" y="0" width="${width}" height="${height}" rx="${radius}" fill="${PALETTE.card}"/>` +
102    `<rect class="hl" x="${x + 0.5}" y="0.5" width="${width - 1}" height="${height - 1}" rx="${radius - 0.5}" ` +
103    `fill="${tint.base}" fill-opacity=".14" stroke="${tint.base}" stroke-opacity=".38"/>${body}</g>`
104  )
105}
106
107function levelColor(percentUsed: number, fallback: string): string {
108  const level = usageLevel(percentUsed)
109  return level === 'normal' ? fallback : PALETTE.level[level]
110}
111
112/** 長條:底軌+已用的部分+時段已經過的時間標記 */
113function progressBar(x: number, window: UsageWindow, kind: WindowKind, color: string, now: number): string {
114  const top = (SIZE.height - SIZE.barHeight) / 2
115  const radius = SIZE.barHeight / 2
116  const filled = usedFraction(window) * SIZE.bar
117  const track = `<rect x="${x}" y="${top}" width="${SIZE.bar}" height="${SIZE.barHeight}" rx="${radius}" fill="${PALETTE.track}"/>`
118  const fill =
119    filled > 0
120      ? `<rect x="${x}" y="${top}" width="${Math.max(SIZE.barHeight, filled)}" height="${SIZE.barHeight}" rx="${radius}" fill="${color}"/>`
121      : ''
122  const elapsed = elapsedFraction(window, WINDOW_SPAN_MS[kind], now)
123  const markerTop = (SIZE.height - SIZE.markerHeight) / 2
124  const marker =
125    elapsed === null
126      ? ''
127      : `<rect x="${x + elapsed * (SIZE.bar - 2)}" y="${markerTop}" width="2" height="${SIZE.markerHeight}" rx="1" fill="${PALETTE.text}"/>`
128  return track + fill + marker
129}
130
131const WINDOW_META: Readonly<Record<WindowKind, { label: string; title: string; icon: IconName }>> = {
132  fiveHour: { label: '5h', title: '5 小時工作階段額度', icon: 'gauge' },
133  sevenDay: { label: '7d', title: '7 天每週額度', icon: 'calendar' },
134}
135
136/** 額度膠囊:icon 5h ▬▬▬ 42% │ ⏱ 3h 05m */
137function windowPill(x: number, kind: WindowKind, window: UsageWindow, now: number): Pill {
138  const meta = WINDOW_META[kind]
139  const tint = TINT[kind]
140  const percent = formatPercent(window)
141  const reset = shortCountdown(window.resetsAt, now)
142  let cursor = x + SIZE.paddingX
143  let body = icon(meta.icon, cursor, tint.icon)
144  cursor += SIZE.icon + SIZE.iconGap
145  body += label(cursor, meta.label, PALETTE.muted)
146  cursor += textWidth(meta.label) + SIZE.iconGap
147  body += progressBar(cursor, window, kind, levelColor(window.percentUsed, tint.icon), now)
148  cursor += SIZE.bar + SIZE.gap
149  body += label(cursor, percent, PALETTE.text, true)
150  cursor += textWidth(percent, true) + SIZE.gap
151  if (reset) {
152    body += `<rect x="${cursor}" y="8" width="1" height="12" fill="${PALETTE.muted}" opacity=".35"/>`
153    cursor += SIZE.gap
154    body += icon('clock', cursor, tint.icon)
155    cursor += SIZE.icon + SIZE.iconGap
156    body += label(cursor, reset, PALETTE.muted)
157    cursor += textWidth(reset)
158  }
159  const width = cursor + SIZE.paddingX - x
160  const tooltip = `${meta.title}:已用 ${percent},${longCountdown(window.resetsAt, now)}\n長條上的直線=這個時段已經過了多少時間`
161  return { svg: frame(x, width, tint, body, tooltip), width }
162}
163
164/** 數值膠囊:icon 12.3k */
165function valuePill(x: number, name: IconName, tint: Tint, value: string, tooltip: string): Pill {
166  const width = SIZE.paddingX * 2 + SIZE.icon + SIZE.iconGap + textWidth(value)
167  const body = icon(name, x + SIZE.paddingX, tint.icon) + label(x + SIZE.paddingX + SIZE.icon + SIZE.iconGap, value, PALETTE.text)
168  return { svg: frame(x, width, tint, body, tooltip), width }
169}
170
171type PillFactory = { readonly make: (x: number) => Pill; readonly gapAfter: number }
172
173function windowFactories(snapshot: UsageSnapshot, now: number): PillFactory[] {
174  const present = (['fiveHour', 'sevenDay'] as const).flatMap(kind => {
175    const window = snapshot[kind]
176    return window ? [{ kind, window }] : []
177  })
178  // 最後一個額度膠囊後面留大一點的間距,和 token 群組分開
179  return present.map(({ kind, window }, i) => ({
180    make: (x: number) => windowPill(x, kind, window, now),
181    gapAfter: i === present.length - 1 ? SIZE.groupGap : SIZE.gap,
182  }))
183}
184
185function tokenFactories(snapshot: UsageSnapshot): PillFactory[] {
186  const input = formatTokens(snapshot.tokens.input)
187  const output = formatTokens(snapshot.tokens.output)
188  const cacheRead = formatTokens(snapshot.tokens.cacheRead)
189  return [
190    {
191      make: x => valuePill(x, 'input', TINT.input, input, `輸入 token:本工作階段送給模型的內容共 ${input}`),
192      gapAfter: SIZE.gap,
193    },
194    {
195      make: x => valuePill(x, 'output', TINT.output, output, `輸出 token:模型在本工作階段產生的內容共 ${output}`),
196      gapAfter: SIZE.gap,
197    },
198    {
199      make: x => valuePill(x, 'cache', TINT.cacheRead, cacheRead, `快取讀取 token:從提示快取重複使用 ${cacheRead},計費比一般輸入低`),
200      gapAfter: SIZE.groupGap,
201    },
202  ]
203}
204
205function costFactories(costUsd: number | null): PillFactory[] {
206  if (costUsd === null) return []
207  const cost = formatUsd(costUsd)
208  return [{ make: x => valuePill(x, 'coin', TINT.cost, cost, `工作階段費用:依 API 牌價估算約 ${cost} 美元`), gapAfter: 0 }]
209}
210
211/** 由左到右排好每顆膠囊,回傳整張 SVG 與寬度 */
212export function usageStrip(snapshot: UsageSnapshot, now: number): { readonly svg: string; readonly width: number } {
213  const factories = [...windowFactories(snapshot, now), ...tokenFactories(snapshot), ...costFactories(snapshot.costUsd)]
214  const { pills, end } = factories.reduce<{ pills: readonly Pill[]; end: number; next: number }>(
215    (acc, factory) => {
216      const pill = factory.make(acc.next)
217      return { pills: [...acc.pills, pill], end: acc.next + pill.width, next: acc.next + pill.width + factory.gapAfter }
218    },
219    { pills: [], end: 0, next: 0 },
220  )
221  const width = Math.ceil(end)
222  const style =
223    ':root{color-scheme:light dark;background:transparent}.pill{cursor:help}' +
224    '.pill .hl{transition:fill-opacity .12s}.pill:hover .hl{fill-opacity:.26}'
225  return {
226    width,
227    svg:
228      `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${SIZE.height}" viewBox="0 0 ${width} ${SIZE.height}">` +
229      `<style>${style}</style>${pills.map(p => p.svg).join('')}</svg>`,
230  }
231}
232
233export const STRIP_HEIGHT = SIZE.height
234
types/index.d.ts 35 lines
1/** 一個額度視窗(5 小時或 7 天)的最新讀數 */
2export type UsageWindow = {
3  /** 0~100,可能有一位小數 */
4  percentUsed: number
5  /** 重置時間,ISO 8601 */
6  resetsAt?: string
7}
8
9/** 本 session 的 token 加總 */
10export type TokenTotals = {
11  /** 未快取的輸入+寫入快取的輸入 */
12  input: number
13  output: number
14  /** 從提示快取讀取的輸入 */
15  cacheRead: number
16}
17
18export type UsageSnapshot = {
19  fiveHour: UsageWindow | null
20  sevenDay: UsageWindow | null
21  /** 引擎估算的花費(美元);沒有帳本時是 null */
22  costUsd: number | null
23  tokens: TokenTotals
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'token-usage': {
29      snapshot: UsageSnapshot
30      /** 每隔一段時間 +1,讓倒數與時間標記重畫 */
31      tick: number
32    }
33  }
34}
35