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

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
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.
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:
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.
| Command | What it does |
|---|---|
/limit-watch | Hide or show the bar. |
/limit-watch test 1m | Try 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. |
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.
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.claude --version is 2.1.295 or newer.Zzz button — it exists only on macOS.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).
hooks/register.tsx 560 lines1import { 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}
560hooks/i18n.ts 313 lines1export 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}
313types/index.d.ts 35 lines1export 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