Шкала: заповнення контексту з кольором і прогнозом «скільки ходів до межі», ліміти 5 год і тижня з часом скидання, скільки ще живе кеш підказок; /gauge — з…

Колекція модів для Claude Code з повністю українським інтерфейсом. Працюють і в терміналі, і в застосунку Claude. Нічого не надсилають в інтернет.
| Мод | Що робить | Версія |
|---|---|---|
| gauge | Шкала контексту, лімітів і кешу підказок в одному рядку під полем вводу | 0.2.2 |
| relay | Передача роботи в чисте вікно, коли контекст заповнюється | 0.1.1 |
/plugin marketplace add Sofilkanych/claude-code-mods
/plugin install gauge@sofilkanych
/plugin install relay@sofilkanych
Після встановлення відкрийте нову сесію. Оновлення всіх модів: claude plugin marketplace update sofilkanych. Налаштування мода: /plugin configure <мод>@sofilkanych.
Один рядок під полем вводу (або над ним — налаштування position):
▰▰▰▰▱▱▱▱▱▱ 40% · ≈5 ходів до 90% · 5 год 42% (до 16:40) · тиждень 18% (до 18.10) · кеш 60 хв
/gauge — з чого складається контекст, ліміти, кеш і прогноз.Налаштування: warnAt (жовтий з, %), alertAt (червоний з, %), cacheTtl (1h або 5m), position (below — під полем вводу, above — над ним).
/relay будь-коли) — Claude пише документ передачі зі сталою структурою: Мета · Зроблено · Рішення (і чому) · Пастки · Далі · Стан. Мод сам додає факти з git: незакомічені файли й останні коміти./relay скасувати./relay список — документи передачі цього проєкту. Вони лежать у ~/.claude/relay/<тека проєкту>/.
Налаштування: threshold (з якого заповнення пропонувати передачу, 50–95%).
Моди ранні (0.x). Поведінку кожного покрито тестами й незалежним рев'ю коду; дещо (наприклад, очищення сесії з мода в relay) варто перевірити у своєму середовищі. Перевірити самому:
claude plugin validate plugins/<мод>
claude plugin test plugins/<мод>
MIT — можна вільно використовувати, змінювати й поширювати, зберігаючи вказівку авторства.
hooks/register.tsx 368 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Limit, Reading } from '../types'
5
6// Шкала: один рядок над полем вводу — заповнення контексту (з кольором і прогнозом), ліміти 5 год і тижня,
7// скільки ще живе кеш підказок. Дані — ті самі, що в рядку стану рушія; мод нічого не надсилає.
8const EMPTY: Reading = { percent: null, tokens: null, window: null, limits: [], answeredAt: 0 }
9const reading = atom({ plugin: 'gauge', key: 'reading' } as const, EMPTY)
10const history = atom({ plugin: 'gauge', key: 'history' } as const, [] as number[])
11const isAlerted = atom({ plugin: 'gauge', key: 'isAlerted' } as const, false)
12const now = atom({ plugin: 'gauge', key: 'now' } as const, 0)
13
14const TICK_MS = 15_000
15// Скільки останніх ходів пам'ятаємо для прогнозу (дають 6 приростів)
16const HISTORY = 7
17const BAR = 10
18const DAY = 24 * 60 * 60_000
19
20// Ліміт з 70% — жовтий, з 90% — червоний; нижче — приглушений, як прогноз
21const LIMIT_WARN = 70
22const LIMIT_ALERT = 90
23
24// position: де рядок — під полем вводу (рядок підказок) чи над ним
25type Config = { warnAt: number; alertAt: number; ttlMs: number; position: 'below' | 'above' }
26
27export function config(options: any): Config {
28 const num = (v: unknown, d: number, lo: number, hi: number) => {
29 const n = Number(v)
30 return Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.round(n))) : d
31 }
32 const warnAt = num(options?.warnAt, 70, 30, 95)
33 const alertAt = Math.max(warnAt + 1, num(options?.alertAt, 90, 50, 99))
34 return { warnAt, alertAt, ttlMs: options?.cacheTtl === '5m' ? 5 * 60_000 : 60 * 60_000, position: options?.position === 'above' ? 'above' : 'below' }
35}
36
37// ——— розрахунки ————————————————————————————————————————————————————————
38
39// Скільки ходів до червоної зони за середнім додатним приростом останніх ходів; null — прогнозу немає
40export function turnsLeft(hist: number[], percent: number, alertAt: number): number | null {
41 if (percent >= alertAt || hist.length < 2) return null
42 const steps: number[] = []
43 for (let i = 1; i < hist.length; i++) {
44 const d = hist[i]! - hist[i - 1]!
45 // Спад — це /compact чи /clear, нуль — хід без нових даних: у середнє не беремо
46 if (d > 0) steps.push(d)
47 }
48 if (!steps.length) return null
49 const avg = steps.reduce((a, b) => a + b, 0) / steps.length
50 if (avg < 0.5) return null
51 return Math.max(1, Math.ceil((alertAt - percent) / avg))
52}
53
54export function bar(percent: number): string {
55 const full = Math.max(0, Math.min(BAR, Math.round(percent / (100 / BAR))))
56 return '▰'.repeat(full) + '▱'.repeat(BAR - full)
57}
58
59function plural(n: number, one: string, few: string, many: string): string {
60 const a = n % 10, b = n % 100
61 if (a === 1 && b !== 11) return one
62 if (a >= 2 && a <= 4 && (b < 12 || b > 14)) return few
63 return many
64}
65
66const pct = (n: number) => `${String(n).replace('.', ',')}%`
67const kTokens = (n: number) => (n < 1000 ? '<1k' : `${Math.round(n / 1000)}k`)
68
69const LIMIT_NAMES: Record<string, string> = { five_hour: '5 год', seven_day: 'тиждень', spend_limit: 'бюджет' }
70const limitName = (kind: string) => LIMIT_NAMES[kind] ?? `ліміт ${kind.slice(0, 20)}`
71
72// Назви категорій /context рушій дає англійською — перекладаємо знайомі
73const CATEGORY_NAMES: Record<string, string> = {
74 'system prompt': 'Системна підказка',
75 'system tools': 'Системні інструменти',
76 'mcp tools': 'Інструменти MCP',
77 'custom agents': 'Агенти',
78 'memory files': 'Файли пам\'яті',
79 skills: 'Навички',
80 messages: 'Повідомлення',
81 'free space': 'Вільно',
82 'autocompact buffer': 'Запас на стиснення',
83}
84const categoryName = (name: string) => CATEGORY_NAMES[name.trim().toLowerCase()] ?? name.slice(0, 60)
85
86// Коли скидається ліміт: протягом доби — годиною, далі — датою
87export function resetText(resetsAt: number, nowMs: number): string {
88 if (!resetsAt) return ''
89 const d = new Date(resetsAt)
90 const p = (n: number) => String(n).padStart(2, '0')
91 return resetsAt - nowMs < DAY ? `до ${p(d.getHours())}:${p(d.getMinutes())}` : `до ${p(d.getDate())}.${p(d.getMonth() + 1)}`
92}
93
94// Ліміт, час скидання якого вже минув, — застарілий: його не показуємо, доки не прийде свіжий
95const isCurrent = (l: Limit, nowMs: number) => !l.resetsAt || l.resetsAt > nowMs
96
97// Скільки ще живе кеш: «41 хв», «2:05»; null — вистиг або відповіді ще не було
98export function cacheLeft(answeredAt: number, nowMs: number, ttlMs: number): { text: string; isLow: boolean } | null {
99 if (!answeredAt) return null
100 const left = answeredAt + ttlMs - nowMs
101 if (left <= 0) return null
102 const isLow = left < ttlMs * 0.2
103 if (left >= 10 * 60_000) return { text: `${Math.floor(left / 60_000)} хв`, isLow }
104 const s = Math.floor(left / 1000)
105 return { text: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`, isLow }
106}
107
108function levelColor(percent: number, warnAt: number, alertAt: number): string | undefined {
109 return percent >= alertAt ? 'red' : percent >= warnAt ? 'yellow' : undefined
110}
111
112// Частини рядка від найважливішої. detail: 3 — усе, 2 — без часу скидання тижня, 1 — без прогнозу й часів, 0 — без шкали
113type Part = { key: string; text: string; color?: string; dim?: boolean }
114export function parts(r: Reading, hist: number[], nowMs: number, c: Config, detail: number): Part[] {
115 const out: Part[] = []
116 if (r.percent !== null) {
117 const color = levelColor(r.percent, c.warnAt, c.alertAt) ?? 'green'
118 out.push({ key: 'ctx', text: detail >= 1 ? `${bar(r.percent)} ${pct(r.percent)}` : pct(r.percent), color })
119 const left = turnsLeft(hist, r.percent, c.alertAt)
120 if (left && detail >= 2) out.push({ key: 'fc', text: `≈${left} ${plural(left, 'хід', 'ходи', 'ходів')} до ${c.alertAt}%`, dim: true })
121 }
122 for (const l of r.limits.filter(x => isCurrent(x, nowMs))) {
123 const showReset = detail >= 3 || (detail >= 2 && l.kind === 'five_hour')
124 const reset = showReset ? resetText(l.resetsAt, nowMs) : ''
125 out.push({ key: `l-${l.kind}`, text: `${limitName(l.kind)} ${pct(l.percent)}${reset ? ` (${reset})` : ''}`, ...(l.percent >= LIMIT_WARN ? { color: levelColor(l.percent, LIMIT_WARN, LIMIT_ALERT) } : { dim: true }) })
126 }
127 const cache = cacheLeft(r.answeredAt, nowMs, c.ttlMs)
128 if (cache) out.push({ key: 'cache', text: `кеш ${cache.text}`, color: cache.isLow ? 'yellow' : undefined, dim: !cache.isLow })
129 else if (r.answeredAt) out.push({ key: 'cache', text: 'кеш вистиг', dim: true })
130 return out
131}
132
133const width = (ps: Part[]) => ps.reduce((n, p) => n + [...p.text].length, 0) + Math.max(0, ps.length - 1) * 3
134
135// Найдокладніший варіант, що вміщується в один рядок
136export function fit(r: Reading, hist: number[], nowMs: number, c: Config, columns: number): Part[] {
137 for (let detail = 3; detail > 0; detail--) {
138 const ps = parts(r, hist, nowMs, c, detail)
139 if (width(ps) <= columns) return ps
140 }
141 // Зовсім вузько — відкидаємо хвіст (кеш лишаємо до останнього: він змінюється найчастіше)
142 const ps = parts(r, hist, nowMs, c, 0)
143 while (ps.length > 1 && width(ps) > columns) {
144 const i = ps.length >= 3 && ps[ps.length - 1]!.key === 'cache' ? ps.length - 2 : ps.length - 1
145 ps.splice(i, 1)
146 }
147 return ps
148}
149
150// ——— читання ————————————————————————————————————————————————————————————
151
152function toLimits(raw: any[]): Limit[] {
153 return (raw ?? [])
154 .filter(l => l && typeof l.percentUsed === 'number')
155 .map(l => ({ kind: String(l.kind), percent: l.percentUsed, resetsAt: l.resetsAt ? Date.parse(l.resetsAt) || 0 : 0 }))
156}
157
158// Зняти показники; answered — Claude справді відповів (є usage): кеш щойно оновився, хід іде в історію
159async function take($: any, c: Config, answered: boolean): Promise<void> {
160 const u = await $.session.usage()
161 const t: number = await $.clock.now()
162 const percent = typeof u?.context?.percent === 'number' ? u.context.percent : null
163 const prev = await read($, reading)
164 const next: Reading = {
165 percent,
166 tokens: typeof u?.context?.tokens === 'number' ? u.context.tokens : null,
167 window: typeof u?.context?.window === 'number' ? u.context.window : null,
168 limits: toLimits(u?.rateLimits),
169 answeredAt: answered ? t : prev.answeredAt,
170 }
171 await update($, reading, () => next)
172 await update($, now, () => t)
173 if (!answered || percent === null) return
174 await update($, history, h => [...h, percent].slice(-HISTORY))
175 // Червона зона — одне сповіщення; знову — лише після спаду нижче жовтої
176 if (percent < c.warnAt) await update($, isAlerted, () => false)
177 else if (percent >= c.alertAt && !(await read($, isAlerted))) {
178 await update($, isAlerted, () => true)
179 $.ui.toast(`Контекст заповнено на ${percent}% — час завершувати або передавати роботу в чисте вікно.`, { timeoutMs: 10000 })
180 }
181}
182
183// Чи хтось бачить сесію: термінал або застосунок. Десктоп запускає сесію як SDK-хост — його впізнаємо за ознакою
184// запуску (а вкладений claude -p, що її успадкував, має ще й позначку дочірньої сесії)
185async function isLive($: any): Promise<boolean> {
186 if ((await $.session.surfaces()).length > 0) return true
187 return (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop' && !(await $.env.get('CLAUDE_CODE_CHILD_SESSION'))
188}
189
190let ticker: { cancel: () => void } | undefined
191let isArming = false
192// Що таймер кешу показав востаннє: перемальовуємо смугу лише коли текст змінився
193let lastCacheText = ''
194
195function stopTicker(): void {
196 ticker?.cancel()
197 ticker = undefined
198}
199
200// Один такт: кеш вистиг — зупиняємось до наступної відповіді; текст змінився — оновлюємо смугу
201async function tick($: any, c: Config): Promise<void> {
202 const t: number = await $.clock.now()
203 const r = await read($, reading)
204 const cache = cacheLeft(r.answeredAt, t, c.ttlMs)
205 // Що видно на смузі: якщо будь-яка частина (кеш, колір, час скидання) змінилась — перемальовуємо
206 const text = parts(r, await read($, history), t, c, 3).map(p => `${p.text}~${p.color ?? ''}`).join('|')
207 if (text !== lastCacheText) {
208 lastCacheText = text
209 await update($, now, () => t)
210 }
211 if (!cache && !r.limits.some(l => l.resetsAt > t)) stopTicker()
212}
213
214// Годинник для таймера кешу й лімітів: лише коли є кому дивитись і є що відлічувати
215async function ensureTicker($: any, c: Config): Promise<void> {
216 if (ticker || isArming) return
217 isArming = true
218 try {
219 if (!(await isLive($))) return
220 const r = await read($, reading)
221 if (!r.answeredAt && !r.limits.length) return
222 ticker = $.clock.every(TICK_MS, () => void tick($, c).catch(() => {}))
223 } finally {
224 isArming = false
225 }
226}
227
228// /gauge — з чого складається контекст, ліміти, кеш
229async function details($: any, c: Config): Promise<string> {
230 const u = await $.session.usage({ breakdown: 'summary' })
231 const t: number = await $.clock.now()
232 const r = await read($, reading)
233 const lines: string[] = []
234 const ctx = u?.context
235 const win = ctx?.window || 0
236 if (typeof ctx?.percent === 'number') lines.push(`Контекст: ${pct(ctx.percent)} (${kTokens(ctx.tokens ?? 0)} з ${kTokens(win)})`)
237 else lines.push('Контекст: ще не було жодної відповіді.')
238 const cats = (ctx?.breakdown?.categories ?? []).filter((x: any) => x.kind === 'used' && x.tokens > 0).sort((a: any, b: any) => b.tokens - a.tokens)
239 if (cats.length) {
240 lines.push('З чого складається (частка від усього вікна):')
241 for (const x of cats) lines.push(`- ${categoryName(String(x.name))}: ${kTokens(x.tokens)}${win ? ` (${Math.round((x.tokens / win) * 100)}%)` : ''}`)
242 }
243 for (const l of toLimits(u?.rateLimits)) {
244 if (!isCurrent(l, t)) continue
245 lines.push(`Ліміт «${limitName(l.kind)}»: ${pct(l.percent)}${l.resetsAt ? ` (скидається ${resetText(l.resetsAt, t).replace(/^до /, '')})` : ''}`)
246 }
247 const cache = cacheLeft(r.answeredAt, t, c.ttlMs)
248 lines.push(cache ? `Кеш підказок: ще ${cache.text}` : r.answeredAt ? 'Кеш підказок: вистиг — наступний запит буде дорожчим.' : 'Кеш підказок: ще не було відповіді.')
249 const left = r.percent !== null ? turnsLeft(await read($, history), r.percent, c.alertAt) : null
250 if (left) lines.push(`Прогноз: приблизно ${left} ${plural(left, 'хід', 'ходи', 'ходів')} до ${c.alertAt}%.`)
251 return lines.join('\n')
252}
253
254// Ширина рядка: рядок під полем вводу її не знає — беремо з останнього малювання смуги над полем
255let columns = 120
256
257// Сам рядок шкали; null — показати нічого
258async function drawRow($: any, e: any, c: Config, cols: number): Promise<any> {
259 const r = await read($, reading)
260 const t = Math.max(await read($, now), r.answeredAt)
261 const ps = fit(r, await read($, history), t, c, Math.max(20, cols - 2))
262 if (!ps.length) return null
263 const { Box, Text } = $.ui.resolve(e)
264 const row: any[] = []
265 ps.forEach((p, i) => {
266 if (i) row.push(<Text key={`s${i}`} dimColor>·</Text>)
267 row.push(<Text key={p.key} color={p.color} dimColor={p.dim}>{p.text}</Text>)
268 })
269 return <Box key="gauge" gap={1}>{row}</Box>
270}
271
272// Нове вікно (/clear, /resume) — стара історія, кеш і сповіщення до нього не стосуються
273async function resetWindow($: any): Promise<void> {
274 await update($, reading, r => ({ ...r, percent: null, tokens: null, answeredAt: 0 }))
275 await update($, history, () => [])
276 await update($, isAlerted, () => false)
277 lastCacheText = ''
278}
279
280export const register: Register = (on, options) => {
281 const c = config(options)
282
283 on('session.start', async ($, e, next) => {
284 await $.command.register({ name: 'gauge', description: 'Шкала: з чого складається контекст, ліміти, кеш підказок', immediate: true })
285 void take($, c, false).then(() => ensureTicker($, c)).catch(() => {})
286 return next(e)
287 })
288
289 on('session.end', async ($, e, next) => {
290 const reason = (e as any).reason
291 if (reason === 'clear' || reason === 'resume') {
292 try {
293 await resetWindow($)
294 } catch {}
295 }
296 return next(e)
297 })
298
299 // Після стиснення старий відсоток неправдивий — до наступної відповіді шкалу не показуємо
300 on('session.compact', async ($, e, next) => {
301 const r = await next(e)
302 // Підготовка наперед, стиснення субагента чи пропущене стиснення основну розмову не змінили
303 if ((e as any).trigger === 'precompute' || (e as any).agentId || (r as any)?.skip !== undefined) return r
304 try {
305 await update($, reading, x => ({ ...x, percent: null, tokens: null }))
306 } catch {}
307 return r
308 })
309
310 // Новий хід — годинник міг зупинитись (кеш вистиг) або зникнути з перезавантаженням мода
311 on('turn.start', async ($, e, next) => {
312 void ensureTicker($, c).catch(() => {})
313 return next(e)
314 })
315
316 on('turn.complete', async ($, e, next) => {
317 const r = await next(e)
318 // Хід субагента не оновлює ні контекст основної розмови, ні її кеш
319 if ((e as any).agentId) return r
320 try {
321 // Без usage — перерваний хід чи помилка API: запиту до моделі не було, кеш не оновлювався
322 await take($, c, Boolean((e as any).usage))
323 await ensureTicker($, c)
324 } catch {}
325 return r
326 })
327
328 on('command.run', { command: 'gauge' }, async $ => {
329 void ensureTicker($, c).catch(() => {})
330 try {
331 return { text: await details($, c) }
332 } catch (err: any) {
333 return { text: `Не вдалося прочитати показники: ${String(err?.message ?? err).slice(0, 80)}` }
334 }
335 })
336
337 // Над полем вводу — лише коли так налаштовано; заодно запам'ятовуємо ширину для рядка під полем
338 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
339 const below = await next(e)
340 if (typeof e.props.bodyColumns === 'number') columns = e.props.bodyColumns
341 if (c.position !== 'above' || e.props.hasSurvey) return below
342 const row = await drawRow($, e, c, columns)
343 if (!row) return below
344 const { Box } = $.ui.resolve(e)
345 return (
346 <Box flexDirection="column">
347 {row}
348 {below}
349 </Box>
350 )
351 })
352
353 // Під полем вводу: рядок підказок рушія лишається, шкала — під ним
354 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
355 const engine = await next(e)
356 if (c.position !== 'below') return engine
357 const row = await drawRow($, e, c, columns)
358 if (!row) return engine
359 const { Box } = $.ui.resolve(e)
360 return (
361 <Box flexDirection="column">
362 {engine}
363 {row}
364 </Box>
365 )
366 })
367}
368types/index.d.ts 28 lines1// Ліміт використання: яке вікно, скільки використано, коли скидається (мс; 0 — невідомо)
2export type Limit = { kind: string; percent: number; resetsAt: number }
3
4// Останній відомий стан після відповіді Claude
5export type Reading = {
6 // Заповнення контексту, %; null — ще не було відповіді
7 percent: number | null
8 tokens: number | null
9 window: number | null
10 limits: Limit[]
11 // Коли прийшла остання відповідь основного Claude (мс); 0 — ще не було
12 answeredAt: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 gauge: {
18 reading: Reading
19 // Заповнення після кількох останніх ходів — для прогнозу
20 history: number[]
21 // Сповіщення про червону зону вже показано (знову — після спаду нижче жовтої)
22 isAlerted: boolean
23 // Поточний час для таймера кешу: оновлюється раз на 15 с, щоб смуга перемальовувалась
24 now: number
25 }
26 }
27}
28