SLOPSHOPPER

context-meter

Context meter above the prompt: a full-width context bar, a KV-cache clock, the 5-hour session usage and a one-click Handoff (write handoff → /clear → resume).

newbandtoaststatuspromptmodel
v0.13.1MITupdated 2026-10-15m0s4ik/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
README

context-meter

A Claude Code mod that keeps the context window in sight. It draws one compact row above the prompt:

38% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━  381k/1M  🕐  [Handoff]
    ───────────────────
  • Context bar: full-width, green → amber from 60% → red from 80%, with tokens used / window.
  • Session line: a thin grey line under the bar fills with the 5-hour session usage. Hover the bar for 5h 27% · reset 2h 39m · 7d 30%.
  • Cache clock: a small clock shows how long the prompt (KV) cache stays warm (1-hour TTL from the last API reply). Green, yellow under 10 minutes, red once expired. Hover it for the minutes left and the last turn's cache-hit %, plus a ↻ refresh button that sends a tiny ping to keep the cache warm.
  • Handoff: one click asks Claude to write a handoff document, runs /clear, and starts the fresh context from that document. While it runs the row shows a spinner with the step (1/3 … 3/3) and prompts you type are held back, so /clear cannot wipe them. If writing the handoff fails, nothing is cleared. The last handoff is kept in the mod's store under last-handoff.

The row hides while Claude Code shows its own next-step or question box. In a terminal the same information is drawn with text and coloured blocks.

Install

/plugin install context-meter --marketplace m0s4ik/context-meter

Or add the marketplace first and pick the plugin from the list:

/plugin marketplace add m0s4ik/context-meter

Sessions already open keep the version they started with; open a new session to load it.

Language

The row speaks English or Italian. With language set to auto (the default) it follows Claude Code's own language setting, then the system locale (LC_ALL, LANG), and falls back to English. To force one, set the plugin's Language option in /config to en or it.

Adding a language means adding one dictionary to hooks/i18n.ts and its code to Lang in types/index.d.ts.

Notes

  • The cache timer is an estimate: Claude Code has no API that says whether the cache is warm. It assumes the 1-hour subscription TTL; in overage the real TTL drops to 5 minutes, which the cache-hit % will show.
  • Rate limits are only reported on a Claude subscription; with API billing the session line is not drawn.

Develop

claude plugin validate . && claude plugin test .

The hooks live in hooks/register.tsx, the shared state contract in types/index.d.ts, the tests in tests/.

License

MIT

