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

Claude Code mod:在輸入框上方顯示一條用量列。功能參考 letswritetw/claude-mod-token-usage,程式在本 repo 重寫。
5h ━━━┃──── 42% ↻ 3h 05m │ ⇧ 12.3k ⇩ 678 ▤ 2.5M │ $1.23。不呼叫模型,也不讀檔案,只聽引擎事件:
| 資料 | 來源 |
|---|---|
| 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 跑的測試
claude plugin validate <repo>/mods/token-usage
claude plugin test <repo>/mods/token-usage
~/.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"
}
停用:從 CLAUDE_CODE_PLUGIN_DIRS 移除這個路徑,重開 session。
AbovePrompt)同時只適合放一個 mod 的畫面;session-board 已改到右側 Pane,兩者不衝突。Svg,但畫不出東西,所以以 e.surface === 'desktop' 判斷。hooks/register.tsx 36 lines1// 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}
36hooks/usage.ts 199 lines1// 純邏輯:額度視窗、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}
199hooks/view.tsx 36 lines1// 用量條畫面: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}
36hooks/strip.ts 234 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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(' ')
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
234types/index.d.ts 35 lines1/** 一個額度視窗(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