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

A Claude Code mod that keeps the context window in sight. It draws one compact row above the prompt:
38% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 381k/1M 🕐 [Handoff]
───────────────────
5h 27% · reset 2h 39m · 7d 30%./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.
/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.
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.
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/.
MIT
hooks/register.tsx 462 lines1import { 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}
462hooks/i18n.ts 147 lines1import 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}
147types/index.d.ts 18 lines1export 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