SLOPSHOPPER

limit-watch

Usage-limit bar above the prompt: 5-hour and weekly limits with time to reset, resume after the limit resets, and a stay-awake toggle on macOS

bandcommandtoastpromptprocesstimer
v0.2.1MITupdated 2026-10-17setcodes/claude-code-mods/limit-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-watch
› 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 › /limit-watch ⎿ limit-watch: Bar hidden. Bring it back: /limit-watch ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

limit-watch

A mod for Claude Code. It shows your usage limits above the prompt and, if a limit stops your work, picks it back up after the limit resets.

🕓 5h 67% reset 3h 14m    📅 week 85% reset 15h 54m     ● [Resume after reset] [Zzz]  Hide

Install

You need Claude Code 2.1.295 or newer (claude --version; update with claude update). Then run this in your terminal:

claude plugin install limit-watch --marketplace setcodes/claude-code-mods

Open a new Claude Code session — in the terminal or in the desktop app's Code tab — and the bar appears above the prompt. Other ways to install, and how to update or remove the mod: see the repository README.

How to use it

Read your limits. The bar shows the 5-hour and weekly windows: how much is used and when each one resets. Numbers are grey below 50%, yellow from 50%, red from 80%. On a narrow window — say, with a side panel open — the bar keeps just the icons and percentages; if even that doesn't fit, the buttons move to a second row.

Let Claude continue after a limit. Click Resume after reset — the dot next to it turns green. From now on, if Claude stops in the middle of work because a limit ran out, the mod waits until the limit resets (plus one minute) and sends the chat a message with your last request and Claude's last reply, so Claude carries on where it stopped. While it waits, Resume now and Cancel replace the button; the reset times next to the limits show when it will continue.

What it will not do:

  • It does nothing if a limit was reached by an answer that finished — there is nothing to continue.
  • It cancels itself if the chat continues some other way, for example when you write a message. No duplicates.
  • If the weekly limit runs out and resets more than 6 hours later, it asks first: Resume in … or No thanks.
  • It stops after 3 automatic tries in a row that hit the limit again.
  • It only works while the app and this chat stay open.

Keep the Mac awake (macOS). Click Zzz to stop the Mac from falling asleep while this chat is open, so it is awake when the limit resets. Click it again to allow sleep. Closing the laptop lid still puts the Mac to sleep.

Hide the bar. Click Hide. To bring it back, type /limit-watch.

Commands

CommandWhat it does
/limit-watchHide or show the bar.
/limit-watch test 1mTry resuming without waiting for a real limit: in one minute the mod sends a real resume message to the chat. Use 30s, 5m, 2h for other delays.

Language

The bar uses your system language: English, Russian, Spanish, German, French, Portuguese, Chinese (simplified), Japanese or Ukrainian; anything else gets English. To pick a language yourself, add this to ~/.claude/settings.json (inside the outer { }) and start a new session:

"pluginConfigs": {
  "limit-watch@claude-code-mods": { "options": { "language": "en" } }
}

Use auto, en, ru, es, de, fr, pt, zh, ja or uk. If the file already has a "pluginConfigs" block, add only the limit-watch@claude-code-mods line inside it.

If something doesn't work

  • Unknown command: /limit-watch or no bar — the session was started before the mod was installed. Start a new one. Check that the mod is on with claude plugin list: limit-watch@claude-code-mods should show ✔ enabled.
  • Still nothing — check claude --version is 2.1.295 or newer.
  • No Zzz button — it exists only on macOS.

For developers

From the repository root:

claude plugin validate limit-watch
claude plugin test limit-watch

Translations were not checked by native speakers — corrections are welcome. Icons: Lucide (ISC).

Author: Egor Maximov (@setcodes). License: MIT (see the repository root).

