SLOPSHOPPER

mod-usage

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

newbandprocesstimer
★ 6v1.0.0MITupdated 2026-10-03jack21/claude-mod-usage
A shopper browsing a rack in a slop shop
README

claude-mod-usage

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

  • Context: how full the current context window is
  • 5-hour limit: your session usage, with the time left until it resets
  • 7-day limit: your weekly usage, with the time left until it resets

claude-mod-usage preview

Features

  • Green → yellow → red gradient bars with rounded ends; the icon's accent colour follows the usage too
  • The percentage sits in the middle of each bar, outlined so it stays readable on any colour
  • All bars have the same length and the row always uses the full width
  • Reset countdown in a monospace font
  • Updates after every turn and whenever a limit moves by a whole point; the countdown ticks every minute
  • 12 languages, detected automatically
  • Leaves the terminal alone: the CLI already has a status line for this
  • Plays well with other mods above the prompt: whatever they draw there is stacked under the bars instead of being covered

All 12 languages

Requirements

  • Claude Code with Mods (function hooks) support. Tested on 2.1.286. The Mods API is in early access and may change.
  • A Claude subscription. The 5-hour and 7-day bars only appear when Claude Code reports rate limits; with an API key you get the context bar alone.

Install

Quick install: let Claude do it

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.

Manual install

  1. Get the files:
   git clone https://github.com/jack21/claude-mod-usage ~/.claude/mods/claude-mod-usage
  1. Tell Claude Code to load it. Claude Code Desktop cannot pass command-line flags, so add the folder to the 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).

  1. Start a new session. The bars appear after the first reply.

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.

Language

The display language is chosen in this order:

  1. The Language option in the plugin's config menu (auto by default)
  2. Claude Code's own language setting (e.g. "japanese", "繁體中文")
  3. LC_ALL, LC_MESSAGES, LANG
  4. The macOS system language (apps launched from the Dock usually have no LANG)
  5. English

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.

How it works

  • session.measure pushes the context and rate-limit figures; session.start reads the current ones with $.session.usage().
  • The bars are drawn into the 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.
  • Desktop draws isInteractive SVG frames on a white background, so everything here is drawn as plain images.

Develop

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.

License

MIT © 2026 Jack Chiang

Source 3 files
hooks/register.tsx 317 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
317
hooks/i18n.ts 159 lines
1import 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}
159
types/index.d.ts 25 lines
1// 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