SLOPSHOPPER

gauge

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

newbandspinnercommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gauge
› 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 › /gauge ⎿ gauge: Контекст: 49% (97k з 200k) ⎿ gauge: Ліміт «5 год»: 31% ⎿ gauge: Кеш підказок: ще 59 хв ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ▰▰▰▰▰▱▱▱▱▱ 49% · 5 год 31% · кеш 60 хв

Draws

Prompt hint
⟨Claude Code's own drawing⟩ ▰▰▰▰▰▱▱▱▱▱ 49% · 5 год 31% · кеш 60 хв
README

Моди для Claude Code

Колекція модів для 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.


gauge — шкала

Один рядок під полем вводу (або над ним — налаштування position):

▰▰▰▰▱▱▱▱▱▱ 40% · ≈5 ходів до 90% · 5 год 42% (до 16:40) · тиждень 18% (до 18.10) · кеш 60 хв
  • Контекст кольором: зелений, з 70% — жовтий, з 90% — червоний (межі налаштовуються). Одне сповіщення при переході в червону зону.
  • Прогноз — скільки ходів до червоної зони за середнім приростом останніх ходів.
  • Ліміти 5 год і тижня з часом скидання (приглушено, як прогноз; з 70% — жовтим, з 90% — червоним); ліміт, що вже скинувся, зникає сам.
  • Кеш підказок — скільки ще живе (тривалість налаштовується: 1 год або 5 хв); наприкінці жовтіє, вистиглий — так і каже.
  • Вузьке вікно — коротший рядок; рядок ніколи не переноситься.
  • /gauge — з чого складається контекст, ліміти, кеш і прогноз.

Налаштування: warnAt (жовтий з, %), alertAt (червоний з, %), cacheTtl (1h або 5m), position (below — під полем вводу, above — над ним).

relay — естафета

  1. Коли контекст заповнено на 80% (поріг налаштовується), над полем вводу з'являється пропозиція з кнопками «Передати» і «Пізніше». Поки Claude працює, вона не заважає; «Пізніше» відкладає її на +5%.
  2. «Передати» (або /relay будь-коли) — Claude пише документ передачі зі сталою структурою: Мета · Зроблено · Рішення (і чому) · Пастки · Далі · Стан. Мод сам додає факти з git: незакомічені файли й останні коміти.
  3. Мод перевіряє, що документ справді записано й він не порожній, і лише тоді очищує сесію. Немає документа чи хід перервано — сесія лишається як є. Передачу можна скасувати кнопкою або /relay скасувати.
  4. Чисте вікно спершу в 3–5 рядках каже, що зрозуміло з документа, — і продовжує роботу. Перед видаленням, заливкою чи push питає.

/relay список — документи передачі цього проєкту. Вони лежать у ~/.claude/relay/<тека проєкту>/.

Налаштування: threshold (з якого заповнення пропонувати передачу, 50–95%).


Статус

Моди ранні (0.x). Поведінку кожного покрито тестами й незалежним рев'ю коду; дещо (наприклад, очищення сесії з мода в relay) варто перевірити у своєму середовищі. Перевірити самому:

claude plugin validate plugins/<мод>
claude plugin test plugins/<мод>

Ліцензія

MIT — можна вільно використовувати, змінювати й поширювати, зберігаючи вказівку авторства.

Source 2 files
hooks/register.tsx 368 lines
1import { 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}
368
types/index.d.ts 28 lines
1// Ліміт використання: яке вікно, скільки використано, коли скидається (мс; 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