Source 3 files
hooks/register.tsx 560 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, PluginOptions, ProcessSpawnChunk, ProcessSpawnResult, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Limit, Retry, Snapshot } from '../types'
5import { isLang, langFromLocale, RESUME_HEADS, STRINGS } from './i18n'
6import type { Lang, Strings } from './i18n'
7
8const EMPTY: Snapshot = { limits: [] }
9
10const snapshot = atom({ plugin: 'limit-watch', key: 'snapshotV6' } as const, EMPTY)
11const isHidden = atom({ plugin: 'limit-watch', key: 'isHidden' } as const, false)
12const IDLE: Retry = { isEnabled: false, at: null, reason: null, offer: null, attempts: 0, isInterrupted: false }
13const retry = atom({ plugin: 'limit-watch', key: 'retryV4' } as const, IDLE)
14// Время последней перерисовки: по нему считаем «через сколько» без вызова часов при отрисовке.
15const now = atom({ plugin: 'limit-watch', key: 'now' } as const, 0)
16// Язык полосы, определённый при старте сессии.
17const lang = atom({ plugin: 'limit-watch', key: 'lang' } as const, 'en')
18// Режим «не засыпать» (Zzz): включён ли он и есть ли вообще caffeinate (только macOS).
19const isAwake = atom({ plugin: 'limit-watch', key: 'isAwake' } as const, false)
20const isMac = atom({ plugin: 'limit-watch', key: 'isMac' } as const, false)
21
22// Запас после сброса окна, чтобы не попасть в границу.
23const RETRY_GRACE_MS = 60_000
24// Если сервер не сообщил время сброса, пробуем снова через этот интервал.
25const RETRY_FALLBACK_MS = 15 * 60_000
26// Дольше этого ждать сам не стану: за полдня и больше контекст устаревает. Дальний сброс — только по подтверждению.
27const AUTO_WAIT_MAX_MS = 6 * 3_600_000
28// Сколько раз подряд продолжать автоматически, если лимит после продолжения всё ещё исчерпан.
29// Счётчик обнуляется, как только ход завершается ответом.
30const MAX_AUTO_RESUMES = 3
31// Сколько символов запроса и ответа цитировать в сообщении о продолжении.
32const QUOTE_MAX_CHARS = 400
33
34function toLimit(r: SessionRateLimit): Limit {
35  const resetsAt = r.resetsAt ? Date.parse(r.resetsAt) : NaN
36  return { kind: r.kind, percent: r.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
37}
38
39function exhausted(limits: Limit[]): Limit | undefined {
40  return limits
41    .filter(l => l.percent >= 100)
42    .sort((a, b) => (b.resetsAt ?? 0) - (a.resetsAt ?? 0))[0]
43}
44
45async function strings($: EngineInterface): Promise<Strings> {
46  return STRINGS[await read($, lang)]
47}
48
49function labelOf(kind: string, s: Strings): string {
50  return s.limitLabels[kind] ?? kind
51}
52
53function formatIn(ms: number, s: Strings): string {
54  const { d, h: hu, m: mu } = s.units
55  const minutes = Math.max(1, Math.round(ms / 60_000))
56  const h = Math.floor(minutes / 60)
57  const m = minutes % 60
58  if (h >= 24) return `${Math.floor(h / 24)}${d} ${h % 24}${hu}`
59  return h > 0 ? `${h}${hu} ${m}${mu}` : `${m}${mu}`
60}
61
62// Язык: выбранный в настройках мода, иначе язык интерфейса ОС, иначе переменные локали, иначе английский.
63async function detectLang($: EngineInterface, options: PluginOptions): Promise<Lang> {
64  const chosen = options.language
65  if (isLang(chosen)) return chosen
66
67  // macOS: язык интерфейса системы. LANG там часто en_US даже при русской системе, поэтому он вторичен.
68  try {
69    const { exitCode, stdout } = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 3000 })
70    const first = exitCode === 0 ? /[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*/.exec(stdout.replace(/^\s*\(/, ''))?.[0] : undefined
71    const fromMac = langFromLocale(first)
72    if (fromMac) return fromMac
73  } catch {
74    // Не macOS: команды defaults нет.
75  }
76
77  // Linux и прочие: стандартные переменные локали по старшинству.
78  for (const getVar of [() => $.env.get('LC_ALL'), () => $.env.get('LC_MESSAGES'), () => $.env.get('LANG')]) {
79    try {
80      const fromEnv = langFromLocale(await getVar())
81      if (fromEnv) return fromEnv
82    } catch {
83      // Переменная недоступна: смотрим следующую.
84    }
85  }
86  return 'en'
87}
88
89type Tone = 'subtle' | 'warning' | 'error'
90
91// Цвет появляется, только когда есть повод: до 50% цифра серая, как остальной текст.
92function tone(percent: number): Tone {
93  return percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'subtle'
94}
95
96// Иконки Lucide (ISC). SVG рисуется картинкой и тему не наследует, поэтому цвет задан явно.
97const ICON_COLOR = '#9b9b9b'
98const ICONS: Record<string, string> = {
99  five_hour: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
100  seven_day: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
101  spend_limit: '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
102}
103
104// Подписи для alt (в словаре): с пустым alt десктоп молча не рисует картинку.
105function iconSvg(name: string): string {
106  return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</svg>`
107}
108
109// Значок режима «не засыпать»: «Zzz» — обычные буквы, они одинаково видны везде (эмодзи 💤 был бы цветным).
110const SLEEP_ICON = 'Zzz'
111
112// Как часто сверять запланированный момент с реальными часами. Таймер на часы ожидания
113// во время сна Mac может «стоять», а проверка по времени эпохи сработает сразу после пробуждения.
114const WALL_CHECK_MS = 30_000
115
116// Таймеры живут в окружении мода: перезагрузка их сбрасывает, а момент ретрая хранится в $.state.
117let timer: Timer | null = null
118let ticker: Timer | null = null
119
120function cancelTimer() {
121  timer?.cancel()
122  ticker?.cancel()
123  timer = null
124  ticker = null
125}
126
127// Запущенный `caffeinate -i`: пока он жив, macOS не уходит в сон от простоя.
128let caffeinate: HookStream<ProcessSpawnChunk, ProcessSpawnResult> | null = null
129
130function startAwake($: EngineInterface) {
131  if (caffeinate) return
132  const child = $.process.spawn({ argv: ['caffeinate', '-i'] })
133  caffeinate = child
134  // Цикл держит процесс живым: caffeinate ничего не пишет, поэтому цикл просто ждёт его конца.
135  void (async () => {
136    let hasFailed = false
137    try {
138      for await (const _piece of child) {
139        // Вывода нет.
140      }
141    } catch {
142      // Не запустился (не macOS, нет caffeinate) или упал.
143      hasFailed = true
144    }
145    // Остановили мы сами — stopAwake уже снял ссылку. Иначе процесс умер сам: выключаем режим.
146    if (caffeinate !== child) return
147    caffeinate = null
148    try {
149      await update($, isAwake, () => false)
150      if (hasFailed) $.ui.toast((await strings($)).toastAwakeFailed)
151    } catch {
152      // Мод выгружается вместе с процессом: записывать состояние уже некуда.
153    }
154  })()
155}
156
157function stopAwake() {
158  const child = caffeinate
159  caffeinate = null
160  void child?.return({ code: null, signal: null })
161}
162
163async function toggleAwake($: EngineInterface) {
164  const s = await strings($)
165  const on = await update($, isAwake, v => !v)
166  if (on) {
167    startAwake($)
168    $.ui.toast(s.toastAwakeOn)
169  } else {
170    stopAwake()
171    $.ui.toast(s.toastAwakeOff)
172  }
173}
174
175function quote(text: string): string {
176  const flat = text.replace(/\s+/g, ' ').trim()
177  return flat.length > QUOTE_MAX_CHARS ? `${flat.slice(0, QUOTE_MAX_CHARS)}…` : flat
178}
179
180// Собирает сообщение о продолжении: последний запрос человека и последнее, что успел написать агент.
181async function resumePrompt($: EngineInterface): Promise<string> {
182  const s = await strings($)
183  const messages = await $.session.messages()
184  if (!Array.isArray(messages)) return s.resumeHead
185
186  // Свои прошлые сообщения о продолжении (на любом языке) не цитируем.
187  const said = messages.filter(m => m.text.trim() !== '' && !RESUME_HEADS.some(head => m.text.includes(head)))
188  const asked = said.findLast(m => m.role === 'user')
189  const answered = said.findLast(m => m.role === 'assistant')
190
191  const lines = [s.resumeHead]
192  if (asked) lines.push(s.lastAsked(quote(asked.text)))
193  if (answered) lines.push(s.lastAnswered(quote(answered.text)))
194  return lines.join('\n')
195}
196
197// Лимиты обновляются только с ответами сервера, поэтому 100% может быть устаревшим.
198// Считаем окно всё ещё закрытым, только если его время сброса ещё не наступило.
199function stillClosed(limits: Limit[], clockNow: number): Limit | undefined {
200  return limits.filter(l => l.percent >= 100 && l.resetsAt !== null && l.resetsAt > clockNow).sort((a, b) => (b.resetsAt ?? 0) - (a.resetsAt ?? 0))[0]
201}
202
203// Идёт ли сейчас продолжение. После сна Mac точный таймер и проверка по часам срабатывают почти разом:
204// оба успели бы прочитать «запланировано» до того, как первый его снимет, и ушло бы два сообщения.
205// Флаг ставится синхронно, до первого await, поэтому второй вызов сразу выходит.
206let isFiring = false
207
208async function fire($: EngineInterface) {
209  if (isFiring) return
210  isFiring = true
211  try {
212    await fireOnce($)
213  } finally {
214    isFiring = false
215  }
216}
217
218async function fireOnce($: EngineInterface) {
219  cancelTimer()
220  const [r, snap, clockNow, s] = await Promise.all([read($, retry), read($, snapshot), $.clock.now(), strings($)])
221  if (r.at === null) return
222
223  const closed = r.reason === 'test' ? undefined : stillClosed(snap.limits, clockNow)
224  if (closed && closed.resetsAt !== null) {
225    const at = closed.resetsAt + RETRY_GRACE_MS
226    await update($, retry, x => ({ ...x, at, reason: closed.kind }))
227    arm($, at, clockNow)
228    $.ui.toast(s.toastStillClosed(labelOf(closed.kind, s), formatIn(at - clockNow, s)))
229    return
230  }
231
232  await update($, retry, x => ({
233    ...x,
234    at: null,
235    reason: null,
236    offer: null,
237    attempts: r.reason === 'test' ? x.attempts : x.attempts + 1,
238  }))
239  $.ui.toast(s.toastResumed)
240  await $.prompt.submit({ text: await resumePrompt($) })
241}
242
243// Продолжение из таймера или кнопки: ошибку никто не ждёт, поэтому пишем её в debug-лог, а не теряем.
244function fireSafely($: EngineInterface) {
245  fire($).catch(error => $.ui.log(`limit-watch: resume failed: ${String(error)}`, { to: 'debug' }))
246}
247
248function arm($: EngineInterface, at: number, clockNow: number) {
249  cancelTimer()
250  timer = $.clock.after(Math.max(0, at - clockNow), () => fireSafely($))
251  ticker = $.clock.every(WALL_CHECK_MS, () => {
252    $.clock
253      .now()
254      .then(t => (t >= at ? fireSafely($) : undefined))
255      .catch(error => $.ui.log(`limit-watch: clock check failed: ${String(error)}`, { to: 'debug' }))
256  })
257}
258
259async function schedule($: EngineInterface, at: number, reason: string) {
260  const clockNow = await $.clock.now()
261  await update($, retry, x => ({ ...x, at, reason, offer: null }))
262  arm($, at, clockNow)
263}
264
265async function cancelRetry($: EngineInterface) {
266  cancelTimer()
267  await update($, retry, x => ({ ...x, at: null, reason: null, offer: null }))
268}
269
270// Ставит ретрай, если он включён, лимит исчерпан, работа на нём оборвалась и ничего ещё не запланировано.
271// 100% бывает и после успешного ответа ровно на границе лимита — тогда продолжать нечего.
272async function maybeSchedule($: EngineInterface) {
273  const [r, snap, clockNow, s] = await Promise.all([read($, retry), read($, snapshot), $.clock.now(), strings($)])
274  const hit = exhausted(snap.limits)
275  if (!hit && r.offer !== null) {
276    // Окно снова открыто: предлагать больше нечего.
277    await update($, retry, x => ({ ...x, offer: null }))
278    return
279  }
280  if (!r.isEnabled || !r.isInterrupted || r.at !== null || r.offer !== null || !hit) return
281
282  // Продолжали уже несколько раз подряд, а лимит всё ещё исчерпан: выключаемся, чтобы не слать сообщения без конца.
283  if (r.attempts >= MAX_AUTO_RESUMES) {
284    await update($, retry, x => ({ ...x, isEnabled: false, attempts: 0 }))
285    $.ui.toast(s.toastGaveUp(MAX_AUTO_RESUMES))
286    return
287  }
288
289  const at = hit.resetsAt !== null ? hit.resetsAt + RETRY_GRACE_MS : clockNow + RETRY_FALLBACK_MS
290  if (at - clockNow > AUTO_WAIT_MAX_MS) {
291    await update($, retry, x => ({ ...x, offer: { at, kind: hit.kind } }))
292    $.ui.toast(s.toastFar(labelOf(hit.kind, s), formatIn(at - clockNow, s)))
293    return
294  }
295  await schedule($, at, hit.kind)
296  $.ui.toast(s.toastScheduled(labelOf(hit.kind, s), formatIn(at - clockNow, s)))
297}
298
299async function toggleRetry($: EngineInterface) {
300  await update($, retry, x => ({ ...x, isEnabled: !x.isEnabled, attempts: 0 }))
301  await maybeSchedule($)
302}
303
304async function applyUsage($: EngineInterface, rateLimits: readonly SessionRateLimit[]) {
305  const clockNow = await $.clock.now()
306  await update($, now, () => clockNow)
307  await update($, snapshot, s => ({ ...s, limits: rateLimits.map(toLimit) }))
308  await maybeSchedule($)
309}
310
311// Ширина строки в ячейках: иероглифы и кана занимают две.
312function cellWidth(text: string): number {
313  let width = 0
314  for (const ch of text) width += /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch) ? 2 : 1
315  return width
316}
317
318// Десктоп, VS Code и телефон рисуют пропорциональным шрифтом: подпись там уже, чем символ на ячейку.
319// Подобрано по скриншотам с запасом: в 94 ячейки полная раскладка помещается, в ~70 (открыта боковая панель) — нет.
320// В терминале символ — ровно ячейка.
321const PROPORTIONAL_SCALE = 0.85
322
323type LimitItem = { key: string; label: string; value: string; color: Tone; hint?: string }
324
325// Как подробно показывать лимиты: всё или только значок и проценты (в терминале без значков — подпись и проценты).
326type Detail = 'full' | 'compact'
327const DETAILS: readonly Detail[] = ['full', 'compact']
328
329// Сколько ячеек займёт ряд лимитов при данной подробности (gap 1 внутри пункта, 3 между пунктами).
330function limitsWidth(items: LimitItem[], detail: Detail, hasIcons: boolean): number {
331  const parts = items.map(item => {
332    const pieces = [item.value]
333    if (detail !== 'compact' || !hasIcons) pieces.unshift(item.label)
334    if (detail === 'full' && item.hint) pieces.push(item.hint)
335    const text = pieces.reduce((sum, piece) => sum + cellWidth(piece), 0) + (pieces.length - 1)
336    return text + (hasIcons ? 3 : 0)
337  })
338  return parts.reduce((sum, w) => sum + w, 0) + 3 * Math.max(0, parts.length - 1)
339}
340
341async function isDarwin($: EngineInterface): Promise<boolean> {
342  try {
343    const { exitCode, stdout } = await $.process.run(['uname', '-s'], { timeoutMs: 3000 })
344    return exitCode === 0 && stdout.trim() === 'Darwin'
345  } catch {
346    return false
347  }
348}
349
350// «1m», «30s», «2h», по-русски тоже; без единицы — минуты.
351function parseDelay(text: string): number | null {
352  const match = /^(\d+)\s*(s|m|h|с|м|ч)?$/i.exec(text.trim())
353  if (!match) return null
354  const unit = (match[2] ?? 'm').toLowerCase()
355  const ms = unit === 's' || unit === 'с' ? 1_000 : unit === 'h' || unit === 'ч' ? 3_600_000 : 60_000
356  return Number(match[1]) * ms
357}
358
359export const register: Register = (on, options) => {
360  on('session.start', async ($, e, next) => {
361    const detected = await detectLang($, options)
362    await update($, lang, () => detected)
363    await $.command.register({ name: 'limit-watch', description: STRINGS[detected].commandDescription })
364    const result = await next(e)
365    const usage = await $.session.usage()
366    await applyUsage($, usage.rateLimits)
367
368    // Перезагрузка мода сбрасывает таймеры и caffeinate, но $.state сохраняется: восстанавливаем оба.
369    const r = await read($, retry)
370    if (r.at !== null) arm($, r.at, await $.clock.now())
371
372    const mac = await isDarwin($)
373    await update($, isMac, () => mac)
374    if (mac && (await read($, isAwake))) startAwake($)
375
376    // Раз в минуту обновляем «через сколько» на полосе.
377    $.clock.every(60_000, () => void $.clock.now().then(t => update($, now, () => t)))
378    return result
379  })
380
381  on('command.run', { command: 'limit-watch' }, async ($, e) => {
382    const s = await strings($)
383    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
384    if (sub === 'test') {
385      // Разыгрывает исчерпанный лимит: через указанное время отправит настоящее сообщение о продолжении.
386      const delay = parseDelay(rest.join(' ') || '1m')
387      if (delay === null) return { text: s.testUsage }
388      await schedule($, (await $.clock.now()) + delay, 'test')
389      return { text: s.testScheduled(formatIn(delay, s)) }
390    }
391    const hidden = await update($, isHidden, v => !v)
392    return { text: hidden ? s.barHidden : s.barShown }
393  })
394
395  on('prompt.submit', async ($, e, next) => {
396    // Сессию продолжил кто-то другой — человек, автопродолжение десктопа, уведомление: наше продолжение
397    // было бы дублем. Своё сообщение мод отправляет, уже сняв план, поэтому его не трогаем.
398    const isOwn = e.origin.kind === 'plugin' && e.origin.name === $.plugin.name
399    if (!isOwn) {
400      const r = await read($, retry)
401      if (r.at !== null) {
402        await cancelRetry($)
403        $.ui.toast((await strings($)).toastManual)
404      } else if (r.offer !== null) {
405        // Предложение и так не продолжило бы само: снимаем молча, тост «не продолжу» тут лишний.
406        await cancelRetry($)
407      }
408    }
409    return next(e)
410  })
411
412  on('session.measure', async ($, e, next) => {
413    await applyUsage($, e.rateLimits)
414    return next(e)
415  })
416
417  on('turn.complete', async ($, e, next) => {
418    const result = await next(e)
419    if (e.agentId === undefined) {
420      // Ход основной сессии: ответ — работа идёт, обрыв — есть что продолжать после сброса.
421      const r = await read($, retry)
422      if (e.reason === 'answer' && (r.attempts > 0 || r.isInterrupted)) {
423        await update($, retry, x => ({ ...x, attempts: 0, isInterrupted: false }))
424      }
425      if (e.reason === 'error' && !r.isInterrupted) {
426        await update($, retry, x => ({ ...x, isInterrupted: true }))
427      }
428    }
429    // После ошибки (в том числе исчерпанного лимита) перечитываем лимиты сразу, не дожидаясь session.measure.
430    if (e.reason === 'error') {
431      const usage = await $.session.usage()
432      await applyUsage($, usage.rateLimits)
433    }
434    return result
435  })
436
437  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
438    if (e.props.hasSurvey || (await read($, isHidden))) {
439      return next(e)
440    }
441    const [snap, r, t, s, awake, mac] = await Promise.all([
442      read($, snapshot),
443      read($, retry),
444      read($, now),
445      strings($),
446      read($, isAwake),
447      read($, isMac),
448    ])
449    const ui = $.ui.resolve(e)
450    const { Box, Button, Text } = ui
451    // Иконки рисуют только десктоп, VS Code и телефон: терминал Svg не показывает, там остаются подписи.
452    const hasIcons = e.surface !== 'terminal' && 'Svg' in ui
453    const icon = (name: string) =>
454      hasIcons && 'Svg' in ui && ICONS[name] ? <ui.Svg source={iconSvg(name)} alt={s.iconAlts[name] ?? name} width={16} height={16} /> : null
455
456    // Подписи серым, проценты окрашиваются только от 50%.
457    const items: LimitItem[] = []
458    for (const l of snap.limits) {
459      // Время до сброса показываем всегда, когда сервер его сообщил: по нему планируют работу.
460      const hint = l.resetsAt !== null && t > 0 ? s.resetIn(formatIn(l.resetsAt - t, s)) : undefined
461      items.push({ key: l.kind, label: labelOf(l.kind, s), value: `${l.percent}%`, color: tone(l.percent), hint })
462    }
463
464    const actions = (
465      <Box flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
466        {r.at !== null ? (
467          <Box flexDirection="row" alignItems="center" gap={1}>
468            <Button key="retry-now" label={s.resumeNow} onPress={() => {
469              cancelTimer()
470              fireSafely($)
471            }} />
472            <Button key="retry-cancel" label={s.cancel} dimColor onPress={() => void cancelRetry($)} />
473          </Box>
474        ) : r.offer !== null ? (
475          <Box flexDirection="row" alignItems="center" gap={1}>
476            <Button key="retry-confirm" label={s.resumeInButton(formatIn(r.offer.at - t, s))} onPress={() => {
477              const offer = r.offer
478              if (offer) void schedule($, offer.at, offer.kind)
479            }} />
480            <Button key="retry-cancel" label={s.noThanks} dimColor onPress={() => void cancelRetry($)} />
481          </Box>
482        ) : (
483          // Включено — зелёная точка слева и главная (белая, скруглённая) кнопка приложения.
484          // Свой цвет фона у нативной кнопки задать нельзя, а Client со своим фоном не скругляется.
485          <Box key="retry-toggle-box" flexDirection="row" alignItems="center" gap={1}>
486            {/* Точка есть всегда, чтобы при включении ничего не сдвигалось: серая — выключено, зелёная — включено. */}
487            <Text color={r.isEnabled ? 'success' : 'subtle'} dimColor={!r.isEnabled}>
488              ●
489            </Text>
490            <Button
491              key="retry-toggle"
492              label={s.toggleOff}
493              variant={r.isEnabled ? 'primary' : undefined}
494              dimColor={!r.isEnabled}
495              onPress={() => void toggleRetry($)}
496            />
497          </Box>
498        )}
499        {/* Zzz — не давать Mac уснуть (caffeinate), пока открыт этот чат. Svg в кнопку не вставить, поэтому текст.
500            Включено — белая кнопка: сон заблокирован. */}
501        {mac && (
502          <Button key="awake" label={SLEEP_ICON} variant={awake ? 'primary' : undefined} dimColor={!awake} onPress={() => void toggleAwake($)} />
503        )}
504        <Button key="hide" label={s.hide} role="dismiss" plain dimColor onPress={() => update($, isHidden, () => true)} />
505      </Box>
506    )
507
508    // Адаптив: берём самую подробную раскладку, которая помещается в одну строку с кнопками.
509    // Не помещается даже самая краткая — кнопки уходят на вторую строку.
510    // Текст подписей на десктопе уже, чем ячейка (пропорциональный шрифт), а рамка кнопки — нет:
511    // поэтому подписи масштабируем, а поля кнопок (по 4 ячейки) и промежутки считаем как есть.
512    const scale = e.surface === 'terminal' ? 1 : PROPORTIONAL_SCALE
513    const labels =
514      r.at !== null
515        ? [s.resumeNow, s.cancel]
516        : r.offer !== null
517          ? [s.resumeInButton(formatIn(r.offer.at - t, s)), s.noThanks]
518          : [s.toggleOff]
519    const buttons = [...labels, ...(mac ? [SLEEP_ICON] : [])]
520    const actionsText = buttons.reduce((sum, label) => sum + cellWidth(label), 0) + cellWidth(s.hide)
521    const actionsChrome = buttons.length * 4 + buttons.length + (r.at === null && r.offer === null ? 2 : 0)
522    const room = e.props.bodyColumns
523    const fits = (text: number, chrome: number) => text * scale + chrome <= room
524    const inline = DETAILS.find(d => fits(limitsWidth(items, d, hasIcons) + actionsText, 2 + actionsChrome))
525    const isStacked = inline === undefined
526    const detail = inline ?? DETAILS.find(d => fits(limitsWidth(items, d, hasIcons), 0)) ?? 'compact'
527
528    const limitsRow = (
529      <Box flexDirection="row" alignItems="center" flexWrap={isStacked ? 'wrap' : 'nowrap'} columnGap={3}>
530        {items.map(item => (
531          <Box key={item.key} flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
532            {icon(item.key)}
533            {(detail !== 'compact' || !hasIcons) && <Text color="subtle">{item.label}</Text>}
534            <Text color={item.color}>{item.value}</Text>
535            {detail === 'full' && item.hint && (
536              <Text color="subtle" dimColor>
537                {item.hint}
538              </Text>
539            )}
540          </Box>
541        ))}
542      </Box>
543    )
544
545    return isStacked ? (
546      <Box flexDirection="column" width="100%" rowGap={1}>
547        {limitsRow}
548        <Box flexDirection="row" justifyContent="flex-end" width="100%">
549          {actions}
550        </Box>
551      </Box>
552    ) : (
553      <Box flexDirection="row" alignItems="center" justifyContent="space-between" width="100%" columnGap={2}>
554        {limitsRow}
555        {actions}
556      </Box>
557    )
558  })
559}
560
hooks/i18n.ts 313 lines
1export type Lang = 'ru' | 'en' | 'es' | 'de' | 'fr' | 'pt' | 'zh' | 'ja' | 'uk'
2
3export const LANGS: readonly Lang[] = ['ru', 'en', 'es', 'de', 'fr', 'pt', 'zh', 'ja', 'uk']
4
5export type Strings = {
6  limitLabels: Record<string, string>
7  iconAlts: Record<string, string>
8  units: { d: string; h: string; m: string }
9  resetIn: (time: string) => string
10  resumeNow: string
11  cancel: string
12  resumeInButton: (time: string) => string
13  noThanks: string
14  toggleOff: string
15  hide: string
16  toastStillClosed: (label: string, time: string) => string
17  toastResumed: string
18  toastFar: (label: string, time: string) => string
19  toastScheduled: (label: string, time: string) => string
20  toastManual: string
21  toastGaveUp: (attempts: number) => string
22  toastAwakeOn: string
23  toastAwakeOff: string
24  toastAwakeFailed: string
25  commandDescription: string
26  testUsage: string
27  testScheduled: (time: string) => string
28  barHidden: string
29  barShown: string
30  resumeHead: string
31  lastAsked: (text: string) => string
32  lastAnswered: (text: string) => string
33}
34
35export const STRINGS: Record<Lang, Strings> = {
36  ru: {
37    limitLabels: { five_hour: '5ч', seven_day: 'неделя', spend_limit: 'бюджет' },
38    iconAlts: { five_hour: 'лимит 5 часов', seven_day: 'недельный лимит', spend_limit: 'бюджет' },
39    units: { d: 'д', h: 'ч', m: 'м' },
40    resetIn: time => `сброс ${time}`,
41    resumeNow: 'Продолжить сейчас',
42    cancel: 'Отменить',
43    resumeInButton: time => `Продолжить через ${time}`,
44    noThanks: 'Не нужно',
45    toggleOff: 'Продолжить после сброса',
46    hide: 'Скрыть',
47    toastStillClosed: (label, time) => `Лимит «${label}» ещё не сброшен, продолжу через ${time}`,
48    toastResumed: 'Лимит сброшен — продолжаю работу',
49    toastFar: (label, time) => `Лимит «${label}» исчерпан до сброса через ${time}: сам не продолжу`,
50    toastScheduled: (label, time) => `Лимит «${label}» исчерпан, продолжу через ${time}`,
51    toastManual: 'Не продолжу: сессия уже продолжена',
52    toastGaveUp: n => `Лимит всё ещё исчерпан после ${n} попыток — больше не продолжаю сам`,
53    toastAwakeOn: 'Mac не будет засыпать, пока открыт этот чат',
54    toastAwakeOff: 'Mac снова может засыпать',
55    toastAwakeFailed: 'Не удалось запустить caffeinate: режим «не засыпать» выключен',
56    commandDescription: 'Показать или скрыть полосу лимитов',
57    testUsage: 'Формат: /limit-watch test 1m (или 30s, 2h)',
58    testScheduled: time => `Тест: продолжу через ${time}. Отменить — кнопкой на полосе.`,
59    barHidden: 'Полоса скрыта. Вернуть: /limit-watch',
60    barShown: 'Полоса снова показана.',
61    resumeHead: 'Лимит восстановился, продолжай работу с того места, где остановился.',
62    lastAsked: text => `Последний запрос пользователя: «${text}»`,
63    lastAnswered: text => `Твоё последнее сообщение перед остановкой: «${text}»`,
64  },
65  en: {
66    limitLabels: { five_hour: '5h', seven_day: 'week', spend_limit: 'budget' },
67    iconAlts: { five_hour: '5-hour limit', seven_day: 'weekly limit', spend_limit: 'budget' },
68    units: { d: 'd', h: 'h', m: 'm' },
69    resetIn: time => `reset ${time}`,
70    resumeNow: 'Resume now',
71    cancel: 'Cancel',
72    resumeInButton: time => `Resume in ${time}`,
73    noThanks: 'No thanks',
74    toggleOff: 'Resume after reset',
75    hide: 'Hide',
76    toastStillClosed: (label, time) => `${label} limit hasn't reset yet, resuming in ${time}`,
77    toastResumed: 'Limit reset — resuming work',
78    toastFar: (label, time) => `${label} limit used up, resets in ${time}: not resuming on my own`,
79    toastScheduled: (label, time) => `${label} limit used up, resuming in ${time}`,
80    toastManual: 'Not resuming: the session already continued',
81    toastGaveUp: n => `The limit is still used up after ${n} tries — no more automatic resumes`,
82    toastAwakeOn: "Your Mac won't sleep while this chat is open",
83    toastAwakeOff: 'Your Mac can sleep again',
84    toastAwakeFailed: "Couldn't start caffeinate: stay-awake mode is off",
85    commandDescription: 'Show or hide the limits bar',
86    testUsage: 'Usage: /limit-watch test 1m (or 30s, 2h)',
87    testScheduled: time => `Test: resuming in ${time}. Cancel with the button on the bar.`,
88    barHidden: 'Bar hidden. Bring it back: /limit-watch',
89    barShown: 'Bar shown again.',
90    resumeHead: 'The usage limit has reset. Continue the work from where you stopped.',
91    lastAsked: text => `The user's last request: "${text}"`,
92    lastAnswered: text => `Your last message before stopping: "${text}"`,
93  },
94  es: {
95    limitLabels: { five_hour: '5h', seven_day: 'semana', spend_limit: 'presupuesto' },
96    iconAlts: { five_hour: 'límite de 5 horas', seven_day: 'límite semanal', spend_limit: 'presupuesto' },
97    units: { d: 'd', h: 'h', m: 'min' },
98    resetIn: time => `reinicio ${time}`,
99    resumeNow: 'Continuar ahora',
100    cancel: 'Cancelar',
101    resumeInButton: time => `Continuar en ${time}`,
102    noThanks: 'No, gracias',
103    toggleOff: 'Continuar tras el reinicio',
104    hide: 'Ocultar',
105    toastStillClosed: (label, time) => `El límite «${label}» aún no se ha reiniciado, continuaré en ${time}`,
106    toastResumed: 'Límite reiniciado: continúo el trabajo',
107    toastFar: (label, time) => `Límite «${label}» agotado, se reinicia en ${time}: no continuaré por mi cuenta`,
108    toastScheduled: (label, time) => `Límite «${label}» agotado, continuaré en ${time}`,
109    toastManual: 'No continuaré: la sesión ya continuó',
110    toastGaveUp: n => `El límite sigue agotado tras ${n} intentos: no continuaré más por mi cuenta`,
111    toastAwakeOn: 'El Mac no se dormirá mientras este chat esté abierto',
112    toastAwakeOff: 'El Mac ya puede dormirse',
113    toastAwakeFailed: 'No se pudo iniciar caffeinate: el modo sin reposo está desactivado',
114    commandDescription: 'Mostrar u ocultar la barra de límites',
115    testUsage: 'Uso: /limit-watch test 1m (o 30s, 2h)',
116    testScheduled: time => `Prueba: continuaré en ${time}. Cancela con el botón de la barra.`,
117    barHidden: 'Barra oculta. Para mostrarla: /limit-watch',
118    barShown: 'Barra visible de nuevo.',
119    resumeHead: 'El límite de uso se ha reiniciado. Continúa el trabajo desde donde lo dejaste.',
120    lastAsked: text => `Última petición del usuario: «${text}»`,
121    lastAnswered: text => `Tu último mensaje antes de detenerte: «${text}»`,
122  },
123  de: {
124    limitLabels: { five_hour: '5h', seven_day: 'Woche', spend_limit: 'Budget' },
125    iconAlts: { five_hour: '5-Stunden-Limit', seven_day: 'Wochenlimit', spend_limit: 'Budget' },
126    units: { d: 'T', h: 'h', m: 'min' },
127    resetIn: time => `Reset in ${time}`,
128    resumeNow: 'Jetzt fortsetzen',
129    cancel: 'Abbrechen',
130    resumeInButton: time => `In ${time} fortsetzen`,
131    noThanks: 'Nein danke',
132    toggleOff: 'Nach Reset fortsetzen',
133    hide: 'Ausblenden',
134    toastStillClosed: (label, time) => `Limit „${label}“ noch nicht zurückgesetzt, weiter in ${time}`,
135    toastResumed: 'Limit zurückgesetzt – arbeite weiter',
136    toastFar: (label, time) => `Limit „${label}“ aufgebraucht, Reset in ${time}: ich mache nicht selbst weiter`,
137    toastScheduled: (label, time) => `Limit „${label}“ aufgebraucht, weiter in ${time}`,
138    toastManual: 'Kein Fortsetzen: die Sitzung läuft schon weiter',
139    toastGaveUp: n => `Limit nach ${n} Versuchen noch aufgebraucht – kein automatisches Fortsetzen mehr`,
140    toastAwakeOn: 'Der Mac schläft nicht ein, solange dieser Chat offen ist',
141    toastAwakeOff: 'Der Mac darf wieder einschlafen',
142    toastAwakeFailed: 'caffeinate ließ sich nicht starten: Wach-Modus ist aus',
143    commandDescription: 'Limit-Leiste ein- oder ausblenden',
144    testUsage: 'Verwendung: /limit-watch test 1m (oder 30s, 2h)',
145    testScheduled: time => `Test: weiter in ${time}. Abbrechen mit dem Button in der Leiste.`,
146    barHidden: 'Leiste ausgeblendet. Wieder einblenden: /limit-watch',
147    barShown: 'Leiste wieder eingeblendet.',
148    resumeHead: 'Das Nutzungslimit wurde zurückgesetzt. Setze die Arbeit dort fort, wo du aufgehört hast.',
149    lastAsked: text => `Letzte Anfrage des Nutzers: „${text}“`,
150    lastAnswered: text => `Deine letzte Nachricht vor dem Stopp: „${text}“`,
151  },
152  fr: {
153    limitLabels: { five_hour: '5h', seven_day: 'semaine', spend_limit: 'budget' },
154    iconAlts: { five_hour: 'limite de 5 heures', seven_day: 'limite hebdomadaire', spend_limit: 'budget' },
155    units: { d: 'j', h: 'h', m: 'min' },
156    resetIn: time => `réinit. ${time}`,
157    resumeNow: 'Reprendre maintenant',
158    cancel: 'Annuler',
159    resumeInButton: time => `Reprendre dans ${time}`,
160    noThanks: 'Non merci',
161    toggleOff: 'Reprendre après réinitialisation',
162    hide: 'Masquer',
163    toastStillClosed: (label, time) => `La limite « ${label} » n'est pas encore réinitialisée, reprise dans ${time}`,
164    toastResumed: 'Limite réinitialisée — je reprends le travail',
165    toastFar: (label, time) => `Limite « ${label} » épuisée, réinitialisation dans ${time} : je ne reprendrai pas seul`,
166    toastScheduled: (label, time) => `Limite « ${label} » épuisée, reprise dans ${time}`,
167    toastManual: 'Pas de reprise : la session a déjà repris',
168    toastGaveUp: n => `Limite toujours épuisée après ${n} essais : plus de reprise automatique`,
169    toastAwakeOn: 'Le Mac ne se mettra pas en veille tant que ce chat est ouvert',
170    toastAwakeOff: 'Le Mac peut de nouveau se mettre en veille',
171    toastAwakeFailed: 'Impossible de lancer caffeinate : le mode anti-veille est désactivé',
172    commandDescription: 'Afficher ou masquer la barre des limites',
173    testUsage: 'Usage : /limit-watch test 1m (ou 30s, 2h)',
174    testScheduled: time => `Test : reprise dans ${time}. Annulez avec le bouton de la barre.`,
175    barHidden: 'Barre masquée. Pour la réafficher : /limit-watch',
176    barShown: 'Barre de nouveau affichée.',
177    resumeHead: "La limite d'utilisation a été réinitialisée. Reprends le travail là où tu t'es arrêté.",
178    lastAsked: text => `Dernière demande de l'utilisateur : « ${text} »`,
179    lastAnswered: text => `Ton dernier message avant l'arrêt : « ${text} »`,
180  },
181  pt: {
182    limitLabels: { five_hour: '5h', seven_day: 'semana', spend_limit: 'orçamento' },
183    iconAlts: { five_hour: 'limite de 5 horas', seven_day: 'limite semanal', spend_limit: 'orçamento' },
184    units: { d: 'd', h: 'h', m: 'min' },
185    resetIn: time => `reinício ${time}`,
186    resumeNow: 'Continuar agora',
187    cancel: 'Cancelar',
188    resumeInButton: time => `Continuar em ${time}`,
189    noThanks: 'Não, obrigado',
190    toggleOff: 'Continuar após o reinício',
191    hide: 'Ocultar',
192    toastStillClosed: (label, time) => `O limite "${label}" ainda não reiniciou, continuo em ${time}`,
193    toastResumed: 'Limite reiniciado — continuando o trabalho',
194    toastFar: (label, time) => `Limite "${label}" esgotado, reinicia em ${time}: não vou continuar sozinho`,
195    toastScheduled: (label, time) => `Limite "${label}" esgotado, continuo em ${time}`,
196    toastManual: 'Não vou continuar: a sessão já continuou',
197    toastGaveUp: n => `O limite continua esgotado após ${n} tentativas: não vou mais continuar sozinho`,
198    toastAwakeOn: 'O Mac não vai dormir enquanto este chat estiver aberto',
199    toastAwakeOff: 'O Mac pode dormir de novo',
200    toastAwakeFailed: 'Não foi possível iniciar o caffeinate: o modo sem repouso está desligado',
201    commandDescription: 'Mostrar ou ocultar a barra de limites',
202    testUsage: 'Uso: /limit-watch test 1m (ou 30s, 2h)',
203    testScheduled: time => `Teste: continuo em ${time}. Cancele com o botão na barra.`,
204    barHidden: 'Barra oculta. Para mostrar de novo: /limit-watch',
205    barShown: 'Barra exibida novamente.',
206    resumeHead: 'O limite de uso foi reiniciado. Continue o trabalho de onde parou.',
207    lastAsked: text => `Último pedido do usuário: "${text}"`,
208    lastAnswered: text => `Sua última mensagem antes de parar: "${text}"`,
209  },
210  zh: {
211    limitLabels: { five_hour: '5小时', seven_day: '每周', spend_limit: '预算' },
212    iconAlts: { five_hour: '5 小时额度', seven_day: '每周额度', spend_limit: '预算' },
213    units: { d: '天', h: '小时', m: '分' },
214    resetIn: time => `${time}后重置`,
215    resumeNow: '立即继续',
216    cancel: '取消',
217    resumeInButton: time => `${time}后继续`,
218    noThanks: '不用了',
219    toggleOff: '重置后继续',
220    hide: '隐藏',
221    toastStillClosed: (label, time) => `“${label}”额度尚未重置,${time}后继续`,
222    toastResumed: '额度已重置——继续工作',
223    toastFar: (label, time) => `“${label}”额度已用完,${time}后重置:不会自动继续`,
224    toastScheduled: (label, time) => `“${label}”额度已用完,${time}后继续`,
225    toastManual: '不会自动继续:会话已经继续了',
226    toastGaveUp: n => `尝试 ${n} 次后额度仍已用完:不再自动继续`,
227    toastAwakeOn: '此聊天打开期间 Mac 不会休眠',
228    toastAwakeOff: 'Mac 可以再次休眠',
229    toastAwakeFailed: '无法启动 caffeinate:防休眠模式已关闭',
230    commandDescription: '显示或隐藏额度栏',
231    testUsage: '用法:/limit-watch test 1m(或 30s、2h)',
232    testScheduled: time => `测试:${time}后继续。可用栏上的按钮取消。`,
233    barHidden: '额度栏已隐藏。重新显示:/limit-watch',
234    barShown: '额度栏已重新显示。',
235    resumeHead: '使用额度已重置。请从上次停下的地方继续工作。',
236    lastAsked: text => `用户最后的请求:“${text}”`,
237    lastAnswered: text => `你停止前的最后一条消息:“${text}”`,
238  },
239  ja: {
240    limitLabels: { five_hour: '5時間', seven_day: '週', spend_limit: '予算' },
241    iconAlts: { five_hour: '5時間の上限', seven_day: '週の上限', spend_limit: '予算' },
242    units: { d: '日', h: '時間', m: '分' },
243    resetIn: time => `${time}後にリセット`,
244    resumeNow: '今すぐ再開',
245    cancel: 'キャンセル',
246    resumeInButton: time => `${time}後に再開`,
247    noThanks: '不要',
248    toggleOff: 'リセット後に再開',
249    hide: '非表示',
250    toastStillClosed: (label, time) => `「${label}」の上限はまだリセットされていません。${time}後に再開します`,
251    toastResumed: '上限がリセットされました。作業を再開します',
252    toastFar: (label, time) => `「${label}」の上限に達しました。リセットは${time}後です。自動では再開しません`,
253    toastScheduled: (label, time) => `「${label}」の上限に達しました。${time}後に再開します`,
254    toastManual: '再開しません:セッションはすでに続行されています',
255    toastGaveUp: n => `${n} 回試しても上限に達したままです。自動再開を停止します`,
256    toastAwakeOn: 'このチャットが開いている間、Mac はスリープしません',
257    toastAwakeOff: 'Mac はスリープできるようになりました',
258    toastAwakeFailed: 'caffeinate を起動できませんでした:スリープ防止モードはオフです',
259    commandDescription: '上限バーの表示/非表示を切り替え',
260    testUsage: '使い方:/limit-watch test 1m(または 30s、2h)',
261    testScheduled: time => `テスト:${time}後に再開します。バーのボタンでキャンセルできます。`,
262    barHidden: 'バーを非表示にしました。再表示:/limit-watch',
263    barShown: 'バーを再表示しました。',
264    resumeHead: '利用上限がリセットされました。中断したところから作業を続けてください。',
265    lastAsked: text => `ユーザーの最後のリクエスト:「${text}」`,
266    lastAnswered: text => `停止前のあなたの最後のメッセージ:「${text}」`,
267  },
268  uk: {
269    limitLabels: { five_hour: '5год', seven_day: 'тиждень', spend_limit: 'бюджет' },
270    iconAlts: { five_hour: 'ліміт на 5 годин', seven_day: 'тижневий ліміт', spend_limit: 'бюджет' },
271    units: { d: 'д', h: 'год', m: 'хв' },
272    resetIn: time => `скидання ${time}`,
273    resumeNow: 'Продовжити зараз',
274    cancel: 'Скасувати',
275    resumeInButton: time => `Продовжити через ${time}`,
276    noThanks: 'Не треба',
277    toggleOff: 'Продовжити після скидання',
278    hide: 'Сховати',
279    toastStillClosed: (label, time) => `Ліміт «${label}» ще не скинуто, продовжу через ${time}`,
280    toastResumed: 'Ліміт скинуто — продовжую роботу',
281    toastFar: (label, time) => `Ліміт «${label}» вичерпано до скидання через ${time}: сам не продовжу`,
282    toastScheduled: (label, time) => `Ліміт «${label}» вичерпано, продовжу через ${time}`,
283    toastManual: 'Не продовжу: сесію вже продовжено',
284    toastGaveUp: n => `Ліміт усе ще вичерпано після ${n} спроб — більше сам не продовжую`,
285    toastAwakeOn: 'Mac не засинатиме, поки відкритий цей чат',
286    toastAwakeOff: 'Mac знову може засинати',
287    toastAwakeFailed: 'Не вдалося запустити caffeinate: режим «не засинати» вимкнено',
288    commandDescription: 'Показати або сховати смугу лімітів',
289    testUsage: 'Формат: /limit-watch test 1m (або 30s, 2h)',
290    testScheduled: time => `Тест: продовжу через ${time}. Скасувати — кнопкою на смузі.`,
291    barHidden: 'Смугу сховано. Повернути: /limit-watch',
292    barShown: 'Смугу знову показано.',
293    resumeHead: 'Ліміт відновився, продовжуй роботу з того місця, де зупинився.',
294    lastAsked: text => `Останній запит користувача: «${text}»`,
295    lastAnswered: text => `Твоє останнє повідомлення перед зупинкою: «${text}»`,
296  },
297}
298
299// Начала сообщений о продолжении на всех языках: по ним мод узнаёт свои сообщения в истории.
300export const RESUME_HEADS = Object.values(STRINGS).map(s => s.resumeHead)
301
302export function isLang(value: unknown): value is Lang {
303  return typeof value === 'string' && (LANGS as readonly string[]).includes(value)
304}
305
306// Тег локали (`ru_RU.UTF-8`, `pt-BR`, `zh-Hans-CN`) в язык мода. Пустой, `C` и `POSIX` ничего не говорят о языке.
307export function langFromLocale(tag: string | undefined): Lang | null {
308  const code = tag?.trim().split(/[-_.@]/)[0]?.toLowerCase()
309  if (!code || code === 'c' || code === 'posix') return null
310  // Язык без перевода получает английский. Китайский один, упрощённый: для zh-TW и zh-HK тоже он.
311  return isLang(code) ? code : 'en'
312}
313
types/index.d.ts 35 lines
1export type Limit = {
2  kind: string
3  percent: number
4  resetsAt: number | null
5}
6
7export type Snapshot = {
8  limits: Limit[]
9}
10
11// Ретрай, который сам не ставится, потому что ждать слишком долго: ждёт подтверждения.
12export type RetryOffer = {
13  at: number
14  kind: string
15}
16
17export type Retry = {
18  isEnabled: boolean
19  // Когда сработает запланированный ретрай; null, если ничего не запланировано.
20  at: number | null
21  // Какое окно исчерпано (`five_hour`, `seven_day`) или `test` для /limit-watch test.
22  reason: string | null
23  offer: RetryOffer | null
24  // Сколько раз подряд мод продолжил сам, а лимит так и не восстановился.
25  attempts: number
26  // Оборвался ли последний ход основной сессии ошибкой (упор в лимит): только тогда есть что продолжать.
27  isInterrupted: boolean
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'limit-watch': { snapshotV6: Snapshot; isHidden: boolean; retryV4: Retry; now: number; isAwake: boolean; isMac: boolean; lang: 'ru' | 'en' | 'es' | 'de' | 'fr' | 'pt' | 'zh' | 'ja' | 'uk' }
33  }
34}
35