Shows Claude context, 5-hour and 7-day usage as gradient progress bars above the prompt (Claude Code Desktop / VS Code / mobile). 12 languages.

English · 简体中文 · 繁體中文 · 日本語 · हिन्दी · Español · العربية · Français · বাংলা · Português · Русский · Bahasa Indonesia
A Claude Code Mod that shows your Claude usage as three gradient progress bars right above the prompt in Claude Code Desktop (also VS Code and the mobile app):


Copy this prompt and paste it into Claude Code (Desktop, CLI or VS Code). Claude clones the mod, updates your settings and checks them:
Install the claude-mod-usage Claude Code mod: clone https://github.com/jack21/claude-mod-usage into ~/.claude/mods/claude-mod-usage (if the folder already exists, run git pull there instead). Then add that folder to CLAUDE_CODE_PLUGIN_DIRS in the "env" block of ~/.claude/settings.json, keeping any folders already listed (join them with ":" on macOS/Linux, ";" on Windows) and changing no other setting. Make sure settings.json is still valid JSON, run `claude plugin validate ~/.claude/mods/claude-mod-usage`, and tell me to start a new session.
git clone https://github.com/jack21/claude-mod-usage ~/.claude/mods/claude-mod-usage
env block of ~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/claude-mod-usage"
}
}
Several folders are separated by : (macOS / Linux) or ; (Windows).
For a one-off try in the CLI: claude --plugin-dir ~/.claude/mods/claude-mod-usage.
Optional: add "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1" to the same env block so Desktop reloads the mod when you edit its files.
The display language is chosen in this order:
auto by default)language setting (e.g. "japanese", "繁體中文")LC_ALL, LC_MESSAGES, LANGLANG)To pin a language without the menu, add this to ~/.claude/settings.json:
{
"pluginConfigs": {
"mod-usage": { "language": "ja" }
}
}
Supported codes: en, zh-CN, zh-TW, ja, hi, es, ar, fr, bn, pt, ru, id.
session.measure pushes the context and rate-limit figures; session.start reads the current ones with $.session.usage().AbovePrompt slot with Svg elements. A plain-image SVG is stretched horizontally to its box, so the bar is a round-capped line with vector-effect="non-scaling-stroke", which keeps the ends circular at any width. Text (percent, countdown) is a separate fixed-size SVG so it is never distorted.isInteractive SVG frames on a white background, so everything here is drawn as plain images.claude plugin validate . # checks the manifest and what the module hooks and calls
claude plugin test . # runs tests/*.test.tsx against the engine
hooks/register.tsx is the hooks module; hooks/i18n.ts holds the strings and locale detection helpers; types/index.d.ts is the state contract.
To add a language: add its code to types/index.d.ts and .claude-plugin/plugin.json, its strings to MESSAGES and SUPPORTED_LOCALES in hooks/i18n.ts, and a name hint to LANGUAGE_NAME_HINTS.
MIT © 2026 Jack Chiang
hooks/register.tsx 317 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { Locale, QuotaUsage, QuotaWindow } from '../types'
5import { DEFAULT_LOCALE, MESSAGES, localeFromLanguageName, normalizeLocale } from './i18n'
6
7// claude-mod-usage: Claude context / 5-hour / 7-day usage as gradient progress bars above the prompt
8
9// Bar keys: React key, SVG id prefix and icon table key
10const SegmentKey = {
11 CONTEXT: 'context',
12 FIVE: 'five',
13 WEEK: 'week'
14} as const
15type TSegmentKey = (typeof SegmentKey)[keyof typeof SegmentKey]
16
17// What one bar draws
18interface ISegmentInput {
19 key: TSegmentKey // bar key
20 name: string // screen-reader name, localized
21 percent: number // percent used, 0-100
22 resetsAt?: number // reset time (epoch ms); the context bar has none
23}
24
25// Layout
26const SEGMENT_GAP = 2 // columns between bars
27const MIN_BAR_LEN = 6 // a bar never gets narrower than this many columns (fits "100%")
28const CELL_PX_ESTIMATE = 7 // rough px per column (the API has no real value); used to inset round caps and size text slots
29
30// Bar
31const BAR_HEIGHT = 8 // bar thickness (CSS px)
32const EMPTY_COLOR = '#3c3c3c' // unfilled track
33
34// Percent label on the bar: white text with a dark outline, readable on green, yellow, red and the dark track
35const LABEL_FONT_SIZE = 12
36const LABEL_HEIGHT = 16
37const LABEL_CHAR_PX = 8 // bold digit width, estimated on the generous side so nothing is clipped
38const LABEL_FILL_COLOR = '#ffffff'
39const LABEL_STROKE_COLOR = '#1e1e1e'
40
41// Reset countdown to the right of the bar: monospace digits
42const TIME_FONT_SIZE = 12
43const TIME_HEIGHT = 16
44const TIME_ASCII_PX = 7.4 // monospace Latin glyph (≈0.6em)
45const TIME_WIDE_PX = 12.5 // CJK glyph (≈1em), falls back to the system CJK font
46const TIME_OTHER_PX = 9 // other scripts (Devanagari, Bengali, Arabic, Cyrillic) in their fallback font
47const TIME_COLOR = '#c8c8c8'
48const TIME_FONT = `font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-weight="500"`
49
50// Icons: two-tone line icons, grey outline + an accent colour that follows usage
51const ICON_SIZE = 18 // CSS px
52const ICON_CELLS = 3 // columns an icon takes (18px ≈ 2.6 columns)
53const ICON_LINE_COLOR = '#8a8a8a' // mid grey, visible on dark and light themes
54const ICON_FONT = `font-family="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif" font-weight="700"`
55
56// Session state: latest usage and the resolved display language
57const usage = atom({ plugin: 'mod-usage', key: 'usage' } as const, null)
58const locale = atom({ plugin: 'mod-usage', key: 'locale' } as const, DEFAULT_LOCALE)
59
60// Picks one rate-limit window and converts resetsAt to epoch ms
61const _pickWindow = (rateLimits: SessionRateLimit[], kind: string): QuotaWindow | undefined => {
62 const found = rateLimits.find((limit) => limit.kind === kind)
63 if (!found) return undefined
64
65 const resetsAt = found.resetsAt ? Date.parse(found.resetsAt) : NaN
66 return Number.isNaN(resetsAt) ? { percent: found.percentUsed } : { percent: found.percentUsed, resetsAt }
67}
68
69// The fields session.measure and $.session.usage() share, as this mod's QuotaUsage
70const _toQuotaUsage = (context: SessionContextUsage, rateLimits: SessionRateLimit[]): QuotaUsage => ({
71 contextPercent: context.percent,
72 fiveHour: _pickWindow(rateLimits, 'five_hour'),
73 sevenDay: _pickWindow(rateLimits, 'seven_day')
74})
75
76// Time left until reset, in the display language's format; empty once it has passed
77const _formatRemaining = (resetsAt: number | undefined, nowMs: number, messages: (typeof MESSAGES)[keyof typeof MESSAGES]): string => {
78 if (!resetsAt) return ''
79
80 const diff = Math.floor((resetsAt - nowMs) / 1000)
81 if (diff <= 0) return ''
82
83 const days = Math.floor(diff / 86400)
84 const hours = Math.floor((diff % 86400) / 3600)
85 const minutes = Math.floor((diff % 3600) / 60)
86 if (days > 0) return messages.daysHours(days, hours)
87 if (hours > 0) return messages.hoursMinutes(hours, minutes)
88 return messages.minutes(minutes)
89}
90
91// Gradient colour at a usage level: green → yellow → red (same stops as the bar)
92const _accentColor = (percent: number): string => {
93 const pos = Math.min(100, Math.max(0, percent))
94 const red = pos <= 50 ? Math.round((pos * 255) / 50) : 255
95 const green = pos <= 50 ? 200 : Math.round(200 - ((pos - 50) * 200) / 50)
96 const toHex = (value: number) => value.toString(16).padStart(2, '0')
97 return `#${toHex(red)}${toHex(green)}00`
98}
99
100// Escapes text placed inside SVG markup
101const _escapeXml = (text: string): string => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
102
103// Shared 18×18 shell of the two-tone icons
104const _iconSvg = (inner: string): string =>
105 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="${ICON_SIZE}" height="${ICON_SIZE}" fill="none" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
106
107// Icons: context = speech bubble with three dots, 5-hour = stopwatch "5", 7-day = calendar "7"
108const _iconSources: Record<TSegmentKey, (accent: string) => string> = {
109 context: (accent) =>
110 _iconSvg(
111 `<path d="M4.5 3h9A2.5 2.5 0 0 1 16 5.5v5a2.5 2.5 0 0 1-2.5 2.5H8.6L5.2 15.6V13h-.7A2.5 2.5 0 0 1 2 10.5v-5A2.5 2.5 0 0 1 4.5 3z" stroke="${ICON_LINE_COLOR}" stroke-width="1.5"/>` +
112 `<circle cx="5.8" cy="8" r="1.25" fill="${accent}"/><circle cx="9" cy="8" r="1.25" fill="${accent}"/><circle cx="12.2" cy="8" r="1.25" fill="${accent}"/>`
113 ),
114 five: (accent) =>
115 _iconSvg(
116 `<circle cx="9" cy="10.4" r="6.3" stroke="${ICON_LINE_COLOR}" stroke-width="1.5"/>` +
117 `<path d="M9 4.1V2.2M7.3 1.6h3.4" stroke="${ICON_LINE_COLOR}" stroke-width="1.5"/>` +
118 `<text x="9" y="10.6" text-anchor="middle" dominant-baseline="central" font-size="9" ${ICON_FONT} fill="${accent}">5</text>`
119 ),
120 week: (accent) =>
121 _iconSvg(
122 `<rect x="2.25" y="3.5" width="13.5" height="12.75" rx="2.5" stroke="${ICON_LINE_COLOR}" stroke-width="1.5"/>` +
123 `<path d="M6 1.75V5M12 1.75V5M2.25 7.25h13.5" stroke="${ICON_LINE_COLOR}" stroke-width="1.5"/>` +
124 `<text x="9" y="11.6" text-anchor="middle" dominant-baseline="central" font-size="8.5" ${ICON_FONT} fill="${accent}">7</text>`
125 )
126}
127
128// Percent label: outlined SVG text (Text has no outline); a fixed width/height keeps it from being stretched
129const _labelSvg = (label: string): { source: string; width: number } => {
130 const width = Math.ceil(label.length * LABEL_CHAR_PX + 6)
131 const source = [
132 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${LABEL_HEIGHT}" width="${width}" height="${LABEL_HEIGHT}">`,
133 `<text x="${width / 2}" y="${LABEL_HEIGHT / 2}" text-anchor="middle" dominant-baseline="central" font-size="${LABEL_FONT_SIZE}" ${ICON_FONT}`,
134 ` fill="${LABEL_FILL_COLOR}" stroke="${LABEL_STROKE_COLOR}" stroke-width="3" stroke-linejoin="round" paint-order="stroke">${_escapeXml(label)}</text>`,
135 '</svg>'
136 ].join('')
137 return { source, width }
138}
139
140// Glyph width of the countdown text, per script
141const _timeGlyphPx = (char: string): number => {
142 const code = char.codePointAt(0) ?? 0
143 if (code < 0x80) return TIME_ASCII_PX
144 return code >= 0x2e80 ? TIME_WIDE_PX : TIME_OTHER_PX
145}
146
147// Reset countdown: monospace SVG text (Text cannot choose a font), fixed size so it is not stretched.
148// Right-to-left scripts are laid out RTL from the right edge, otherwise digits and units swap places
149const _timeSvg = (text: string, rtl = false): { source: string; width: number; cells: number } => {
150 const textPx = [...text].reduce((sum, char) => sum + _timeGlyphPx(char), 0)
151 const width = Math.ceil(textPx + 2)
152 const source = [
153 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${TIME_HEIGHT}" width="${width}" height="${TIME_HEIGHT}">`,
154 rtl
155 ? `<text x="${width - 1}" y="${TIME_HEIGHT / 2}" direction="rtl" unicode-bidi="embed" dominant-baseline="central" font-size="${TIME_FONT_SIZE}" ${TIME_FONT} fill="${TIME_COLOR}">${_escapeXml(text)}</text>`
156 : `<text x="1" y="${TIME_HEIGHT / 2}" dominant-baseline="central" font-size="${TIME_FONT_SIZE}" ${TIME_FONT} fill="${TIME_COLOR}">${_escapeXml(text)}</text>`,
157 '</svg>'
158 ].join('')
159 return { source, width, cells: Math.ceil(width / CELL_PX_ESTIMATE) }
160}
161
162// The bar: dark pill track + green → yellow → red gradient, filled to the usage level.
163// Drawn as a plain image (an isInteractive frame paints a white background on Desktop, showing past the round ends).
164// The image is stretched to the column width, so the bar is a round-capped line with non-scaling-stroke:
165// stroke width and caps are computed in screen space and stay circular. Caps reach half a stroke past the
166// endpoints, so the endpoints are inset by an estimated px width to keep the caps inside the image.
167const _barSvg = (key: TSegmentKey, percent: number, barCells: number): string => {
168 const filled = Math.min(100, Math.max(0, percent))
169 const inset = (BAR_HEIGHT / 2 / (barCells * CELL_PX_ESTIMATE)) * 1000
170 const trackEnd = 1000 - inset
171 const fillEnd = inset + ((trackEnd - inset) * filled) / 100
172 const centerY = BAR_HEIGHT / 2
173 const lineAttrs = `stroke-width="${BAR_HEIGHT}" stroke-linecap="round" vector-effect="non-scaling-stroke"`
174 return [
175 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 ${BAR_HEIGHT}" preserveAspectRatio="none" width="4000" height="${BAR_HEIGHT}">`,
176 '<defs>',
177 `<linearGradient id="grad-${key}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1000" y2="0">`,
178 '<stop offset="0" stop-color="#00c800"/><stop offset="0.5" stop-color="#ffc800"/><stop offset="1" stop-color="#ff0000"/>',
179 '</linearGradient>',
180 '</defs>',
181 `<line x1="${inset.toFixed(2)}" y1="${centerY}" x2="${trackEnd.toFixed(2)}" y2="${centerY}" stroke="${EMPTY_COLOR}" ${lineAttrs}/>`,
182 `<line x1="${inset.toFixed(2)}" y1="${centerY}" x2="${fillEnd.toFixed(2)}" y2="${centerY}" stroke="url(#grad-${key})" ${lineAttrs}/>`,
183 '</svg>'
184 ].join('')
185}
186
187// Resolves the display language: user option → Claude Code setting → LC_ALL / LC_MESSAGES / LANG → macOS → English
188const _resolveLocale = async ($: EngineInterface, option: string | undefined): Promise<Locale> => {
189 // 1. explicit choice in the plugin's config menu
190 const chosen = option && option !== 'auto' ? normalizeLocale(option) : null
191 if (chosen) return chosen
192
193 // 2. Claude Code's own `language` setting
194 const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
195 const settingName = typeof settings.language === 'string' ? settings.language : undefined
196 const fromSetting = localeFromLanguageName(settingName)
197 if (fromSetting) return fromSetting
198
199 // 3. POSIX locale variables, most specific first
200 const envCandidates = [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')]
201 const fromEnv = envCandidates.map(normalizeLocale).find((locale) => locale !== null)
202 if (fromEnv) return fromEnv
203
204 // 4. macOS system language (apps launched from Finder usually have no LANG)
205 const appleLanguages = await $.process
206 .run(['/usr/bin/defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 5_000 })
207 .catch(() => null)
208 const firstAppleLanguage = appleLanguages?.exitCode === 0 ? /"?([A-Za-z]{2,3}(?:-[A-Za-z0-9]+)*)"?/.exec(appleLanguages.stdout)?.[1] : undefined
209 const fromMac = normalizeLocale(firstAppleLanguage)
210 if (fromMac) return fromMac
211
212 return DEFAULT_LOCALE
213}
214
215export const register: Register = (on, options) => {
216 const languageOption = typeof options.language === 'string' ? options.language : 'auto'
217
218 // On start (and every hot reload): resolve the display language, read the current figures,
219 // and redraw once a minute so the reset countdown moves
220 on('session.start', async ($, e, next) => {
221 const resolvedLocale = await _resolveLocale($, languageOption)
222 await update($, locale, () => resolvedLocale)
223
224 const initial = await $.session.usage()
225 await update($, usage, () => _toQuotaUsage(initial.context, initial.rateLimits))
226
227 $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
228
229 return next(e)
230 })
231
232 // The engine pushes new figures after each turn and whenever a limit moves a whole point
233 on('session.measure', async ($, e, next) => {
234 await update($, usage, () => _toQuotaUsage(e.context, e.rateLimits))
235 return next(e)
236 })
237
238 // Draws the bars above the prompt. The terminal already has a status line, so it is left to the engine there
239 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
240 if (e.surface === 'terminal' || e.props.hasSurvey) return next(e)
241
242 const current = await read($, usage)
243 if (!current) return next(e)
244
245 const messages = MESSAGES[await read($, locale)] ?? MESSAGES[DEFAULT_LOCALE]
246 const nowMs = await $.clock.now()
247 const { Box, Svg } = $.ui.resolve(e)
248
249 // Bars that have data
250 const segmentCandidates: (ISegmentInput | null)[] = [
251 current.contextPercent === undefined ? null : { key: SegmentKey.CONTEXT, name: messages.context, percent: current.contextPercent },
252 current.fiveHour ? { key: SegmentKey.FIVE, name: messages.fiveHour, percent: current.fiveHour.percent, resetsAt: current.fiveHour.resetsAt } : null,
253 current.sevenDay ? { key: SegmentKey.WEEK, name: messages.sevenDay, percent: current.sevenDay.percent, resetsAt: current.sevenDay.resetsAt } : null
254 ]
255 const segmentInputs = segmentCandidates.filter((segment) => segment !== null)
256 if (segmentInputs.length === 0) return next(e)
257
258 // Fixed width of each bar's block = icon + gap (+ gap + countdown when there is one); blocks differ in width
259 const remainings = segmentInputs.map((segment) => _formatRemaining(segment.resetsAt, nowMs, messages))
260 const timeSvgs = remainings.map((remaining) => (remaining ? _timeSvg(remaining, messages.rtl) : null))
261 const fixedWidths = timeSvgs.map((timeSvg) => ICON_CELLS + 1 + (timeSvg ? 1 + timeSvg.cells : 0))
262
263 // Equal-length bars filling the row: what is left after gaps and fixed widths is split evenly between the bars;
264 // every block also grows by the same flexGrow, absorbing the remainder and real glyph widths, so bars stay equal
265 const totalGap = SEGMENT_GAP * (segmentInputs.length - 1)
266 const totalFixed = fixedWidths.reduce((sum, width) => sum + width, 0)
267 const barLen = Math.max(MIN_BAR_LEN, Math.floor((e.props.bodyColumns - totalGap - totalFixed) / segmentInputs.length))
268
269 // One block: icon (accent follows usage) + bar (outlined percent centred on it) + reset countdown
270 const renderSegment = ({ key, name, percent }: ISegmentInput, index: number) => {
271 const percentLabel = `${Math.round(percent)}%`
272 const remaining = remainings[index] ?? ''
273 const timeSvg = timeSvgs[index] ?? null
274 const segmentWidth = (fixedWidths[index] ?? 0) + barLen
275 const alt = remaining ? `${name} ${percentLabel} (${remaining})` : `${name} ${percentLabel}`
276 const percentSvg = _labelSvg(percentLabel)
277
278 return (
279 <Box key={key} flexDirection="row" alignItems="center" gap={1} width={segmentWidth} flexGrow={1} flexShrink={1}>
280 <Box width={ICON_CELLS} flexShrink={0} justifyContent="center">
281 <Svg source={_iconSources[key](_accentColor(percent))} alt={name} width={ICON_SIZE} height={ICON_SIZE} />
282 </Box>
283 <Box position="relative" flexDirection="column" justifyContent="center" width={barLen} flexGrow={1} flexShrink={1}>
284 <Svg source={_barSvg(key, percent, barLen)} alt={alt} height={BAR_HEIGHT} />
285 <Box position="absolute" top={0} bottom={0} left={0} right={0} justifyContent="center" alignItems="center">
286 <Svg source={percentSvg.source} alt={percentLabel} width={percentSvg.width} height={LABEL_HEIGHT} />
287 </Box>
288 </Box>
289 {timeSvg ? (
290 <Box width={timeSvg.cells} flexShrink={0} justifyContent="center">
291 <Svg source={timeSvg.source} alt={remaining} width={timeSvg.width} height={TIME_HEIGHT} />
292 </Box>
293 ) : null}
294 </Box>
295 )
296 }
297
298 // One row, no wrapping
299 const segments = segmentInputs.map(renderSegment)
300 const barsRow = (
301 <Box flexDirection="row" flexWrap="nowrap" columnGap={SEGMENT_GAP} width={e.props.bodyColumns}>
302 {segments}
303 </Box>
304 )
305
306 // Play well with other band mods: whatever the mods beneath draw is stacked under the bars instead of replaced
307 const below = await next(e)
308 if (below.type === 'engine') return barsRow
309 return (
310 <Box flexDirection="column">
311 {barsRow}
312 {below}
313 </Box>
314 )
315 })
316}
317hooks/i18n.ts 159 lines1import type { Locale } from '../types'
2
3// Per-language strings: bar names (screen-reader alt text) and the reset countdown format
4interface IMessages {
5 context: string // context window bar
6 fiveHour: string // 5-hour limit bar
7 sevenDay: string // 7-day limit bar
8 daysHours: (days: number, hours: number) => string // countdown over a day
9 hoursMinutes: (hours: number, minutes: number) => string // countdown under a day
10 minutes: (minutes: number) => string // countdown under an hour
11 rtl?: true // right-to-left script (countdown is laid out RTL so digits and units keep their order)
12}
13
14export const SUPPORTED_LOCALES: readonly Locale[] = ['en', 'zh-CN', 'zh-TW', 'ja', 'hi', 'es', 'ar', 'fr', 'bn', 'pt', 'ru', 'id']
15export const DEFAULT_LOCALE: Locale = 'en'
16
17export const MESSAGES: Record<Locale, IMessages> = {
18 en: {
19 context: 'Context',
20 fiveHour: '5-hour limit',
21 sevenDay: '7-day limit',
22 daysHours: (d, h) => `${d}d${h}h`,
23 hoursMinutes: (h, m) => `${h}h${m}m`,
24 minutes: (m) => `${m}m`
25 },
26 'zh-CN': {
27 context: '上下文',
28 fiveHour: '5 小时额度',
29 sevenDay: '7 天额度',
30 daysHours: (d, h) => `${d}天${h}时`,
31 hoursMinutes: (h, m) => `${h}时${m}分`,
32 minutes: (m) => `${m}分`
33 },
34 'zh-TW': {
35 context: '上下文',
36 fiveHour: '5 小時額度',
37 sevenDay: '7 天額度',
38 daysHours: (d, h) => `${d}天${h}時`,
39 hoursMinutes: (h, m) => `${h}時${m}分`,
40 minutes: (m) => `${m}分`
41 },
42 ja: {
43 context: 'コンテキスト',
44 fiveHour: '5時間の上限',
45 sevenDay: '7日間の上限',
46 daysHours: (d, h) => `${d}日${h}時間`,
47 hoursMinutes: (h, m) => `${h}時間${m}分`,
48 minutes: (m) => `${m}分`
49 },
50 hi: {
51 context: 'कॉन्टेक्स्ट',
52 fiveHour: '5 घंटे की सीमा',
53 sevenDay: '7 दिन की सीमा',
54 daysHours: (d, h) => `${d}दि ${h}घं`,
55 hoursMinutes: (h, m) => `${h}घं ${m}मि`,
56 minutes: (m) => `${m}मि`
57 },
58 es: {
59 context: 'Contexto',
60 fiveHour: 'Límite de 5 horas',
61 sevenDay: 'Límite de 7 días',
62 daysHours: (d, h) => `${d}d${h}h`,
63 hoursMinutes: (h, m) => `${h}h${m}min`,
64 minutes: (m) => `${m}min`
65 },
66 ar: {
67 context: 'السياق',
68 fiveHour: 'حد 5 ساعات',
69 sevenDay: 'حد 7 أيام',
70 daysHours: (d, h) => `${d}ي ${h}س`,
71 hoursMinutes: (h, m) => `${h}س ${m}د`,
72 minutes: (m) => `${m}د`,
73 rtl: true
74 },
75 fr: {
76 context: 'Contexte',
77 fiveHour: 'Limite de 5 heures',
78 sevenDay: 'Limite de 7 jours',
79 daysHours: (d, h) => `${d}j${h}h`,
80 hoursMinutes: (h, m) => `${h}h${m}min`,
81 minutes: (m) => `${m}min`
82 },
83 bn: {
84 context: 'কনটেক্সট',
85 fiveHour: '৫ ঘণ্টার সীমা',
86 sevenDay: '৭ দিনের সীমা',
87 daysHours: (d, h) => `${d}দি ${h}ঘ`,
88 hoursMinutes: (h, m) => `${h}ঘ ${m}মি`,
89 minutes: (m) => `${m}মি`
90 },
91 pt: {
92 context: 'Contexto',
93 fiveHour: 'Limite de 5 horas',
94 sevenDay: 'Limite de 7 dias',
95 daysHours: (d, h) => `${d}d${h}h`,
96 hoursMinutes: (h, m) => `${h}h${m}min`,
97 minutes: (m) => `${m}min`
98 },
99 ru: {
100 context: 'Контекст',
101 fiveHour: 'Лимит 5 часов',
102 sevenDay: 'Лимит 7 дней',
103 daysHours: (d, h) => `${d}д ${h}ч`,
104 hoursMinutes: (h, m) => `${h}ч ${m}м`,
105 minutes: (m) => `${m}м`
106 },
107 id: {
108 context: 'Konteks',
109 fiveHour: 'Batas 5 jam',
110 sevenDay: 'Batas 7 hari',
111 daysHours: (d, h) => `${d}h ${h}j`,
112 hoursMinutes: (h, m) => `${h}j ${m}m`,
113 minutes: (m) => `${m}m`
114 }
115}
116
117// Free-text language names as Claude Code's `language` setting may hold them ("japanese", "繁體中文", ...)
118const LANGUAGE_NAME_HINTS: readonly [RegExp, Locale][] = [
119 [/繁體|繁体|traditional|台灣|臺灣|taiwan|香港|hong ?kong/i, 'zh-TW'],
120 [/简体|簡體|simplified|中文|chinese|mandarin|普通话/i, 'zh-CN'],
121 [/日本|japanese|nihongo/i, 'ja'],
122 [/hindi|हिन्दी|हिंदी/i, 'hi'],
123 [/spanish|español|espanol|castellano/i, 'es'],
124 [/arabic|العربية|عربي/i, 'ar'],
125 [/french|français|francais/i, 'fr'],
126 [/bengali|bangla|বাংলা/i, 'bn'],
127 [/portuguese|português|portugues/i, 'pt'],
128 [/russian|русский/i, 'ru'],
129 [/indonesian|bahasa indonesia|indonesia/i, 'id'],
130 [/english/i, 'en']
131]
132
133// Normalizes a locale tag (zh_TW.UTF-8, zh-Hant-HK, ja-JP, pt_BR, in_ID ...) to a supported locale
134export const normalizeLocale = (raw: string | undefined): Locale | null => {
135 if (!raw) return null
136
137 const tag = raw.trim().toLowerCase().replace(/_/g, '-').split('.')[0] ?? ''
138 if (!tag || tag === 'c' || tag === 'posix') return null
139
140 // Chinese: Traditional for Hant / TW / HK / MO, otherwise Simplified
141 if (tag.startsWith('zh')) return /hant|-tw|-hk|-mo/.test(tag) ? 'zh-TW' : 'zh-CN'
142
143 // Other languages by their primary subtag (`in` is the legacy code for Indonesian)
144 const primary = tag.split('-')[0] ?? ''
145 if (primary === 'in') return 'id'
146 return SUPPORTED_LOCALES.find((locale) => locale === primary) ?? null
147}
148
149// Maps a free-text language name (Claude Code's `language` setting) to a supported locale
150export const localeFromLanguageName = (name: string | undefined): Locale | null => {
151 if (!name) return null
152
153 const asTag = normalizeLocale(name)
154 if (asTag) return asTag
155
156 const hint = LANGUAGE_NAME_HINTS.find(([pattern]) => pattern.test(name))
157 return hint ? hint[1] : null
158}
159types/index.d.ts 25 lines1// One rate-limit window
2export type QuotaWindow = {
3 percent: number // percent used, 0-100
4 resetsAt?: number // reset time (epoch ms)
5}
6
7// The three bars' data; a missing one is not drawn
8export type QuotaUsage = {
9 contextPercent?: number // context window usage
10 fiveHour?: QuotaWindow // 5-hour session limit
11 sevenDay?: QuotaWindow // 7-day weekly limit
12}
13
14// Supported display languages
15export type Locale = 'en' | 'zh-CN' | 'zh-TW' | 'ja' | 'hi' | 'es' | 'ar' | 'fr' | 'bn' | 'pt' | 'ru' | 'id'
16
17declare module 'claude-code' {
18 interface PluginState {
19 'mod-usage': {
20 usage: QuotaUsage | null // latest measurement
21 locale: Locale // resolved display language
22 }
23 }
24}
25