SLOPSHOPPER

context-git-band

Claude Code mod: one line above the prompt with the context window fill (weather, with a compact link before auto-compaction), the subscription quota left…

newbandguardtoastpromptmodel
v0.1.0MITupdated 2026-10-07mlabo-org/context-git-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-git-band
› 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 ☁ Cloudy 49% (97k/200k) │ ✔ clean · no upstream [ Hide ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% (97k/200k) │ ✔ clean · no upstream [ Hide ] ⟨Claude Code's own drawing⟩
README

context-git-band

日本語

A Claude Code mod (needs Claude Code 2.1.288 or later; tested on 2.1.288 and 2.1.291). It draws one line above the prompt, in the terminal and in the desktop Code tab:

The band in the desktop Code tab, in English

In the terminal, with language set to en:

☂ Showers 54% (108k/200k) │ 5h ████████▊░ 88% wk ███████▋░░ 77% │ main ● [5 uncommitted] · ↑2 unpushed  [Hide]

Claude Code only; Codex has no mods.

What it shows

Context window as weather

  • The fill of the context window, as a share of the model's whole window: ☀ Clear (under 25%), ☁ Cloudy, ☂ Showers (50%), ☇ Storm (75%), ↯ Compact Soon (90%), with the tokens used.

Compact link

  • 50k tokens before Claude Code's own auto-compaction, between turns, the weather part is drawn as a link (☂ Showers 60% (120k/200k) ⟲ compact), and a toast says so once.
  • Pressing the link compacts the conversation, the same call /compact makes; the band shows ⟳ Compacting… until it ends.
  • The auto-compaction point is read from the engine each turn (autoCompactThreshold of the /context breakdown, estimated locally, no request). Measured, it is the window less 33k, so the link comes at 117k of 200k (58.5%) and 917k of 1M. With auto-compaction off, the window itself is the limit.
  • While a turn runs, the part stays plain text, since a compaction is refused then.

Subscription quota

  • What is left of the 5-hour and weekly windows, as remaining percent, each with a 10-cell gauge: green, yellow at 25% or less, red at 10% or less. The terminal draws the gauge in block characters (eighths of a cell); the desktop Code tab draws it as a rounded SVG bar.
  • Two sources, the newer reading shown:
  • this session's own API responses (session.measure's rateLimits), shown the moment a window moves a whole point;
  • the account usage endpoint https://api.anthropic.com/api/oauth/usage, so use from other sessions shows too. It is fetched through $.http.fetch with the session's own credential ($.session.authorize(); the token never reaches the mod): at session start, when a turn ends (at most once a minute) and otherwise every 3 minutes. A failure or a 429 doubles the wait, up to 15 minutes.
  • The newest reading and the poll's own bookkeeping (last fetch time, backoff, a claim) are kept apart in the plugin's $.store, which every session reads, so open sessions share one fetch and none overwrites another's backoff.
  • The endpoint is not part of Anthropic's public API: it may change or stop working at any time, and whether the terms of service allow a third-party tool to call it has not been confirmed. Use it at your own risk; with no subscription sign-in, the mod never calls it.
  • Nothing shows until one of the sources has a reading, nor off a subscription (an API key).

