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…

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:

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.
☂ Showers 60% (120k/200k) ⟲ compact), and a toast says so once./compact makes; the band shows ⟳ Compacting… until it ends.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.session.measure's rateLimits), shown the moment a window moves a whole point;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.$.store, which every session reads, so open sessions share one fetch and none overwrites another's backoff.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.git --no-optional-locks, so it never takes the index lock from your own git commands.$.process is CLI only), the line shows the context part alone.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 collapses the line to a small ▸ ctx/git button; press it to bring the line back.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.
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
claude plugin validate ~/plugins/context-git-band
claude plugin test ~/plugins/context-git-band
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.
hooks/register.tsx 687 lines1import { 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}
687types/index.d.ts 40 lines1export 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