Source 3 files
hooks/register.tsx 462 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheInfo, ContextFill, Limit, Limits } from '../types'
5import { detectLang, IT, STRINGS } from './i18n'
6import type { Strings } from './i18n'
7
8const fill = atom({ plugin: 'context-meter', key: 'fill' } as const, null)
9const phase = atom({ plugin: 'context-meter', key: 'phase' } as const, 'idle')
10const cache = atom({ plugin: 'context-meter', key: 'cache' } as const, { lastAt: null, hit: null, now: 0 })
11const limits = atom({ plugin: 'context-meter', key: 'limits' } as const, {})
12const lang = atom({ plugin: 'context-meter', key: 'lang' } as const, 'en')
13
14// Claude Code's prompt cache TTL on a subscription; it drops to 5 minutes in overage.
15const CACHE_TTL_MS = 60 * 60 * 1000
16const CACHE_WARN_MS = 10 * 60 * 1000
17
18export function cacheLabel(c: CacheInfo, s: Strings = IT): { text: string; tone: 'success' | 'warning' | 'error' | 'subtle' } {
19  if (c.lastAt === null) {
20    return { text: s.cacheNone, tone: 'subtle' }
21  }
22
23  const left = CACHE_TTL_MS - (c.now - c.lastAt)
24  const hit = c.hit === null ? '' : ` · hit ${c.hit}%`
25
26  if (left <= 0) {
27    return { text: `${s.cacheExpired}${hit}`, tone: 'error' }
28  }
29
30  return { text: `● cache ${Math.ceil(left / 60000)}m${hit}`, tone: left <= CACHE_WARN_MS ? 'warning' : 'success' }
31}
32
33export const SPINNER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">`
34  + `<circle cx="8" cy="8" r="6" fill="none" stroke="#f59e0b" stroke-opacity="0.25" stroke-width="2"/>`
35  + `<path d="M8 2 A6 6 0 0 1 14 8" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round">`
36  + `<animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="0.9s" repeatCount="indefinite"/>`
37  + `</path></svg>`
38
39function kilo(n: number): string {
40  return n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
41}
42
43export function label(f: ContextFill, s: Strings = IT): string {
44  return f.percent === undefined
45    ? `— ${s.of(kilo(f.window))}`
46    : `${f.percent}%  ·  ${kilo(f.tokens ?? 0)} / ${kilo(f.window)}`
47}
48
49function tone(percent: number): 'success' | 'warning' | 'error' {
50  return percent >= 80 ? 'error' : percent >= 60 ? 'warning' : 'success'
51}
52
53const HEX = { success: ['#34d399', '#10b981'], warning: ['#fbbf24', '#f59e0b'], error: ['#fb7185', '#ef4444'] }
54
55export type LimitLine = { percent: number; elapsed?: number; tone: 'success' | 'warning' | 'error' }
56
57export function barSvg(percent: number, week?: LimitLine): string {
58  const [from, to] = HEX[tone(percent)]
59  const w = Math.max(percent, 0.6) * 10
60  const h = week === undefined ? 6 : 10
61  const line = week === undefined
62    ? ''
63    : `<rect x="0" y="8.5" width="1000" height="1.5" fill="#8882" />`
64      + `<rect x="0" y="8.5" width="${Math.min(100, Math.max(week.percent, 0.6)) * 10}" height="1.5" fill="#8a8f98" />`
65
66  return `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="${h}" viewBox="0 0 1000 ${h}" preserveAspectRatio="none">`
67    + `<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs>`
68    + `<rect x="0" y="0" width="1000" height="6" rx="3" fill="#8883" />`
69    + `<rect x="0" y="0" width="${w}" height="6" rx="3" fill="url(#g)" />`
70    + line
71    + `</svg>`
72}
73
74const WEEK_MS = 7 * 24 * 60 * 60 * 1000
75const SESSION_MS = 5 * 60 * 60 * 1000
76
77type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
78
79export function toLimits(rl: readonly RateLimit[]): Limits {
80  const pick = (kind: string): Limit | undefined => {
81    const r = rl.find(x => x.kind === kind)
82    return r === undefined ? undefined : { percent: r.percentUsed, resetsAt: r.resetsAt }
83  }
84  return { session: pick('five_hour'), week: pick('seven_day') }
85}
86
87export function inLabel(ms: number, s: Strings = IT): string {
88  const m = Math.max(0, Math.round(ms / 60000))
89  const d = Math.floor(m / 1440)
90  const h = Math.floor((m % 1440) / 60)
91  return d > 0 ? `${d}${s.days} ${h}h` : h > 0 ? `${h}h ${m % 60}m` : `${m}m`
92}
93
94// Share of the window already elapsed (0..1), from its reset: 7 days for the week, 5 hours for the session.
95export function elapsed(w: Limit, now: number, windowMs = WEEK_MS): number | undefined {
96  if (w.resetsAt === undefined || now === 0) {
97    return undefined
98  }
99  const left = Date.parse(w.resetsAt) - now
100  return Number.isNaN(left) ? undefined : Math.min(1, Math.max(0, 1 - left / windowMs))
101}
102
103// Where the window ends up at its reset if usage keeps the current pace.
104export function pace(w: Limit, now: number, windowMs = WEEK_MS): { projected?: number; tone: 'success' | 'warning' | 'error' } {
105  const el = elapsed(w, now, windowMs)
106  if (el === undefined || el < 0.02) {
107    return { tone: tone(w.percent) }
108  }
109  const projected = Math.round(w.percent / el)
110  return { projected, tone: projected >= 100 ? 'error' : projected >= 85 ? 'warning' : 'success' }
111}
112
113export function limitsTip(l: Limits, now: number, s: Strings = IT): string {
114  const parts: string[] = []
115  if (l.session !== undefined) {
116    parts.push(`5h ${Math.round(l.session.percent)}%`)
117    if (l.session.resetsAt !== undefined && now !== 0) {
118      parts.push(`${s.reset} ${inLabel(Date.parse(l.session.resetsAt) - now, s)}`)
119    }
120  }
121  if (l.week !== undefined) {
122    parts.push(`${s.week} ${Math.round(l.week.percent)}%`)
123  }
124  return parts.join(' · ')
125}
126
127const TONE_HEX = { success: '#10b981', warning: '#f59e0b', error: '#ef4444', subtle: '#9ca3af' }
128
129export function cacheTip(c: CacheInfo, s: Strings = IT): string {
130  if (c.lastAt === null) {
131    return s.cacheTipNone
132  }
133
134  const left = CACHE_TTL_MS - (c.now - c.lastAt)
135  const head = left <= 0
136    ? s.cacheTipExpired(Math.floor(-left / 60000))
137    : s.cacheTipLeft(Math.ceil(left / 60000))
138  const hit = c.hit === null ? '' : `\n${s.cacheTipHit(c.hit)}`
139
140  return `${head} ${s.cacheTipTtl}${hit}`
141}
142
143export function cacheShort(c: CacheInfo, s: Strings = IT): string {
144  if (c.lastAt === null) {
145    return s.cacheShortNone
146  }
147
148  const left = CACHE_TTL_MS - (c.now - c.lastAt)
149  const hit = c.hit === null ? '' : ` · hit ${c.hit}%`
150
151  return (left <= 0 ? s.cacheShortExpired(Math.floor(-left / 60000)) : s.cacheShortLeft(Math.ceil(left / 60000))) + hit
152}
153
154export function cacheClockSvg(c: CacheInfo): string {
155  const { tone: t } = cacheLabel(c)
156  const color = TONE_HEX[t]
157  const frac = c.lastAt === null ? 0 : Math.min(1, Math.max(0, (CACHE_TTL_MS - (c.now - c.lastAt)) / CACHE_TTL_MS))
158  const at = (angle: number, r: number) => `${(10 + r * Math.sin(angle)).toFixed(2)} ${(10 - r * Math.cos(angle)).toFixed(2)}`
159  const a = frac * 2 * Math.PI
160  const remaining = frac >= 0.999
161    ? `<circle cx="10" cy="10" r="7.5" fill="${color}" fill-opacity="0.18"/>`
162    : frac <= 0
163      ? ''
164      : `<path d="M10 10 L10 2.5 A7.5 7.5 0 ${a > Math.PI ? 1 : 0} 1 ${at(a, 7.5)} Z" fill="${color}" fill-opacity="0.18"/>`
165  const ticks = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
166    .map(i => {
167      const q = i % 3 === 0
168      return `<line x1="${at((i * Math.PI) / 6, q ? 6 : 6.8).split(' ')[0]}" y1="${at((i * Math.PI) / 6, q ? 6 : 6.8).split(' ')[1]}" x2="${at((i * Math.PI) / 6, 7.6).split(' ')[0]}" y2="${at((i * Math.PI) / 6, 7.6).split(' ')[1]}" stroke="${color}" stroke-width="${q ? 1.2 : 0.6}" stroke-linecap="round"/>`
169    })
170    .join('')
171  const minute = `<line x1="10" y1="10" x2="${at(a, 5.8).split(' ')[0]}" y2="${at(a, 5.8).split(' ')[1]}" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`
172  const hour = `<line x1="10" y1="10" x2="10" y2="6.4" stroke="${color}" stroke-width="1.8" stroke-linecap="round"/>`
173  return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">`
174    + remaining
175    + `<circle cx="10" cy="10" r="8.6" fill="none" stroke="${color}" stroke-width="1.4"/>`
176    + ticks + hour + minute
177    + `<circle cx="10" cy="10" r="1.1" fill="${color}"/>`
178    + `</svg>`
179}
180
181// A fork over a very long transcript can take minutes; past this the handoff is given up.
182const FORK_TIMEOUT_MS = 5 * 60 * 1000
183let handoffRun = 0
184
185async function handoff($: EngineInterface, s: Strings): Promise<void> {
186  if ((await read($, phase)) !== 'idle') {
187    return
188  }
189
190  // Background agents would be cut off by /clear, and their results would miss the handoff.
191  const active = await $.agent.list().then(
192    list => list.filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting').length,
193    () => 0,
194  )
195
196  if (active > 0) {
197    $.ui.toast(s.toastAgentsActive(active))
198    return
199  }
200
201  const id = ++handoffRun
202
203  try {
204    await update($, phase, () => 'writing')
205    $.ui.toast(s.toastStarted)
206    // The fork takes no signal: past the limit the band frees itself and a late reply is dropped.
207    $.clock.after(FORK_TIMEOUT_MS, () => {
208      if (handoffRun === id) {
209        handoffRun++
210        $.ui.toast(s.toastTimedOut)
211        void update($, phase, () => 'idle')
212      }
213    })
214    const r = await $.model.fork({ prompt: s.handoffPrompt })
215
216    if (handoffRun !== id) {
217      return
218    }
219
220    if (!r.isAnswered) {
221      $.ui.toast(s.toastFailed(r.reason))
222      return
223    }
224
225    await $.store.set('last-handoff', r.text)
226
227    await update($, phase, () => 'clearing')
228    await $.command.run({ command: 'clear' })
229    await update($, fill, f => (f === null ? null : { window: f.window }))
230    await update($, cache, c => ({ ...c, lastAt: null, hit: null }))
231
232    await update($, phase, () => 'resuming')
233    await $.prompt.submit({ text: s.resumePrefix + r.text, asUser: true })
234  } catch (err) {
235    $.ui.toast(s.toastInterrupted(err instanceof Error ? err.message : String(err)))
236  } finally {
237    if (handoffRun === id) {
238      handoffRun++
239      await update($, phase, () => 'idle')
240    }
241  }
242}
243
244// The `language` option wins; on `auto`, Claude Code's own `language` setting, then the system locale; English otherwise.
245async function resolveLang($: EngineInterface, option: unknown): Promise<void> {
246  const hints: (string | undefined)[] = [typeof option === 'string' && option !== 'auto' ? option : undefined]
247
248  try {
249    const row = (await $.config.list()).find(r => r.key === 'language')
250    hints.push(typeof row?.value === 'string' ? row.value : undefined)
251  } catch {
252    // no config rows on this host
253  }
254
255  try {
256    hints.push(await $.env.get('LC_ALL'), await $.env.get('LANG'))
257  } catch {
258    // no environment on this host
259  }
260
261  await update($, lang, () => detectLang(hints))
262}
263
264// Language, clock and limits for this load of the module; the band runs it too when a reload skipped session.start.
265let hydrated = false
266let ticking = false
267
268async function hydrate($: EngineInterface, option: unknown): Promise<void> {
269  hydrated = true
270  await resolveLang($, option)
271  const t = await $.clock.now()
272  await update($, cache, c => ({ ...c, now: t }))
273
274  if (!ticking) {
275    ticking = true
276    $.clock.every(30_000, () => {
277      void $.clock.now().then(n => update($, cache, c => ({ ...c, now: n })))
278    })
279  }
280
281  const { context, rateLimits } = await $.session.usage()
282  await update($, fill, () => ({ tokens: context.tokens, window: context.window, percent: context.percent }))
283  await update($, limits, () => toLimits(rateLimits))
284}
285
286export const register: Register = (on, options) => {
287  on('session.start', async ($, e, next) => {
288    $.ui.status(undefined)
289    await hydrate($, options.language)
290
291    return next(e)
292  })
293
294  on('session.measure', async ($, e, next) => {
295    if (e.changed.includes('context')) {
296      const c = e.context
297      await update($, fill, () => ({ tokens: c.tokens, window: c.window, percent: c.percent }))
298    }
299
300    if (e.changed.includes('rateLimits')) {
301      await update($, limits, () => toLimits(e.rateLimits))
302    }
303
304    return next(e)
305  })
306
307  on('turn.step', async function* ($, e, next) {
308    const r = yield* next(e)
309
310    if (e.agentId === undefined) {
311      const t = await $.clock.now()
312      await update($, cache, c => ({ ...c, lastAt: t, now: t }))
313    }
314
315    return r
316  })
317
318  on('turn.complete', async ($, e, next) => {
319    const r = await next(e)
320    const u = r.usage
321
322    if (u !== undefined) {
323      const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
324
325      if (total > 0) {
326        await update($, cache, c => ({ ...c, hit: Math.round((u.cache_read_input_tokens / total) * 100) }))
327      }
328    }
329
330    return r
331  })
332
333  on('prompt.submit', async ($, e, next) => {
334    if (e.origin.kind !== 'plugin' && (await read($, phase)) !== 'idle') {
335      return { drop: STRINGS[await read($, lang)].dropDuringHandoff }
336    }
337
338    return next(e)
339  }).catch(($, e, next) => next(e))
340
341  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
342    if (!hydrated) {
343      void hydrate($, options.language).catch(() => undefined)
344    }
345
346    const f = await read($, fill)
347
348    if (e.props.hasSurvey || f === null) {
349      return next(e)
350    }
351
352    const s = STRINGS[await read($, lang)]
353    const busy = await read($, phase)
354    const kvInfo = await read($, cache)
355    const kv = cacheLabel(kvInfo, s)
356    const lim = await read($, limits)
357    const sess = lim.session === undefined ? undefined : { ...lim.session, ...pace(lim.session, kvInfo.now, SESSION_MS) }
358    const limitLine: LimitLine | undefined = sess === undefined || lim.session === undefined
359      ? undefined
360      : { percent: sess.percent, tone: sess.tone, elapsed: elapsed(lim.session, kvInfo.now, SESSION_MS) }
361    const percent = Math.min(100, Math.max(0, f.percent ?? 0))
362    const t = tone(percent)
363    const onPress = () => {
364      if (e.props.isWorking) {
365        $.ui.toast(s.toastWaitTurn)
366        return
367      }
368      void handoff($, s)
369    }
370
371    const onPing = () => {
372      if (e.props.isWorking || busy !== 'idle') {
373        $.ui.toast(s.toastAlreadyWorking)
374        return
375      }
376      void $.prompt.submit({ text: s.pingPrompt })
377    }
378
379    if (busy !== 'idle') {
380      if (e.surface === 'desktop') {
381        const { Box, Text, Svg } = $.ui.resolve(e)
382
383        return (
384          <Box flexDirection="row" alignItems="center" gap={2} paddingX={1}>
385            <Box flexShrink={0}>
386              <Svg key="spinner" source={SPINNER_SVG} alt={s.busyAlt} width={16} height={16} isInteractive />
387            </Box>
388            <Box flexShrink={0}>
389              <Text color="warning" bold wrap="truncate">{s.phase[busy]}</Text>
390            </Box>
391            <Box flexShrink={1} minWidth={0}>
392              <Text dimColor wrap="truncate">{s.busyHint}</Text>
393            </Box>
394          </Box>
395        )
396      }
397
398      const { Box, Text } = $.ui.resolve(e)
399
400      return (
401        <Box flexDirection="row" gap={1}>
402          <Text color="warning" bold>⏳ {s.phase[busy]}</Text>
403          <Text dimColor>{s.busyHint}</Text>
404        </Box>
405      )
406    }
407
408    if (e.surface === 'desktop') {
409      const { Box, Text, Svg, Button } = $.ui.resolve(e)
410
411      return (
412        <Box flexDirection="row" alignItems="center" gap={2} paddingX={1}>
413          <Box flexShrink={0}>
414            <Text color={t} wrap="truncate">{f.percent === undefined ? '—' : `${percent}%`}</Text>
415          </Box>
416          <Box key="ctx" flexGrow={1} flexShrink={1} minWidth={0} flexDirection="row" alignItems="center" gap={2}>
417            <Box flexGrow={1} flexShrink={1} minWidth={0}>
418              <Svg key="bar" source={barSvg(percent, limitLine)} alt={s.contextAlt(percent)} height={limitLine === undefined ? 6 : 10} />
419            </Box>
420            {lim.session === undefined && lim.week === undefined ? null : (
421              <Box display="none" hover={{ display: 'flex' }} flexShrink={0}>
422                <Text dimColor wrap="truncate">{limitsTip(lim, kvInfo.now, s)}</Text>
423              </Box>
424            )}
425          </Box>
426          <Box flexShrink={0}>
427            <Text dimColor wrap="truncate">{f.percent === undefined ? s.of(kilo(f.window)) : `${kilo(f.tokens ?? 0)}/${kilo(f.window)}`}</Text>
428          </Box>
429          <Box key="kv" flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
430            <Box display="none" hover={{ display: 'flex' }} flexDirection="row" alignItems="center" gap={1}>
431              <Text color={kv.tone} wrap="truncate">{cacheShort(kvInfo, s)}</Text>
432              <Button key="kv-ping" label={s.refresh} plain dimColor onPress={onPing} />
433            </Box>
434            <Svg key="cache" source={cacheClockSvg(kvInfo)} alt={cacheTip(kvInfo, s)} width={20} height={20} />
435          </Box>
436          <Box flexShrink={0}>
437            <Button key="handoff" label={s.phase[busy]} onPress={onPress} />
438          </Box>
439        </Box>
440      )
441    }
442
443    const { Box, Text, Button } = $.ui.resolve(e)
444
445    return (
446      <Box flexDirection="row" alignItems="center" gap={1}>
447        <Box flexGrow={1} backgroundColor="subtle">
448          <Box width={`${Math.max(percent, 1)}%`} backgroundColor={t}>
449            <Text backgroundColor={t}> </Text>
450          </Box>
451          <Text> </Text>
452        </Box>
453        <Text dimColor={f.percent === undefined}>{label(f, s)}</Text>
454        {sess === undefined ? null : <Text dimColor>{`5h ${Math.round(sess.percent)}%`}</Text>}
455        <Text key="cache" color={kv.tone}>{kv.text}</Text>
456        <Button key="kv-ping" label="↻" onPress={onPing} />
457        <Button key="handoff" label={s.phase[busy]} onPress={onPress} />
458      </Box>
459    )
460  })
461}
462
hooks/i18n.ts 147 lines
1import type { HandoffPhase, Lang } from '../types'
2
3export type Strings = {
4  phase: Record<HandoffPhase, string>
5  busyHint: string
6  busyAlt: string
7  contextAlt: (percent: number) => string
8  of: (window: string) => string
9  days: string
10  reset: string
11  week: string
12  cacheNone: string
13  cacheExpired: string
14  cacheShortNone: string
15  cacheShortExpired: (min: number) => string
16  cacheShortLeft: (min: number) => string
17  cacheTipNone: string
18  cacheTipExpired: (min: number) => string
19  cacheTipLeft: (min: number) => string
20  cacheTipTtl: string
21  cacheTipHit: (hit: number) => string
22  refresh: string
23  toastStarted: string
24  toastFailed: (reason: string) => string
25  toastInterrupted: (message: string) => string
26  toastWaitTurn: string
27  toastAlreadyWorking: string
28  toastAgentsActive: (n: number) => string
29  toastTimedOut: string
30  dropDuringHandoff: string
31  handoffPrompt: string
32  resumePrefix: string
33  pingPrompt: string
34}
35
36export const EN: Strings = {
37  phase: {
38    idle: 'Handoff',
39    writing: 'Handoff 1/3 · writing the document…',
40    clearing: 'Handoff 2/3 · clearing the context…',
41    resuming: 'Handoff 3/3 · resuming from the document…',
42  },
43  busyHint: 'don’t type in the prompt until it resumes',
44  busyAlt: 'In progress',
45  contextAlt: p => `Context at ${p}%`,
46  of: w => `of ${w}`,
47  days: 'd',
48  reset: 'reset',
49  week: '7d',
50  cacheNone: '○ cache —',
51  cacheExpired: '○ cache expired',
52  cacheShortNone: 'cache: no reply yet',
53  cacheShortExpired: m => `cache expired ${m}m ago`,
54  cacheShortLeft: m => `cache: expires in ${m}m`,
55  cacheTipNone: 'KV cache: no reply yet in this context',
56  cacheTipExpired: m => `KV cache expired ${m} min ago`,
57  cacheTipLeft: m => `KV cache: expires in ${m} min`,
58  cacheTipTtl: '(1h TTL from the last reply)',
59  cacheTipHit: h => `Last turn read from cache: ${h}%`,
60  refresh: '↻ refresh',
61  toastStarted: 'Handoff started: it takes a few seconds, don’t type in the prompt.',
62  toastFailed: r => `Handoff failed (${r}): context left as it was.`,
63  toastInterrupted: m => `Handoff interrupted: ${m}`,
64  toastWaitTurn: 'Wait for the turn to finish, then press Handoff.',
65  toastAlreadyWorking: 'Claude is already working: the cache refreshes by itself.',
66  toastAgentsActive: n => `${n === 1 ? '1 agent is' : `${n} agents are`} still running: wait for ${n === 1 ? 'it' : 'them'} to finish, then press Handoff.`,
67  toastTimedOut: 'Handoff gave no answer within 5 minutes: context left as it was, you can press it again.',
68  dropDuringHandoff: 'Handoff in progress: the prompt was not sent, /clear would wipe it. Send it again once the session resumes.',
69  handoffPrompt: `Write a handoff document to continue this work in a new conversation that will see none of this one.
70Include: goal; current state; decisions made and why; relevant files, paths, hosts and commands; what is left to do, as concrete steps; pitfalls and things already tried that don't work.
71Reply with the document only, in markdown, in the language of this conversation, with no preamble.`,
72  resumePrefix: `Resuming from a handoff of the previous session: the context was cleared. Read it and continue where we left off. Before acting, sum up in two lines where we are.
73
74`,
75  pingPrompt: 'Ping to keep the prompt cache warm: reply only “ok”, without using tools.',
76}
77
78export const IT: Strings = {
79  phase: {
80    idle: 'Handoff',
81    writing: 'Handoff 1/3 · scrivo il documento…',
82    clearing: 'Handoff 2/3 · pulisco il contesto…',
83    resuming: 'Handoff 3/3 · riparto dal documento…',
84  },
85  busyHint: 'non scrivere nel prompt finché non riparte',
86  busyAlt: 'In corso',
87  contextAlt: p => `Contesto al ${p}%`,
88  of: w => `di ${w}`,
89  days: 'g',
90  reset: 'reset',
91  week: '7g',
92  cacheNone: '○ cache —',
93  cacheExpired: '○ cache scaduta',
94  cacheShortNone: 'cache: nessuna risposta',
95  cacheShortExpired: m => `cache scaduta da ${m}m`,
96  cacheShortLeft: m => `cache: scade tra ${m}m`,
97  cacheTipNone: 'Cache KV: nessuna risposta ancora in questo contesto',
98  cacheTipExpired: m => `Cache KV scaduta da ${m} min`,
99  cacheTipLeft: m => `Cache KV: scade tra ${m} min`,
100  cacheTipTtl: '(TTL 1h dall’ultima risposta)',
101  cacheTipHit: h => `Ultimo turno letto dalla cache: ${h}%`,
102  refresh: '↻ rinfresca',
103  toastStarted: 'Handoff avviato: ci vuole qualche secondo, non scrivere nel prompt.',
104  toastFailed: r => `Handoff non riuscito (${r}): contesto lasciato com’è.`,
105  toastInterrupted: m => `Handoff interrotto: ${m}`,
106  toastWaitTurn: 'Aspetta che finisca il turno, poi premi Handoff.',
107  toastAlreadyWorking: 'Claude sta già lavorando: la cache si rinfresca da sola.',
108  toastAgentsActive: n => `${n === 1 ? '1 agente è' : `${n} agenti sono`} ancora attiv${n === 1 ? 'o' : 'i'}: aspetta che finisca${n === 1 ? '' : 'no'}, poi premi Handoff.`,
109  toastTimedOut: 'Handoff senza risposta da 5 minuti: contesto lasciato com’è, puoi premerlo di nuovo.',
110  dropDuringHandoff: 'Handoff in corso: il prompt non è stato inviato, il /clear lo cancellerebbe. Rimandalo quando la sessione riparte.',
111  handoffPrompt: `Scrivi un documento di handoff per continuare questo lavoro in una conversazione nuova, che non vedrà nulla di questa.
112Includi: obiettivo; stato attuale; decisioni prese e perché; file, percorsi, host e comandi rilevanti; cosa resta da fare, come passi concreti; insidie e cose già provate che non funzionano.
113Rispondi solo con il documento, in markdown e in italiano, senza preamboli.`,
114  resumePrefix: `Riprendo da un handoff della sessione precedente: il contesto è stato azzerato. Leggilo e continua da dove eravamo. Prima di agire, riassumi in due righe a che punto siamo.
115
116`,
117  pingPrompt: 'Ping per tenere calda la prompt cache: rispondi solo «ok», senza usare strumenti.',
118}
119
120export const STRINGS: Record<Lang, Strings> = { en: EN, it: IT }
121
122// Maps a free-form language hint ("Italiano", "italian", "it_IT.UTF-8", "en-US") to a supported language.
123export function toLang(hint: string | undefined): Lang | undefined {
124  const h = hint?.trim().toLowerCase()
125  if (h === undefined || h === '' || h === 'c' || h === 'posix') {
126    return undefined
127  }
128  if (h.startsWith('it') || h.startsWith('ital')) {
129    return 'it'
130  }
131  if (h.startsWith('en') || h.startsWith('ingl')) {
132    return 'en'
133  }
134  return undefined
135}
136
137// The first hint that names a supported language wins; English otherwise.
138export function detectLang(hints: readonly (string | undefined)[]): Lang {
139  for (const hint of hints) {
140    const l = toLang(hint)
141    if (l !== undefined) {
142      return l
143    }
144  }
145  return 'en'
146}
147
types/index.d.ts 18 lines
1export type ContextFill = { tokens?: number; window: number; percent?: number }
2
3export type Lang = 'en' | 'it'
4
5export type HandoffPhase = 'idle' | 'writing' | 'clearing' | 'resuming'
6
7export type CacheInfo = { lastAt: number | null; hit: number | null; now: number }
8
9export type Limit = { percent: number; resetsAt?: string }
10
11export type Limits = { session?: Limit; week?: Limit }
12
13declare module 'claude-code' {
14  interface PluginState {
15    'context-meter': { fill: ContextFill | null; phase: HandoffPhase; cache: CacheInfo; limits: Limits; lang: Lang }
16  }
17}
18