Git state

  • The session folder's branch, uncommitted files, unpushed commits (↑) and commits behind (↓).
  • A branch with no upstream shows not on GitHub. It shows no upstream instead when a remote already has a branch of that name (pushed without -u), or when the repo's remotes are all off GitHub.
  • It is read in the background at session start, on each prompt, after Bash/Edit/Write tool calls (at most once every 2 s) and when a turn ends, with git --no-optional-locks, so it never takes the index lock from your own git commands.
  • In a folder that is not a Git repository, or on a host that cannot run commands ($.process is CLI only), the line shows the context part alone.
  • When there are uncommitted files, the count is a button: pressing it submits Commit the uncommitted changes (in the band's language) as your prompt, so the session starts the commit (queued until the current turn ends).

Hide

  • Hide collapses the line to a small ▸ ctx/git button; press it to bring the line back.
  • The band stacks with the bands of other mods beneath it.

Language

The band's own words (wk, uncommitted, clean, not on GitHub, unpushed, the prompt the uncommitted button sends, and the toasts) follow the language you use. The weather names and the short labels (5h, Hide, compact) stay English.

The plugin's language option picks the language, auto by default:

  • auto, in the desktop Code tab, follows the Claude desktop app's display language. It is read from locale in the app's own ~/Library/Application Support/Claude/config.json: an internal file, not a documented interface, which also holds the app's sign-in cache, so the mod reads it again only when its modification time moves and keeps nothing but locale.
  • auto, in the terminal (and in the desktop when the app's language cannot be read), follows Claude Code's own language setting (the language Claude replies in), then the locale (LC_ALL, LC_MESSAGES, LANG). With neither, it is English.
  • ja or en fixes Japanese or English on every surface.

Japanese and English are written into the mod. Any other language is translated from English the first time it appears, by one small model call (haiku, through the session's own client, so it counts against your usage). Until it lands, the band shows English; it then switches within seconds. The translation is kept in the plugin's $.store per language and reused by every session, and translated again only when the English words change. A translation that loses a {placeholder}, runs too long for the band, or is not well-formed is thrown away, and that language then stays in English in every session, with no further call, until the English words change.

A switch shows without a new session: a change of Claude Code's language in /config at once, any other change within 3 seconds.

Change the option in the /config panel, where it is a picker of auto, ja and en; the value is saved under pluginConfigs in ~/.claude/settings.json.

Install

In a Claude Code session (2.1.288 or later):

/plugin install context-git-band --marketplace mlabo-org/context-git-band

Or from your shell:

claude plugin marketplace add mlabo-org/context-git-band
claude plugin install context-git-band@context-git-band

Then start a new session. In the desktop app, you can also add the marketplace and install from + > Plugins.

To try a local copy for one session without installing it:

claude --plugin-dir /path/to/context-git-band

Check

claude plugin validate ~/plugins/context-git-band
claude plugin test ~/plugins/context-git-band

Credits

The weather metaphor and its five levels come from the Token Weather example in Getting started with Claude Code mods on the Anthropic blog. The code here is written independently and does not include that example's code.

License

MIT

Source 2 files
hooks/register.tsx 687 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { GitInfo, QuotaReading, QuotaWindow, Words } from '../types'
5
6const git = atom({ plugin: 'context-git-band', key: 'git' } as const, null)
7const isHidden = atom({ plugin: 'context-git-band', key: 'isHidden' } as const, false)
8const isCompacting = atom({ plugin: 'context-git-band', key: 'isCompacting' } as const, false)
9
10// Tokens at which the weather turns into a link that compacts, and a toast says so
11const compactAt = atom({ plugin: 'context-git-band', key: 'compactAt' } as const, null)
12
13// Claude Code compacts on its own at its threshold (the window less 33k as measured:
14// 167k of 200k, 967k of 1M). The link comes this many tokens earlier, so one or two
15// heavy turns still fit before the automatic one runs mid-task.
16const COMPACT_MARGIN_TOKENS = 50_000
17// Never opened: the press is the plugin's (pressableLinks)
18const COMPACT_HREF = 'http://localhost/compact'
19
20// The context window's fill as weather, with the guide's thresholds
21const weather = (percent: number) => {
22  if (percent >= 90) return { icon: '↯', label: 'Compact Soon', color: 'red' }
23  if (percent >= 75) return { icon: '☇', label: 'Storm', color: 'red' }
24  if (percent >= 50) return { icon: '☂', label: 'Showers', color: 'yellow' }
25  if (percent >= 25) return { icon: '☁', label: 'Cloudy', color: 'cyan' }
26  return { icon: '☀', label: 'Clear', color: 'green' }
27}
28
29const kilo = (n: number) => `${Math.round(n / 1000)}k`
30
31
32// The language the band's words are in, as a key (`ja`, `en`, `fr`, `zh-tw`, or a
33// language name): Claude Code's (every surface), and the desktop app's display
34// language, which the desktop Code tab prefers
35const language = atom({ plugin: 'context-git-band', key: 'language' } as const, null)
36const appLanguage = atom({ plugin: 'context-git-band', key: 'appLanguage' } as const, null)
37// Tables translated from EN, by language key
38const translations = atom({ plugin: 'context-git-band', key: 'translations' } as const, null)
39
40// How often a change of either is picked up
41const LANGUAGE_CHECK_MS = 3_000
42
43// The band's own words as templates; `{name}` is filled in. Japanese and English
44// are written here; any other language is translated from English once (see
45// ensureWords). The weather names and the short labels (Hide, compact) stay English.
46const EN: Words = {
47  week: 'wk',
48  quotaAlt: '{label} {remaining}% left',
49  uncommitted: '{count} uncommitted',
50  commitPrompt: 'Commit the uncommitted changes',
51  clean: '✔ clean',
52  noUpstream: 'no upstream',
53  notOnGitHub: 'not on GitHub',
54  unpushed: '↑{count} unpushed',
55  compactSoon: 'Context {used}/{window}: compact soon',
56  compactSkipped: 'compact skipped: {reason}',
57  compactFailed: 'compact could not run now',
58}
59
60const JA: Words = {
61  week: '週',
62  quotaAlt: '{label} 残{remaining}%',
63  uncommitted: '{count} 未コミット',
64  commitPrompt: '未コミットの変更をコミットして',
65  clean: '✔ クリーン',
66  noUpstream: 'upstreamなし',
67  notOnGitHub: 'GitHub未公開',
68  unpushed: '↑{count} 未push',
69  compactSoon: 'コンテキスト {used}/{window}:そろそろ compact',
70  compactSkipped: 'compact を見送りました:{reason}',
71  compactFailed: '今は compact できません',
72}
73
74const WRITTEN: Record<string, Words> = { en: EN, ja: JA }
75
76const fill = (template: string, vars: Record<string, string | number>) =>
77  template.replace(/\{(\w+)\}/g, (whole, name: string) => (name in vars ? String(vars[name]) : whole))
78
79const wordsFor = (key: string | null, translated: Record<string, Words> | null) =>
80  (key === null ? undefined : (WRITTEN[key] ?? translated?.[key])) ?? EN
81
82const quotaColor = (remaining: number) => (remaining <= 10 ? 'red' : remaining <= 25 ? 'yellow' : 'green')
83
84// A gauge of the remaining percent, 10 cells, each split into eighths
85const METER_CELLS = 10
86const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
87
88const meterText = (remaining: number) => {
89  const eighths = Math.round((remaining / 100) * METER_CELLS * 8)
90  const full = Math.floor(eighths / 8)
91  const partial = EIGHTHS[eighths % 8] ?? ''
92  const filled = '█'.repeat(full) + partial
93  return { filled, empty: '░'.repeat(METER_CELLS - full - (partial === '' ? 0 : 1)) }
94}
95
96// The desktop draws the gauge as a rounded bar; colors read on light and dark
97const SVG_COLORS: Record<string, string> = { green: '#2ea043', yellow: '#d29922', red: '#e5534b' }
98const METER_WIDTH = 64
99const METER_HEIGHT = 8
100
101const meterSvg = (remaining: number) => {
102  const fill = Math.round((remaining / 100) * METER_WIDTH)
103  const r = METER_HEIGHT / 2
104  return (
105    `<svg xmlns="http://www.w3.org/2000/svg" width="${METER_WIDTH}" height="${METER_HEIGHT}" viewBox="0 0 ${METER_WIDTH} ${METER_HEIGHT}">` +
106    `<clipPath id="c"><rect width="${METER_WIDTH}" height="${METER_HEIGHT}" rx="${r}"/></clipPath>` +
107    `<g clip-path="url(#c)"><rect width="${METER_WIDTH}" height="${METER_HEIGHT}" fill="#8b949e" fill-opacity="0.3"/>` +
108    `<rect width="${fill}" height="${METER_HEIGHT}" fill="${SVG_COLORS[quotaColor(remaining)]}"/></g></svg>`
109  )
110}
111
112// `git status --porcelain=v1 -b`: first line is the branch, the rest are changed files
113const parseStatus = (stdout: string): GitInfo => {
114  const [head = '', ...rest] = stdout.split('\n').filter(line => line !== '')
115  const branchLine = head.replace(/^## /, '')
116  const name = branchLine.split('...')[0] ?? branchLine
117  const hasUpstream = branchLine.includes('...')
118  const ahead = /ahead (\d+)/.exec(branchLine)
119  const behind = /behind (\d+)/.exec(branchLine)
120
121  return {
122    branch: name.startsWith('No commits yet on ') ? name.slice(18) : name,
123    dirty: rest.length,
124    ahead: hasUpstream ? (ahead ? Number(ahead[1]) : 0) : null,
125    behind: behind ? Number(behind[1]) : 0,
126    notOnGitHub: false,
127  }
128}
129
130// `git remote -v` lists no remote, or a remote on GitHub
131const mayGoToGitHub = (remotes: string) => {
132  const urls = remotes.split('\n').filter(line => line.trim() !== '')
133  return urls.length === 0 || urls.some(line => line.includes('github.com'))
134}
135
136// `git for-each-ref --format=%(refname:lstrip=3) refs/remotes` names a branch
137// of this name on a remote, as when it was pushed without setting an upstream
138const isOnRemote = (refs: string, branch: string) => refs.split('\n').includes(branch)
139
140const EDITING_TOOLS = ['Bash', 'Edit', 'Write', 'NotebookEdit']
141
142let lastRefresh = 0
143
144// Reads git into the shared value; a folder that is not a repo, or a host
145// that cannot run commands, leaves the band showing the context part alone.
146async function refreshGit($: EngineInterface, force: boolean) {
147  const now = await $.clock.now()
148  if (!force && now - lastRefresh < 2000) return
149  lastRefresh = now
150
151  try {
152    const { exitCode, stdout } = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v1', '-b'], {
153      timeoutMs: 5000,
154    })
155    const info = exitCode === 0 ? parseStatus(stdout) : null
156    if (info !== null && info.ahead === null) {
157      const remotes = await $.process.run(['git', 'remote', '-v'], { timeoutMs: 5000 })
158      if (remotes.exitCode === 0 && mayGoToGitHub(remotes.stdout)) {
159        const refs = await $.process.run(
160          ['git', 'for-each-ref', '--format=%(refname:lstrip=3)', 'refs/remotes'],
161          { timeoutMs: 5000 },
162        )
163        info.notOnGitHub = refs.exitCode === 0 && !isOnRemote(refs.stdout, info.branch)
164      }
165    }
166    await update($, git, () => info)
167  } catch {
168    await update($, git, () => null)
169  }
170}
171
172// The subscription's 5-hour and weekly windows, from two sources:
173// - this session's API responses, pushed by `session.measure` (instant, this session's use only)
174// - the account usage endpoint (undocumented), polled (every session's use)
175// The newer reading wins. Polls are shared across sessions through `$.store`,
176// so any number of open sessions makes the requests of one.
177
178const quota = atom({ plugin: 'context-git-band', key: 'quota' } as const, null)
179
180// Undocumented and may change; it answers 429 to aggressive polling
181const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
182const OAUTH_BETA = 'oauth-2025-04-20'
183const POLL_GAP_MS = 180_000
184const EVENT_GAP_MS = 60_000
185const CHECK_EVERY_MS = 30_000
186const MAX_BACKOFF_MS = 900_000
187// How long a session that started a request keeps the others from starting one
188const CLAIM_MS = 15_000
189
190const KINDS = ['five_hour', 'seven_day']
191
192// Two store keys, so no writer overwrites the other's part: the newest reading
193// (this session's responses and every poll write it) and the poll's own
194// bookkeeping (only a poll writes it)
195const READING_KEY = 'quota:reading'
196const POLL_KEY = 'quota:poll'
197
198type Poll = { fetchedAt: number; failures: number; nextAt: number; claim: { until: number; token: string } | null }
199
200const NO_POLL: Poll = { fetchedAt: 0, failures: 0, nextAt: 0, claim: null }
201
202const loadPoll = async ($: EngineInterface): Promise<Poll> => {
203  const value = await $.store.get(POLL_KEY)
204  return typeof value === 'object' && value !== null ? { ...NO_POLL, ...(value as Partial<Poll>) } : { ...NO_POLL }
205}
206
207const loadReading = async ($: EngineInterface): Promise<QuotaReading | null> => {
208  const value = (await $.store.get(READING_KEY)) as QuotaReading | undefined
209  return value !== undefined && Array.isArray(value.windows) && typeof value.at === 'number' ? value : null
210}
211
212const isNewer = (next: QuotaReading, current: QuotaReading | null | undefined) =>
213  current === null || current === undefined || next.at > current.at
214
215const adopt = ($: EngineInterface, reading: QuotaReading) =>
216  update($, quota, current => (isNewer(reading, current) ? reading : current))
217
218// Shares a reading unless the store already holds a newer one
219async function shareReading($: EngineInterface, reading: QuotaReading) {
220  if (isNewer(reading, await loadReading($))) await $.store.set(READING_KEY, reading)
221}
222
223// A reading from this session's own API responses: shown at once and shared
224async function takeLive($: EngineInterface, rateLimits: SessionRateLimit[]) {
225  const windows: QuotaWindow[] = rateLimits
226    .filter(limit => KINDS.includes(limit.kind))
227    .map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
228  if (windows.length === 0) return
229  const reading = { windows, at: await $.clock.now() }
230  await adopt($, reading)
231  await shareReading($, reading)
232}
233
234type UsageWindow = { utilization?: number | null; resets_at?: string | null } | null | undefined
235
236const parseUsage = (text: string, at: number): QuotaReading | null => {
237  const body = JSON.parse(text) as Record<string, UsageWindow>
238  const windows = KINDS.flatMap(kind => {
239    const window = body[kind]
240    return typeof window?.utilization === 'number'
241      ? [{ kind, percentUsed: window.utilization, resetsAt: window.resets_at ?? undefined }]
242      : []
243  })
244  return windows.length === 0 ? null : { windows, at }
245}
246
247let isPolling = false
248
249// Takes what another session shared, then polls when `gapMs` has passed since
250// the last poll and no backoff or other session's request stands in the way.
251// The store has no compare-and-set: a session writes a claim with its own token
252// and goes ahead only if that token reads back, which narrows (not closes) the
253// window in which two sessions both poll.
254async function syncQuota($: EngineInterface, gapMs: number) {
255  const shared = await loadReading($)
256  if (shared !== null) await adopt($, shared)
257  if (isPolling) return
258  isPolling = true
259  try {
260    const poll = await loadPoll($)
261    const now = await $.clock.now()
262    if (now < poll.nextAt || (poll.claim !== null && now < poll.claim.until) || now - poll.fetchedAt < gapMs) return
263
264    // A subscription signs in with a bearer token; an API key has no such quota
265    const auth = await $.session.authorize()
266    if (auth === null || auth.kind !== 'bearer') return
267
268    const token = `${now}-${Math.random().toString(36).slice(2)}`
269    await $.store.set(POLL_KEY, { ...poll, claim: { until: now + CLAIM_MS, token } })
270    if ((await loadPoll($)).claim?.token !== token) return
271
272    let reading: QuotaReading | null = null
273    try {
274      const response = await $.http.fetch(USAGE_URL, {
275        auth: auth.handle,
276        headers: { 'anthropic-beta': OAUTH_BETA, Accept: 'application/json' },
277      })
278      if (response.ok) reading = parseUsage(response.text, now)
279    } catch {
280      reading = null
281    }
282
283    const latest = await loadPoll($)
284    if (reading === null) {
285      const failures = latest.failures + 1
286      await $.store.set(POLL_KEY, {
287        failures,
288        fetchedAt: now,
289        nextAt: now + Math.min(POLL_GAP_MS * 2 ** (failures - 1), MAX_BACKOFF_MS),
290        claim: null,
291      })
292      return
293    }
294    await $.store.set(POLL_KEY, { fetchedAt: now, failures: 0, nextAt: 0, claim: null })
295    await shareReading($, reading)
296    await adopt($, reading)
297  } finally {
298    isPolling = false
299  }
300}
301
302const remainingOf = (reading: QuotaReading | null) =>
303  (reading?.windows ?? []).map(({ kind, percentUsed }) => ({
304    kind,
305    remaining: Math.max(0, Math.min(100, Math.round(100 - percentUsed))),
306  }))
307
308// `/compact`'s own call; refused while a turn runs, so the link shows only between
309// turns. The module flag is set before any await, so a second press does nothing.
310let isCompactRunning = false
311
312async function compactNow($: EngineInterface) {
313  if (isCompactRunning) return
314  isCompactRunning = true
315  try {
316    await update($, isCompacting, () => true)
317    let note: string | undefined
318    try {
319      const result = await $.session.compact()
320      if (result.skip !== undefined) note = fill((await toastWords($)).compactSkipped, { reason: result.skip })
321    } catch {
322      note = (await toastWords($)).compactFailed
323    }
324    if (note !== undefined) $.ui.toast(note)
325  } finally {
326    isCompactRunning = false
327    await update($, isCompacting, () => false)
328  }
329}
330
331// The engine's own threshold, read off the /context breakdown (estimated
332// locally, no request); with auto-compaction off the window is the limit
333async function refreshCompactAt($: EngineInterface) {
334  const { context } = await $.session.usage({ breakdown: 'summary' })
335  const limit = context.breakdown?.autoCompactThreshold ?? context.window
336  await update($, compactAt, () => Math.max(0, limit - COMPACT_MARGIN_TOKENS))
337}
338
339let hasWarned = false
340
341// A language as a key: `ja` and `en` for the written tables, else a lowercased
342// locale tag (`fr`, `pt-br`, `zh-tw`, `sr-latin`) or language name (`french`);
343// null when it names no language (`C`, `POSIX`, a bare `UTF-8`)
344const languageKey = (raw: string): string | null => {
345  const lowered = raw.trim().toLowerCase()
346  // A locale's modifier names its script (sr_RS@latin); its encoding names nothing
347  const modifier = /@([a-z]+)/.exec(lowered)?.[1]
348  const text = lowered.replace(/[.@].*$/, '').replace(/_/g, '-')
349  if (text === '' || text === 'c' || text === 'posix' || /^utf-?8$/.test(text)) return null
350  if (/^(japanese|日本語)/.test(text) || /^ja(?:$|-)/.test(text)) return 'ja'
351  if (/^english/.test(text) || /^en(?:$|-)/.test(text)) return 'en'
352  const tag = /^([a-z]{2,3})(?:-([a-z0-9]+))?$/.exec(text)
353  if (tag === null) return text
354  const [, base = text, region] = tag
355  // Keep what changes the written language: zh-tw/zh-hant, pt-br, a script modifier
356  if (modifier !== undefined) return `${base}-${modifier}`
357  return region !== undefined && (base === 'zh' || base === 'pt') ? `${base}-${region}` : base
358}
359
360// `auto` follows Claude Code's own `language` setting (the language Claude replies
361// in), then the locale variables; English with nothing to go by
362async function claudeLanguage($: EngineInterface): Promise<string> {
363  const { language: replyLanguage } = (await $.settings.read()) as { language?: unknown }
364  const fromSetting = typeof replyLanguage === 'string' ? languageKey(replyLanguage) : null
365  if (fromSetting !== null) return fromSetting
366  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG')) || ''
367  return languageKey(locale) ?? 'en'
368}
369
370// The desktop app keeps its display language as `locale` in its own config.json,
371// an internal file that also holds its sign-in cache: read again only when its
372// mtime moves, and nothing but `locale` kept. Null where there is no such app.
373let appConfigMtime = -1
374
375async function readAppLanguage($: EngineInterface): Promise<string | null | undefined> {
376  const home = await $.env.get('HOME')
377  if (home === undefined) return null
378  const path = `${home}/Library/Application Support/Claude/config.json`
379  try {
380    const { mtimeMs } = await $.fs.stat(path)
381    if (mtimeMs === appConfigMtime) return undefined
382    appConfigMtime = mtimeMs
383    const { locale } = JSON.parse(String(await $.fs.read(path))) as { locale?: unknown }
384    return typeof locale === 'string' ? languageKey(locale) : null
385  } catch {
386    return null
387  }
388}
389
390// A translated table is kept in the store per language, stamped with the English
391// it came from, so a change of EN translates again
392const hashText = (text: string) => {
393  let hash = 5381
394  for (let i = 0; i < text.length; i += 1) hash = ((hash * 33) ^ text.charCodeAt(i)) >>> 0
395  return hash.toString(36)
396}
397const EN_VERSION = hashText(JSON.stringify(EN))
398const TRANSLATE_CLAIM_MS = 60_000
399const translating = new Set<string>()
400
401const placeholders = (template: string) => (template.match(/\{\w+\}/g) ?? []).sort().join()
402
403// A translation is taken whole or not at all: every key, every placeholder, one
404// line each, and no longer than the band can hold
405const checkWords = (value: unknown): Words | null => {
406  if (typeof value !== 'object' || value === null) return null
407  const table = value as Record<string, unknown>
408  for (const key of Object.keys(EN) as (keyof Words)[]) {
409    const text = table[key]
410    if (typeof text !== 'string' || text.trim() === '' || /[\n\[\]]/.test(text)) return null
411    if (placeholders(text) !== placeholders(EN[key])) return null
412    if (text.length > Math.max(24, EN[key].length * 2.5)) return null
413  }
414  return Object.fromEntries((Object.keys(EN) as (keyof Words)[]).map(key => [key, String(table[key])])) as Words
415}
416
417// A language's table as kept: its words, or `failed` when its translation did not
418// hold, which keeps that language in English
419type StoredWords = { version: string; words?: Words; failed?: true }
420
421// Makes the table for `key` ready: written, already translated, kept in the
422// store, or translated now by one small model call (the band shows English
423// until it lands). A failure fixes English for that language, in every session,
424// until the English words change; nothing is tried again before then.
425async function ensureWords($: EngineInterface, key: string | null) {
426  if (key === null || key in WRITTEN || translating.has(key)) return
427  translating.add(key)
428  let claimKey: string | undefined
429  let isDone = false
430  const storeKey = `words:${key}`
431  try {
432    if ((await read($, translations))?.[key] !== undefined) return
433    const stored = (await $.store.get(storeKey)) as Partial<StoredWords> | undefined
434    if (stored?.version === EN_VERSION && stored.failed === true) return
435    const kept = stored?.version === EN_VERSION ? checkWords(stored.words) : null
436    if (kept !== null) {
437      await update($, translations, current => ({ ...current, [key]: kept }))
438      return
439    }
440    const now = await $.clock.now()
441    const claim = `translating:${key}`
442    if (Number((await $.store.get(claim)) ?? 0) > now) return
443    await $.store.set(claim, now + TRANSLATE_CLAIM_MS)
444    claimKey = claim
445    const result = await $.model.complete({
446      model: 'haiku',
447      system: 'You translate the user interface strings of a one-line status bar in a developer tool.',
448      prompt:
449        `Translate the values of this JSON object into the language "${key}" (a language name or a locale tag). ` +
450        'Keep every key. Keep each {placeholder} exactly as written. Keep the ✔ and ↑ marks, and keep 5h, GitHub, ' +
451        'upstream, push and compact as they are. Make each value as short as a status bar needs. ' +
452        'Answer with the JSON object only.\n\n' +
453        JSON.stringify(EN),
454      maxTokens: 1024,
455      timeoutMs: 30_000,
456    })
457    if (!result.isAnswered) return
458    const json = /\{[\s\S]*\}/.exec(result.text)?.[0]
459    const words = json === undefined ? null : checkWords(JSON.parse(json))
460    if (words === null) return
461    await $.store.set(storeKey, { version: EN_VERSION, words })
462    await update($, translations, current => ({ ...current, [key]: words }))
463    isDone = true
464  } catch {
465    // English stays
466  } finally {
467    translating.delete(key)
468    if (claimKey !== undefined) {
469      await $.store.delete(claimKey).catch(() => undefined)
470      if (!isDone) await $.store.set(storeKey, { version: EN_VERSION, failed: true }).catch(() => undefined)
471    }
472  }
473}
474
475// Settles both languages and makes their tables ready; `ja` or `en` in the
476// option fixes them on every surface
477async function refreshLanguage($: EngineInterface, option: unknown) {
478  if (option === 'ja' || option === 'en') {
479    await update($, language, () => option)
480    await update($, appLanguage, () => null)
481    return
482  }
483  const claude = await claudeLanguage($)
484  await update($, language, current => (current === claude ? current : claude))
485  const app = await readAppLanguage($)
486  if (app !== undefined) await update($, appLanguage, current => (current === app ? current : app))
487  void ensureWords($, claude)
488  void ensureWords($, await read($, appLanguage))
489}
490
491// Toasts follow the band's rule: the desktop app's language only when the
492// session draws on the desktop alone, Claude Code's otherwise
493async function toastWords($: EngineInterface) {
494  const surfaces = await $.session.surfaces()
495  const isDesktopOnly = surfaces.length > 0 && surfaces.every(surface => surface === 'desktop')
496  const app = isDesktopOnly ? await read($, appLanguage) : null
497  return wordsFor(app ?? (await read($, language)), await read($, translations))
498}
499
500export const register: Register = (on, options) => {
501
502  on('session.start', async ($, e, next) => {
503    // A reload in the middle of a compaction leaves the flag behind
504    await update($, isCompacting, () => false)
505    await refreshLanguage($, options.language)
506    $.clock.every(LANGUAGE_CHECK_MS, () => void refreshLanguage($, options.language).catch(() => undefined))
507    await refreshGit($, true)
508    // A reload drops the old timer with the old module
509    $.clock.every(CHECK_EVERY_MS, () => void syncQuota($, POLL_GAP_MS).catch(() => undefined))
510    void syncQuota($, EVENT_GAP_MS).catch(() => undefined)
511    await refreshCompactAt($)
512
513    return next(e)
514  })
515
516  // A `/config` change of Claude Code's language shows at once
517  on('config.set', async ($, e, next) => {
518    const result = await next(e)
519    await refreshLanguage($, options.language).catch(() => undefined)
520
521    return result
522  }).catch(($, e, next) => next(e))
523
524  // Git is read in the background, so a prompt or a tool result never waits on it
525  on('prompt.submit', ($, e, next) => {
526    void refreshGit($, false).catch(() => undefined)
527
528    return next(e)
529  }).catch(($, e, next) => next(e))
530
531  on('tool.call', async ($, e, next) => {
532    const result = await next(e)
533    if (EDITING_TOOLS.includes(e.tool)) void refreshGit($, false).catch(() => undefined)
534
535    return result
536  }).catch(($, e, next) => next(e))
537
538  on('session.measure', async ($, e, next) => {
539    if (e.changed.includes('rateLimits')) await takeLive($, e.rateLimits)
540
541    return next(e)
542  })
543
544  on('turn.complete', async ($, e, next) => {
545    await refreshGit($, true)
546    void syncQuota($, EVENT_GAP_MS).catch(() => undefined)
547
548    await refreshCompactAt($)
549    const { context } = await $.session.usage()
550    const at = await read($, compactAt)
551    const tokens = context.tokens ?? 0
552    if (at !== null && tokens >= at && !hasWarned) {
553      hasWarned = true
554      $.ui.toast(fill((await toastWords($)).compactSoon, { used: kilo(tokens), window: kilo(context.window) }))
555    } else if (at !== null && tokens < at) {
556      hasWarned = false
557    }
558
559    return next(e)
560  })
561
562  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
563    if (e.props.hasSurvey) {
564      return next(e)
565    }
566
567    const elements = $.ui.resolve(e)
568    const claude = await read($, language)
569    const app = e.surface === 'desktop' ? await read($, appLanguage) : null
570    const t = wordsFor(app ?? claude, await read($, translations))
571    const { Box, Button, Markdown, Text } = elements
572    const Svg = e.surface === 'desktop' && 'Svg' in elements ? elements.Svg : undefined
573
574    // Hidden: leave one small button that brings the band back
575    if (await read($, isHidden)) {
576      const beneath = await next(e)
577
578      return (
579        <Box flexDirection="column">
580          <Box>
581            <Button key="show" label="▸ ctx/git" onPress={() => update($, isHidden, () => false)} />
582          </Box>
583          {beneath ?? null}
584        </Box>
585      )
586    }
587
588    const { context } = await $.session.usage()
589    const info = await read($, git)
590    const quotas = remainingOf(await read($, quota)).map(q => ({ ...q, label: q.kind === 'seven_day' ? t.week : q.kind === 'five_hour' ? '5h' : q.kind }))
591
592    const hasContext = context.percent !== undefined
593    const w = weather(context.percent ?? 0)
594    const compacting = await read($, isCompacting)
595    const at = await read($, compactAt)
596    const canCompact =
597      at !== null && context.tokens !== undefined && context.tokens >= at && !e.props.isWorking && !compacting
598    const tokensText =
599      hasContext && context.tokens !== undefined ? ` (${kilo(context.tokens)}/${kilo(context.window)})` : ''
600    // The band is one site shared by every plugin: keep what the ones beneath draw
601    const below = await next(e)
602
603    return (
604      <Box flexDirection="column">
605        <Box>
606          {compacting ? (
607            <Text color="cyan">⟳ Compacting… </Text>
608          ) : canCompact ? (
609            <Markdown
610              key="compact"
611              text={`[${w.icon} ${w.label} ${context.percent}%${tokensText} ⟲ compact](${COMPACT_HREF})`}
612              onLinkPress={() => void compactNow($)}
613              pressableLinks={[COMPACT_HREF]}
614            />
615          ) : hasContext ? (
616            <Box>
617              <Text color={w.color}>
618                {w.icon} {w.label} {context.percent}%{' '}
619              </Text>
620              {tokensText === '' ? null : <Text dimColor>{tokensText.trim()} </Text>}
621            </Box>
622          ) : (
623            <Text dimColor>ctx -- </Text>
624          )}
625          {quotas.length === 0 ? null : (
626            <Box>
627              <Text dimColor>│ </Text>
628              {quotas.map(q => {
629                const color = quotaColor(q.remaining)
630                const meter = meterText(q.remaining)
631                return (
632                  <Box key={q.label}>
633                    <Text>{q.label} </Text>
634                    {Svg === undefined ? (
635                      <Text>
636                        <Text color={color}>{meter.filled}</Text>
637                        <Text dimColor>{meter.empty}</Text>
638                      </Text>
639                    ) : (
640                      <Svg
641                        source={meterSvg(q.remaining)}
642                        alt={fill(t.quotaAlt, { label: q.label, remaining: q.remaining })}
643                        width={METER_WIDTH}
644                        height={METER_HEIGHT}
645                      />
646                    )}
647                    <Text color={color}> {q.remaining}% </Text>
648                  </Box>
649                )
650              })}
651            </Box>
652          )}
653          {info === null ? null : (
654            <Box>
655              <Text dimColor>│ </Text>
656              <Text bold>{info.branch}</Text>
657              {info.dirty > 0 ? (
658                <Box>
659                  <Text color="yellow"> ● </Text>
660                  <Button
661                    key="commit"
662                    label={fill(t.uncommitted, { count: info.dirty })}
663                    onPress={() => void $.prompt.submit({ text: t.commitPrompt, asUser: true })}
664                  />
665                </Box>
666              ) : (
667                <Text color="green"> {t.clean}</Text>
668              )}
669              <Text>
670                {info.ahead === null ? (
671                  <Text dimColor>{info.notOnGitHub ? ` · ${t.notOnGitHub}` : ` · ${t.noUpstream}`}</Text>
672                ) : info.ahead > 0 ? (
673                  <Text color="yellow"> · {fill(t.unpushed, { count: info.ahead })}</Text>
674                ) : null}
675                {info.behind > 0 ? <Text color="cyan"> · ↓{info.behind}</Text> : null}
676                <Text> </Text>
677              </Text>
678            </Box>
679          )}
680          <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
681        </Box>
682        {below ?? null}
683      </Box>
684    )
685  })
686}
687
types/index.d.ts 40 lines
1export type GitInfo = {
2  branch: string
3  dirty: number
4  ahead: number | null
5  behind: number
6  notOnGitHub: boolean
7}
8
9export type QuotaWindow = {
10  kind: string
11  percentUsed: number
12  resetsAt?: string
13}
14
15export type QuotaReading = {
16  windows: QuotaWindow[]
17  at: number
18}
19
20// The band's own words, as templates with {placeholders}
21export type Words = {
22  week: string
23  quotaAlt: string
24  uncommitted: string
25  commitPrompt: string
26  clean: string
27  noUpstream: string
28  notOnGitHub: string
29  unpushed: string
30  compactSoon: string
31  compactSkipped: string
32  compactFailed: string
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'context-git-band': { git: GitInfo | null; isHidden: boolean; isCompacting: boolean; compactAt: number | null; language: string | null; appLanguage: string | null; translations: Record<string, Words> | null; quota: QuotaReading | null }
38  }
39}
40