Consumo de tokens, custo, previsão dos limites de 5h e semanal e recomendação de modelo

Your Claude Code plan limits with how long they will last at your current pace, sessions, per-project spend, API credits and model advice.
See the main README · Português.
hooks/register.tsx 613 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage, TurnUsage } from 'claude-code'
3
4import type { Advice, ApiSpend, Mix, ProjectBucket, SessionRecord, Snapshot, Totals, WindowView } from '../types'
5import { apiProblem, costReportUrl, cycleBounds, projectApi, sumCostReport } from './api'
6import { sessionsView, upsertSession } from './sessions'
7import type { SessionBook } from './sessions'
8import { addToMix, advise, emptyMix } from './advisor'
9import { activeHoursPerDay, addActivity, addHourly, recentMix, updateCalibration, weekPerFive } from './pace'
10import type { Calibration, HourlyMix } from './pace'
11import { HAIKU_ID, costOf, family, modelKey } from './pricing'
12import {
13 HOUR,
14 MINUTE,
15 addSample,
16 apiShort,
17 clockTime,
18 duration,
19 emptyTotals,
20 percent,
21 project,
22 projectName,
23 projectShares,
24 readingAge,
25 sameWindow,
26 statusLine,
27 totalTokens,
28} from './projection'
29import type { SampleLog, TokenBucket } from './projection'
30import { RichBand, RichPane, TerminalBand, TerminalPane } from './view'
31import type { Actions, ViewData } from './view'
32
33const PANE = 'usage-limits'
34const COMMAND = 'limites'
35const ECONOMY_COMMAND = 'economia'
36
37const snapshot = atom({ plugin: 'usage-limits', key: 'snapshot' } as const, null)
38const session = atom({ plugin: 'usage-limits', key: 'session' } as const, emptyTotals())
39const economy = atom({ plugin: 'usage-limits', key: 'economy' } as const, false)
40const setup = atom({ plugin: 'usage-limits', key: 'setup' } as const, {})
41const currentProject = atom({ plugin: 'usage-limits', key: 'project' } as const, '')
42const api = atom({ plugin: 'usage-limits', key: 'api' } as const, null)
43
44type Alerts = { resetsAt: number; sent: string[] }
45
46const samplesKey = (kind: string) => `samples:${kind}`
47const bucketKey = (kind: string) => `tokens:${kind}`
48const alertsKey = (kind: string) => `alerts:${kind}`
49const mixKey = (kind: string) => `mix:${kind}`
50const projectsKey = (kind: string) => `projects:${kind}`
51const ECONOMY_KEY = 'economy'
52const CALIBRATION_KEY = 'calibration'
53const ACTIVITY_KEY = 'activity'
54const HOURLY_KEY = 'hourly'
55
56/** A leitura desta sessão e os arquivos que as sessões da máquina compartilham. */
57const reading = { ownAt: 0, own: [] as SessionRateLimit[], sharedFile: '', sessionsFile: '', selfId: '', startedAt: 0 }
58
59/** As opções dos créditos de API, do /plugin. */
60const apiConfig = { key: '', creditUsd: 200, cycleDay: 1 }
61
62const readBook = async ($: EngineInterface): Promise<SessionBook | undefined> => {
63 if (!reading.sessionsFile) return undefined
64 try {
65 if (!(await $.fs.exists(reading.sessionsFile))) return undefined
66 const raw = JSON.parse(await $.fs.read(reading.sessionsFile)) as Record<string, unknown>
67 const book: SessionBook = {}
68 for (const [id, r] of Object.entries(raw ?? {})) {
69 const rec = r as Partial<SessionRecord> | null
70 if (rec && typeof rec === 'object' && rec.id === id && typeof rec.updatedAt === 'number' && rec.tokens) {
71 book[id] = rec as SessionRecord
72 }
73 }
74 return book
75 } catch {
76 return undefined
77 }
78}
79
80/** Grava o consumo desta sessão no livro de sessões da máquina e devolve o livro. */
81const writeSelf = async ($: EngineInterface, costUsd: number | undefined, ended = false) => {
82 if (!reading.sessionsFile || !reading.selfId) return undefined
83 const now = await $.clock.now()
84 const held = await readBook($)
85 const previous = held?.[reading.selfId]
86 const record: SessionRecord = {
87 id: reading.selfId,
88 project: await read($, currentProject),
89 model: (await read($, setup)).mainModel,
90 startedAt: reading.startedAt || now,
91 updatedAt: now,
92 tokens: await read($, session),
93 costUsd: costUsd ?? previous?.costUsd ?? 0,
94 ended,
95 }
96 const book = upsertSession(held, record, now)
97 try {
98 await $.fs.write(reading.sessionsFile, JSON.stringify(book))
99 } catch {
100 // Sem o arquivo, a lista mostra só esta sessão.
101 }
102
103 return book
104}
105
106/** Lê do Console quanto dos créditos de API o ciclo já gastou. */
107const refreshApi = async ($: EngineInterface) => {
108 if (!apiConfig.key) return
109 const now = await $.clock.now()
110 const bounds = cycleBounds(now, apiConfig.cycleDay)
111 let usdTotal = 0
112 const byModel: Record<string, number> = {}
113 let page: string | undefined
114 try {
115 for (let i = 0; i < 5; i++) {
116 const r = await $.http.fetch(costReportUrl(bounds.start, now, page), {
117 headers: { 'x-api-key': apiConfig.key, 'anthropic-version': '2023-06-01', 'user-agent': 'usage-limits-mod/0.6' },
118 })
119 if (!r.ok) {
120 const held = await read($, api)
121 await update($, api, () => ({ ...(held ?? projectApi(0, {}, bounds, now, apiConfig.creditUsd)), problem: apiProblem(r.status, r.text) }))
122 return
123 }
124 const pageSum = sumCostReport(r.text)
125 usdTotal += pageSum.usd
126 for (const [m, v] of Object.entries(pageSum.byModel)) byModel[m] = (byModel[m] ?? 0) + v
127 page = pageSum.next
128 if (!page) break
129 }
130 await update($, api, () => projectApi(usdTotal, byModel, bounds, now, apiConfig.creditUsd))
131 } catch (error) {
132 const held = await read($, api)
133 await update($, api, () => ({
134 ...(held ?? projectApi(0, {}, bounds, now, apiConfig.creditUsd)),
135 problem: `Não consegui falar com o Console: ${String(error).slice(0, 120)}`,
136 }))
137 }
138}
139
140const apiStatus = (spend: ApiSpend | null) => (spend && !spend.problem ? apiShort(spend.spentUsd, spend.creditUsd) : undefined)
141
142type SharedReading = { at: number; rateLimits: SessionRateLimit[] }
143
144const readShared = async ($: EngineInterface): Promise<SharedReading | undefined> => {
145 if (!reading.sharedFile) return undefined
146 try {
147 if (!(await $.fs.exists(reading.sharedFile))) return undefined
148 const data = JSON.parse(await $.fs.read(reading.sharedFile)) as Partial<SharedReading>
149 return typeof data.at === 'number' && Array.isArray(data.rateLimits) ? (data as SharedReading) : undefined
150 } catch {
151 return undefined
152 }
153}
154
155const writeShared = async ($: EngineInterface, value: SharedReading) => {
156 if (!reading.sharedFile) return
157 try {
158 await $.fs.write(reading.sharedFile, JSON.stringify(value))
159 } catch {
160 // Sem arquivo compartilhado, cada sessão segue com a própria leitura.
161 }
162}
163
164const resetOf = (w: SessionRateLimit) => (w.resetsAt ? Date.parse(w.resetsAt) : NaN)
165
166const loadMix = async ($: EngineInterface, w: WindowView): Promise<Mix | undefined> => {
167 const held = (await $.store.get(mixKey(w.kind))) as Mix | undefined
168
169 return held && w.resetsAt !== undefined && sameWindow(held.resetsAt, w.resetsAt) ? held : undefined
170}
171
172/**
173 * Recalcula tudo a partir da leitura mais nova da máquina: a desta sessão ou a
174 * que outra sessão gravou no arquivo compartilhado. `record` diz que a leitura
175 * desta sessão acabou de mudar.
176 */
177const refresh = async ($: EngineInterface, usage: SessionUsage, record: boolean) => {
178 const now = await $.clock.now()
179 if (record && usage.rateLimits.length > 0) {
180 reading.ownAt = now
181 reading.own = usage.rateLimits
182 await writeShared($, { at: now, rateLimits: usage.rateLimits })
183 const five = usage.rateLimits.find(w => w.kind === 'five_hour')
184 const week = usage.rateLimits.find(w => w.kind === 'seven_day')
185 if (five && week) {
186 const held = (await $.store.get(CALIBRATION_KEY)) as Calibration | undefined
187 const next = updateCalibration(held, five, week)
188 if (next) await $.store.set(CALIBRATION_KEY, next)
189 }
190 }
191
192 let limits = reading.own.length > 0 ? reading.own : usage.rateLimits
193 let readAt: number | undefined = reading.ownAt || undefined
194 let isShared = false
195 const shared = await readShared($)
196 if (shared && shared.rateLimits.length > 0 && shared.at > (reading.ownAt || 0) + 30_000) {
197 limits = shared.rateLimits
198 readAt = shared.at
199 isShared = true
200 }
201 // O 5h primeiro: o ritmo dele alimenta o do semanal.
202 const ordered = [...limits].sort((a, b) => (a.kind === 'five_hour' ? -1 : b.kind === 'five_hour' ? 1 : 0))
203
204 const perDay = activeHoursPerDay((await $.store.get(ACTIVITY_KEY)) as number[] | undefined, now)
205 const ratio = weekPerFive((await $.store.get(CALIBRATION_KEY)) as Calibration | undefined)
206 let fiveRate: number | undefined
207 const windows: WindowView[] = []
208 const mixes: Record<string, Mix | undefined> = {}
209
210 for (const raw of ordered) {
211 const resetsAt = resetOf(raw)
212 let log = (await $.store.get(samplesKey(raw.kind))) as SampleLog | undefined
213 if ((record || isShared) && !Number.isNaN(resetsAt)) {
214 log = addSample(log, resetsAt, { t: readAt ?? now, pct: raw.percentUsed })
215 await $.store.set(samplesKey(raw.kind), log)
216 }
217 const bucket = (await $.store.get(bucketKey(raw.kind))) as TokenBucket | undefined
218 const view = project(raw, now, log, bucket, { fiveRate, weekPerFive: ratio, activeHoursPerDay: perDay })
219 if (raw.kind === 'five_hour') {
220 // Nos primeiros 30 min de uma janela de 5h o ritmo é só ruído (1% em 9 min viraria 6,7%/h).
221 const elapsed5 = 5 * HOUR - (view.msToReset ?? 5 * HOUR)
222 const recent = view.recentRatePerHour ?? 0
223 fiveRate =
224 elapsed5 < 30 * MINUTE ? undefined : recent > 0 ? recent : (view.windowRatePerHour ?? 0) > 0 ? view.windowRatePerHour : undefined
225 }
226 windows.push(view)
227 mixes[raw.kind] = await loadMix($, view)
228 const projects = (await $.store.get(projectsKey(raw.kind))) as ProjectBucket | undefined
229 if (projects && view.resetsAt !== undefined && sameWindow(projects.resetsAt, view.resetsAt)) {
230 view.projects = projectShares(projects, view.pct)
231 }
232 }
233
234 const recent = recentMix((await $.store.get(HOURLY_KEY)) as HourlyMix | undefined, now, 0)
235 const book = await writeSelf($, usage.cost?.usd)
236 const sessions = reading.selfId ? sessionsView(book, now, reading.selfId) : undefined
237 const next: Snapshot = {
238 updatedAt: now,
239 windows,
240 costUsd: usage.cost?.usd,
241 contextPercent: usage.context.percent,
242 advice: advise(windows, mixes, await read($, setup), recent),
243 readAt,
244 isSharedReading: isShared,
245 sessions,
246 selfId: reading.selfId,
247 }
248 await update($, snapshot, () => next)
249 $.ui.status(
250 statusLine(windows, await read($, session), next.costUsd, readingAge(readAt, now), {
251 sessions: sessions && {
252 count: sessions.total.count,
253 tokens: totalTokens(sessions.total.tokens),
254 costUsd: sessions.total.costUsd,
255 },
256 api: apiStatus(await read($, api)),
257 }),
258 )
259 await alert($, windows, now, next.advice)
260}
261
262/** Avisa uma vez por janela em cada nível, e uma vez por recomendação. */
263const alert = async ($: EngineInterface, windows: WindowView[], now: number, advice?: Advice) => {
264 for (const w of windows) {
265 if (w.resetsAt === undefined) continue
266 const stored = (await $.store.get(alertsKey(w.kind))) as Alerts | undefined
267 const held = stored && sameWindow(stored.resetsAt, w.resetsAt) ? stored.sent : []
268 const sent = [...held]
269 const once = (level: string, text: string) => {
270 if (sent.includes(level)) return
271 sent.push(level)
272 $.ui.toast(text, { timeoutMs: 8000 })
273 }
274
275 if (w.verdict === 'exhausted') {
276 once('100', `Limite ${w.label} esgotado. Volta às ${clockTime(w.resetsAt, now)}.`)
277 } else if (w.pct >= 95) {
278 once('95', `Limite ${w.label} em ${percent(w.pct)}. Reset às ${clockTime(w.resetsAt, now)}.`)
279 } else if (w.pct >= 80) {
280 once('80', `Limite ${w.label} em ${percent(w.pct)}. Reset às ${clockTime(w.resetsAt, now)}.`)
281 }
282 if (w.verdict === 'exhausts' && w.exhaustAt !== undefined && w.pct >= 25) {
283 once(
284 'projection',
285 `No ritmo atual o limite ${w.label} acaba ~${clockTime(w.exhaustAt, now)}, ` +
286 `${duration(w.resetsAt - w.exhaustAt)} antes do reset.`,
287 )
288 }
289 if (advice?.pick && advice.windowKind === w.kind && w.pct >= 15) {
290 once(`advice:${advice.pick}`, `${advice.headline} Veja /limites.`)
291 }
292
293 if (sent.length !== held.length) {
294 await $.store.set(alertsKey(w.kind), { resetsAt: w.resetsAt, sent })
295 }
296 }
297}
298
299/** Soma os tokens de uma resposta às janelas abertas. */
300const countTokens = async ($: EngineInterface, tokens: number) => {
301 const last = await read($, snapshot)
302 for (const w of last?.windows ?? []) {
303 if (w.resetsAt === undefined) continue
304 const held = (await $.store.get(bucketKey(w.kind))) as TokenBucket | undefined
305 const bucket: TokenBucket =
306 held && sameWindow(held.resetsAt, w.resetsAt)
307 ? { ...held, tokens: held.tokens + tokens }
308 : { resetsAt: w.resetsAt, tokens, firstPct: w.pct }
309 await $.store.set(bucketKey(w.kind), bucket)
310 }
311}
312
313/** Soma uma resposta ao gasto por modelo e por papel de cada janela aberta. */
314const countMix = async ($: EngineInterface, role: 'main' | 'sub', usage: TurnUsage) => {
315 const last = await read($, snapshot)
316 const tokens = {
317 input: usage.input_tokens,
318 output: usage.output_tokens,
319 cacheRead: usage.cache_read_input_tokens,
320 cacheWrite: usage.cache_creation_input_tokens,
321 }
322 for (const w of last?.windows ?? []) {
323 if (w.resetsAt === undefined) continue
324 const held = (await loadMix($, w)) ?? emptyMix(w.resetsAt)
325 await $.store.set(mixKey(w.kind), addToMix(held, role, usage.model, tokens))
326 }
327}
328
329/** Anota a hora de trabalho e soma a resposta ao gasto por hora (para a recomendação). */
330const countRecent = async ($: EngineInterface, role: 'main' | 'sub', usage: TurnUsage) => {
331 const now = await $.clock.now()
332 await $.store.set(ACTIVITY_KEY, addActivity((await $.store.get(ACTIVITY_KEY)) as number[] | undefined, now))
333 const tokens = {
334 input: usage.input_tokens,
335 output: usage.output_tokens,
336 cacheRead: usage.cache_read_input_tokens,
337 cacheWrite: usage.cache_creation_input_tokens,
338 }
339 const held = (await $.store.get(HOURLY_KEY)) as HourlyMix | undefined
340 await $.store.set(HOURLY_KEY, addHourly(held, now, role, modelKey(usage.model), tokens))
341}
342
343/** Soma o custo de uma resposta ao projeto da sessão em cada janela aberta. */
344const countProject = async ($: EngineInterface, usage: TurnUsage) => {
345 const name = await read($, currentProject)
346 const last = await read($, snapshot)
347 if (!name) return
348 const cost = costOf(
349 {
350 input: usage.input_tokens,
351 output: usage.output_tokens,
352 cacheRead: usage.cache_read_input_tokens,
353 cacheWrite: usage.cache_creation_input_tokens,
354 },
355 modelKey(usage.model),
356 )
357 for (const w of last?.windows ?? []) {
358 if (w.resetsAt === undefined) continue
359 const held = (await $.store.get(projectsKey(w.kind))) as ProjectBucket | undefined
360 const bucket: ProjectBucket =
361 held && sameWindow(held.resetsAt, w.resetsAt) ? held : { resetsAt: w.resetsAt, firstPct: w.pct, byProject: {} }
362 bucket.byProject[name] = (bucket.byProject[name] ?? 0) + cost
363 await $.store.set(projectsKey(w.kind), bucket)
364 }
365}
366
367/** Raiz do git da pasta da sessão, ou a própria pasta. */
368const findProject = async ($: EngineInterface, cwd: string) => {
369 try {
370 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 })
371 return projectName(exitCode === 0 && stdout.trim() ? stdout.trim() : cwd)
372 } catch {
373 return projectName(cwd)
374 }
375}
376
377const setEconomy = async ($: EngineInterface, on: boolean) => {
378 await update($, economy, () => on)
379 await $.store.set(ECONOMY_KEY, on)
380 $.ui.toast(
381 on
382 ? 'Modo economia ligado: o fio principal orquestra, os novos subagentes executam em Haiku 5.5.'
383 : 'Modo economia desligado: os subagentes voltam ao modelo de sempre.',
384 )
385}
386
387/**
388 * O botão ↻: relê a leitura mais nova da máquina, recalcula, atualiza a lista
389 * de sessões e os créditos de API. O % da conta em si só muda quando alguma
390 * sessão recebe uma resposta da API (uma chamada avulsa não o traz).
391 */
392const reloadAll = async ($: EngineInterface) => {
393 if (apiConfig.key) await refreshApi($)
394 await refresh($, await $.session.usage(), false)
395 const age = readingAge((await read($, snapshot))?.readAt, await $.clock.now())
396 $.ui.toast(
397 age
398 ? `Atualizado. A leitura da conta é de ${age}; um número novo chega na próxima resposta do Claude em qualquer sessão desta máquina.`
399 : 'Atualizado com a leitura mais recente da conta.',
400 { timeoutMs: 6000 },
401 )
402}
403
404const actionsFor = ($: EngineInterface, withDetails: boolean): Actions => ({
405 details: withDetails ? () => void $.ui.open({ id: PANE, title: 'Limites' }) : undefined,
406 reload: () => void reloadAll($),
407 toggleEconomy: () => void read($, economy).then(on => setEconomy($, !on)),
408 fillModel: alias => void $.prompt.fill({ text: `/model ${alias}` }),
409})
410
411const viewData = async ($: EngineInterface, snap: Snapshot): Promise<ViewData> => ({
412 snap,
413 totals: await read($, session),
414 economy: await read($, economy),
415 api: await read($, api),
416})
417
418export const register: Register = (on, options) => {
419 apiConfig.key = String(options.adminApiKey ?? '').trim()
420 apiConfig.creditUsd = Number(options.apiMonthlyCredit ?? 200)
421 apiConfig.cycleDay = Number(options.apiCycleDay ?? 1)
422
423 /** Subagentes já decididos: true roda em Haiku. Um agente nunca troca no meio. */
424 const haikuAgents = new Map<string, boolean>()
425 const forks = new Set<string>()
426
427 on('session.start', async ($, e, next) => {
428 const started = await next(e)
429 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
430 const dir = home ? `${home.replaceAll('\\', '/')}/.claude/usage-limits` : ''
431 reading.sharedFile = dir ? `${dir}/latest.json` : ''
432 reading.sessionsFile = dir ? `${dir}/sessions.json` : ''
433 reading.selfId = await $.session.id()
434 reading.startedAt = (await $.session.usage()).startedAt
435 const name = await findProject($, started.cwd)
436 await update($, currentProject, () => name)
437 await $.command.register({
438 name: COMMAND,
439 description: 'Mostra consumo de tokens, previsão dos limites e qual modelo usar',
440 })
441 await $.command.register({
442 name: ECONOMY_COMMAND,
443 description: 'Liga/desliga o modo economia: o principal orquestra, subagentes executam em Haiku',
444 })
445 const isEconomy = (await $.store.get(ECONOMY_KEY)) === true
446 await update($, economy, () => isEconomy)
447 // O modelo da sessão já ao abrir, para a recomendação não dizer "o modelo atual".
448 try {
449 const model = await $.session.model()
450 if (model) await update($, setup, held => (held.mainModel ? held : { ...held, mainModel: model }))
451 } catch {
452 // Sem o modelo agora, ele chega na primeira resposta.
453 }
454 await refresh($, await $.session.usage(), false)
455 $.clock.every(60_000, () => {
456 void $.session.usage().then(usage => refresh($, usage, false))
457 })
458 if (apiConfig.key) {
459 void refreshApi($)
460 $.clock.every(10 * 60_000, () => void refreshApi($))
461 }
462
463 return started
464 })
465
466 on('session.measure', async ($, e, next) => {
467 const result = await next(e)
468 await refresh($, { startedAt: 0, ...e }, e.changed.includes('rateLimits'))
469
470 return result
471 })
472
473 on('agent.spawn', async ($, e, next) => {
474 const result = await next(e)
475 if (e.fork && result.agentId) forks.add(result.agentId)
476
477 return result
478 })
479
480 // Cada chamada ao modelo: anota o modelo principal, põe subagentes em Haiku
481 // no modo economia e soma o gasto por modelo e papel.
482 on('turn.step', async function* ($, e, next) {
483 let step = e
484 if (e.agentId) {
485 let isHaiku = haikuAgents.get(e.agentId)
486 if (isHaiku === undefined) {
487 isHaiku = e.index === 0 && !forks.has(e.agentId) && (await read($, economy))
488 haikuAgents.set(e.agentId, isHaiku)
489 }
490 if (isHaiku && family(modelKey(e.model)) !== 'haiku') step = { ...e, model: HAIKU_ID }
491 } else {
492 const held = await read($, setup)
493 if (held.mainModel !== e.model || held.mainEffort !== e.effort) {
494 const effort = typeof e.effort === 'number' ? String(e.effort) : e.effort
495 await update($, setup, () => ({ mainModel: e.model, mainEffort: effort }))
496 }
497 }
498
499 const result = yield* next(step)
500 if (result.usage) {
501 await countMix($, e.agentId ? 'sub' : 'main', result.usage)
502 await countProject($, result.usage)
503 await countRecent($, e.agentId ? 'sub' : 'main', result.usage)
504 }
505
506 return result
507 })
508
509 on('turn.complete', async ($, e, next) => {
510 const result = await next(e)
511 const usage = e.usage
512 if (usage) {
513 const add: Totals = {
514 input: usage.input_tokens,
515 output: usage.output_tokens,
516 cacheRead: usage.cache_read_input_tokens,
517 cacheWrite: usage.cache_creation_input_tokens,
518 turns: e.agentId ? 0 : 1,
519 }
520 const totals = await update($, session, t => ({
521 input: t.input + add.input,
522 output: t.output + add.output,
523 cacheRead: t.cacheRead + add.cacheRead,
524 cacheWrite: t.cacheWrite + add.cacheWrite,
525 turns: t.turns + add.turns,
526 }))
527 await countTokens($, totalTokens(add))
528 const last = await read($, snapshot)
529 await writeSelf($, last?.costUsd)
530 $.ui.status(
531 statusLine(last?.windows ?? [], totals, last?.costUsd, readingAge(last?.readAt, await $.clock.now()), {
532 api: apiStatus(await read($, api)),
533 }),
534 )
535 }
536
537 return result
538 })
539
540 // Ao fechar: marca a sessão como encerrada na lista da máquina (dentro do tempo curto do fim).
541 on('session.end', async ($, e, next) => {
542 try {
543 await writeSelf($, (await read($, snapshot))?.costUsd, true)
544 } catch {
545 // O fim da sessão não espera pelo mod.
546 }
547 return next(e)
548 })
549
550 on('command.run', { command: COMMAND }, async $ => {
551 if (apiConfig.key) void refreshApi($)
552 await refresh($, await $.session.usage(), false)
553 await $.ui.open({ id: PANE, title: 'Limites' })
554
555 return { text: 'Painel de limites aberto.' }
556 })
557
558 on('command.run', { command: ECONOMY_COMMAND }, async ($, e) => {
559 const arg = e.args.trim().toLowerCase()
560 const isOn = arg === 'on' || arg === 'ligar' ? true : arg === 'off' || arg === 'desligar' ? false : !(await read($, economy))
561 await setEconomy($, isOn)
562 await refresh($, await $.session.usage(), false)
563
564 return {
565 text: isOn
566 ? 'Modo economia ligado: novos subagentes rodam em Haiku 5.5; o fio principal continua no modelo atual.'
567 : 'Modo economia desligado.',
568 }
569 })
570
571 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
572 const snap = await read($, snapshot)
573 if (e.surface === 'terminal') {
574 const ui = $.ui.resolve(e)
575 if (snap === null) return <ui.Text dimColor>Aguardando a primeira leitura…</ui.Text>
576
577 return TerminalPane(ui, await viewData($, snap), e.props.bodyColumns, actionsFor($, false))
578 }
579 const ui = $.ui.resolve(e)
580 if (snap === null) return <ui.Text dimColor>Aguardando a primeira leitura…</ui.Text>
581
582 return RichPane(ui, await viewData($, snap), e.props.bodyColumns, actionsFor($, false))
583 })
584
585 // Faixa acima do prompt: barras SVG no desktop, texto no terminal.
586 // Empilha sobre o que outros plugins desenham na faixa, em vez de substituir.
587 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
588 const below = await next(e)
589 if (e.props.hasSurvey) return below
590 const snap = await read($, snapshot)
591 if (snap === null || snap.windows.length === 0) return below
592 const data = await viewData($, snap)
593 if (e.surface === 'terminal') {
594 const ui = $.ui.resolve(e)
595 return (
596 <ui.Box flexDirection="column">
597 {TerminalBand(ui, data, actionsFor($, true))}
598 {below}
599 </ui.Box>
600 )
601 }
602 if (e.surface !== 'desktop') return below
603 const ui = $.ui.resolve(e)
604
605 return (
606 <ui.Box flexDirection="column">
607 {RichBand(ui, data, actionsFor($, true))}
608 {below}
609 </ui.Box>
610 )
611 })
612}
613hooks/api.ts 105 lines1import type { ApiSpend } from '../types'
2import { DAY } from './projection'
3
4/** O ciclo dos créditos: começa no dia `day` de cada mês, à meia-noite local. */
5export const cycleBounds = (now: number, day: number): { start: number; end: number } => {
6 const d = new Date(now)
7 const clamp = (year: number, month: number) => Math.min(Math.max(1, Math.round(day)), new Date(year, month + 1, 0).getDate())
8 let start = new Date(d.getFullYear(), d.getMonth(), clamp(d.getFullYear(), d.getMonth()))
9 if (start.getTime() > now) {
10 const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
11 start = new Date(prev.getFullYear(), prev.getMonth(), clamp(prev.getFullYear(), prev.getMonth()))
12 }
13 const after = new Date(start.getFullYear(), start.getMonth() + 1, 1)
14 const end = new Date(after.getFullYear(), after.getMonth(), clamp(after.getFullYear(), after.getMonth()))
15
16 return { start: start.getTime(), end: end.getTime() }
17}
18
19type CostReport = {
20 data?: { starting_at?: string; results?: { amount?: string; model?: string | null; description?: string | null }[] }[]
21 has_more?: boolean
22 next_page?: string | null
23}
24
25/** Soma uma página do relatório de custo: total e por modelo, em US$. */
26export const sumCostReport = (json: string): { usd: number; byModel: Record<string, number>; next?: string } => {
27 const report = JSON.parse(json) as CostReport
28 let cents = 0
29 const byModel: Record<string, number> = {}
30 for (const bucket of report.data ?? []) {
31 for (const item of bucket.results ?? []) {
32 const amount = Number(item.amount ?? 0)
33 if (!Number.isFinite(amount)) continue
34 cents += amount
35 const model = item.model ?? (item.description?.split(' Usage')[0] || 'outros')
36 byModel[model] = (byModel[model] ?? 0) + amount / 100
37 }
38 }
39
40 return { usd: cents / 100, byModel, next: report.has_more && report.next_page ? report.next_page : undefined }
41}
42
43export const costReportUrl = (start: number, now: number, page?: string) => {
44 // Os baldes são diários em UTC: pede do dia do início até o fim de hoje.
45 const from = new Date(start)
46 from.setUTCHours(0, 0, 0, 0)
47 const to = new Date(now + DAY)
48 to.setUTCHours(0, 0, 0, 0)
49 const params = [
50 `starting_at=${encodeURIComponent(from.toISOString())}`,
51 `ending_at=${encodeURIComponent(to.toISOString())}`,
52 'group_by[]=description',
53 'limit=31',
54 ]
55 if (page) params.push(`page=${encodeURIComponent(page)}`)
56
57 return `https://api.anthropic.com/v1/organizations/cost_report?${params.join('&')}`
58}
59
60/** Quanto do crédito foi gasto, o ritmo por dia e quando acaba nesse ritmo. */
61export const projectApi = (
62 spentUsd: number,
63 byModel: Record<string, number>,
64 bounds: { start: number; end: number },
65 now: number,
66 creditUsd: number,
67): ApiSpend => {
68 const elapsedDays = Math.max(1, (now - bounds.start) / DAY)
69 const dailyUsd = spentUsd / elapsedDays
70 const daysLeft = Math.max(0, (bounds.end - now) / DAY)
71 const projectedUsd = spentUsd + dailyUsd * daysLeft
72 const spend: ApiSpend = {
73 cycleStart: bounds.start,
74 cycleEnd: bounds.end,
75 spentUsd,
76 creditUsd,
77 byModel: Object.entries(byModel)
78 .map(([model, usd]) => ({ model, usd }))
79 .filter(m => m.usd > 0)
80 .sort((a, b) => b.usd - a.usd),
81 dailyUsd,
82 projectedUsd,
83 fetchedAt: now,
84 }
85 if (dailyUsd > 0 && projectedUsd > creditUsd) {
86 spend.runsOutAt = now + ((creditUsd - spentUsd) / dailyUsd) * DAY
87 }
88
89 return spend
90}
91
92/** O motivo de a chave não funcionar, em palavras. */
93export const apiProblem = (status: number, text: string) => {
94 if (status === 401 || status === 403) {
95 return (
96 'A chave não tem acesso ao relatório de custo. Ela precisa ser uma Admin API key (sk-ant-admin…) da ' +
97 'organização ligada aos créditos; contas individuais do Console não têm Admin API (crie uma organização ' +
98 'em Console > Settings > Organization).'
99 )
100 }
101 if (status === 429) return 'O Console pediu para esperar; tento de novo em alguns minutos.'
102
103 return `O Console respondeu ${status}: ${text.slice(0, 160)}`
104}
105hooks/sessions.ts 49 lines1import type { SessionRecord, SessionsView, Totals } from '../types'
2import { DAY, MINUTE, totalTokens } from './projection'
3
4/** Uma sessão sem sinal há mais de 3 min é tratada como fechada. */
5export const RUNNING_WINDOW = 3 * MINUTE
6
7export type SessionBook = Record<string, SessionRecord>
8
9const emptyTotals = (): Totals => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 })
10
11/** Grava o registro desta sessão no livro da máquina e descarta os de mais de 2 dias. */
12export const upsertSession = (book: SessionBook | undefined, record: SessionRecord, now: number): SessionBook => {
13 const next: SessionBook = {}
14 for (const [id, r] of Object.entries(book ?? {})) if (now - r.updatedAt < 2 * DAY) next[id] = r
15 next[record.id] = record
16
17 return next
18}
19
20const sameDay = (a: number, b: number) => new Date(a).toDateString() === new Date(b).toDateString()
21
22export const isRunning = (r: SessionRecord, now: number) => !r.ended && now - r.updatedAt < RUNNING_WINDOW
23
24/** As sessões abertas agora (esta primeiro, depois as que mais gastaram) e o total de hoje. */
25export const sessionsView = (book: SessionBook | undefined, now: number, selfId: string): SessionsView => {
26 const all = Object.values(book ?? {})
27 const running = all
28 .filter(r => r.id === selfId || isRunning(r, now))
29 .sort((a, b) => (a.id === selfId ? -1 : b.id === selfId ? 1 : b.costUsd - a.costUsd))
30 const today = all.filter(r => sameDay(r.updatedAt, now))
31 const sum = (list: SessionRecord[]) => {
32 const tokens = emptyTotals()
33 let costUsd = 0
34 for (const r of list) {
35 tokens.input += r.tokens.input
36 tokens.output += r.tokens.output
37 tokens.cacheRead += r.tokens.cacheRead
38 tokens.cacheWrite += r.tokens.cacheWrite
39 tokens.turns += r.tokens.turns
40 costUsd += r.costUsd
41 }
42 return { tokens, costUsd, count: list.length }
43 }
44
45 return { running, total: sum(running), today: sum(today) }
46}
47
48export const sessionTokens = (r: SessionRecord) => totalTokens(r.tokens)
49hooks/advisor.ts 169 lines1import type { Advice, Mix, Scenario, ScenarioId, Setup, Tokens, WindowView } from '../types'
2import { HAIKU, SONNET, costOf, family, modelKey, modelLabel } from './pricing'
3import { HOUR, hours, percent } from './projection'
4
5export const emptyMix = (resetsAt: number): Mix => ({ resetsAt, main: {}, sub: {} })
6
7/** Junta os tokens de uma resposta ao modelo e ao papel que a fizeram. */
8export const addToMix = (mix: Mix, role: 'main' | 'sub', model: string, t: Tokens): Mix => {
9 const key = modelKey(model)
10 const held = mix[role][key] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
11
12 return {
13 ...mix,
14 [role]: {
15 ...mix[role],
16 [key]: {
17 input: held.input + t.input,
18 output: held.output + t.output,
19 cacheRead: held.cacheRead + t.cacheRead,
20 cacheWrite: held.cacheWrite + t.cacheWrite,
21 },
22 },
23 }
24}
25
26const costAt = (byModel: Record<string, Tokens>, as?: string) =>
27 Object.entries(byModel).reduce((sum, [key, t]) => sum + costOf(t, as ?? key), 0)
28
29const windowName = (kind: string) =>
30 kind === 'five_hour' ? 'de 5h' : kind.startsWith('seven_day') ? 'semanal' : kind
31
32/** Do menos ao mais disruptivo para a qualidade do trabalho. */
33const ORDER: ScenarioId[] = ['sub-haiku', 'all-sonnet', 'main-sonnet-sub-haiku', 'all-haiku']
34
35/** Abaixo disso o limite chega ao reset com folga. */
36const COMFORT = 85
37
38/**
39 * Recomenda a troca de modelo menos disruptiva que faz o limite mais apertado
40 * durar até o reset, estimando o gasto de cada cenário pelo preço de API.
41 */
42export const advise = (
43 windows: WindowView[],
44 mixes: Record<string, Mix | undefined>,
45 setup: Setup,
46 recent?: Mix,
47): Advice | undefined => {
48 const open = windows.filter(
49 w => w.ratePerHour !== undefined && w.msToReset !== undefined && w.verdict !== 'exhausted',
50 )
51 const binding = open.reduce<WindowView | undefined>(
52 (worst, w) => (worst === undefined || (w.pctAtReset ?? 0) > (worst.pctAtReset ?? 0) ? w : worst),
53 undefined,
54 )
55 if (binding === undefined) return undefined
56
57 const mainKey = setup.mainModel ? modelKey(setup.mainModel) : undefined
58 const mainName = mainKey ? modelLabel(mainKey) : 'o modelo atual'
59 const name = windowName(binding.kind)
60 // O gasto das últimas horas mostra o que você está usando agora; a janela toda só na falta dele.
61 const mix = recent ?? mixes[binding.kind]
62 const mainCost = mix ? costAt(mix.main) : 0
63 const subCost = mix ? costAt(mix.sub) : 0
64 const total = mainCost + subCost
65 const subModels = mix ? Object.keys(mix.sub).filter(k => costOf(mix.sub[k]!, k) > 0) : []
66 const advice: Advice = {
67 windowKind: binding.kind,
68 windowLabel: binding.label,
69 mainModel: mainKey,
70 mainEffort: setup.mainEffort,
71 subShare: total > 0 ? subCost / total : 0,
72 subModels,
73 scenarios: [],
74 isFine: binding.verdict === 'ok',
75 mixBasis: recent ? 'recent' : 'window',
76 headline: '',
77 }
78 if (setup.mainEffort === 'xhigh' || setup.mainEffort === 'max') {
79 advice.tip = `Baixar o effort de ${setup.mainEffort} para high (/effort high) também reduz o gasto do fio principal.`
80 }
81
82 if (total > 0 && mix) {
83 const costs: Record<ScenarioId, number> = {
84 'sub-haiku': mainCost + costAt(mix.sub, HAIKU),
85 'all-sonnet': costAt(mix.main, SONNET) + costAt(mix.sub, SONNET),
86 'main-sonnet-sub-haiku': costAt(mix.main, SONNET) + costAt(mix.sub, HAIKU),
87 'all-haiku': costAt(mix.main, HAIKU) + costAt(mix.sub, HAIKU),
88 }
89 const labels: Record<ScenarioId, string> = {
90 'sub-haiku': `${mainName} orquestra, Haiku executa`,
91 'all-sonnet': 'Sonnet 5.5 em tudo',
92 'main-sonnet-sub-haiku': 'Sonnet orquestra, Haiku executa',
93 'all-haiku': 'Haiku 5.5 em tudo',
94 }
95 const rate = binding.ratePerHour ?? 0
96 for (const id of ORDER) {
97 const factor = costs[id] / total
98 const seen = advice.scenarios.some(s => Math.abs(s.factor - factor) < 0.02)
99 if (factor > 0.97 || seen) continue
100 const scenarioRate = rate * factor
101 const pctAtReset = binding.pct + scenarioRate * (binding.hoursAhead ?? (binding.msToReset ?? 0) / HOUR)
102 advice.scenarios.push({
103 id,
104 label: labels[id],
105 factor,
106 hoursLeft: scenarioRate > 0 ? (100 - binding.pct) / scenarioRate : undefined,
107 pctAtReset,
108 lasts: pctAtReset < 100,
109 })
110 }
111 }
112
113 const unit = ''
114
115 if (advice.isFine) {
116 advice.headline =
117 `✓ Mantendo o uso atual, seu plano dura até o reset ` +
118 `(~${percent(binding.pctAtReset ?? binding.pct)} do limite ${name} no fim).`
119
120 return advice
121 }
122
123 const pick =
124 advice.scenarios.find(s => s.pctAtReset < COMFORT) ?? advice.scenarios.find(s => s.lasts)
125 const current =
126 `Você está usando ${mainName}` +
127 (setup.mainEffort ? ` (effort ${setup.mainEffort})` : '') +
128 (advice.subShare >= 0.1
129 ? ` e ${percent(advice.subShare * 100)} do gasto vem de subagentes em ${subModels.map(modelLabel).join(', ')}`
130 : '')
131
132 if (pick === undefined) {
133 const best = advice.scenarios.reduce<Scenario | undefined>(
134 (low, s) => (low === undefined || s.factor < low.factor ? s : low),
135 undefined,
136 )
137 advice.headline =
138 best === undefined
139 ? `⚠ No ritmo atual o limite ${name} acaba antes do reset. Use ${mainName} só para orquestrar e ` +
140 'deixe a execução para subagentes em Haiku.'
141 : `⚠ ${current}. Nem com ${best.label} o limite ${name} chega ao reset; ` +
142 `assim ele dura ~${hours(best.hoursLeft ?? 0)}${unit} em vez de ~${hours(binding.hoursLeft ?? 0)}${unit}.`
143
144 return advice
145 }
146
147 advice.pick = pick.id
148 const action: Record<ScenarioId, string> = {
149 'sub-haiku': `use ${mainName} para orquestrar e Haiku para executar`,
150 'all-sonnet': 'troque para Sonnet 5.5 (/model sonnet)',
151 'main-sonnet-sub-haiku': 'use Sonnet 5.5 para orquestrar (/model sonnet) e Haiku para executar',
152 'all-haiku': 'use Haiku 5.5 em tudo (/model haiku)',
153 }
154 advice.headline =
155 `💡 ${current}. Para não ficar sem uso, ${action[pick.id]}: ` +
156 `o limite ${name} passa a durar ~${hours(pick.hoursLeft ?? 0)}${unit} e seu plano dura até o reset ` +
157 `(~${percent(pick.pctAtReset)} no fim).`
158
159 return advice
160}
161
162/** Se um cenário põe os subagentes em Haiku (o que o modo economia faz). */
163export const usesSubHaiku = (id: ScenarioId | undefined) =>
164 id === 'sub-haiku' || id === 'main-sonnet-sub-haiku' || id === 'all-haiku'
165
166/** Se um cenário pede outro modelo no fio principal. */
167export const changesMain = (id: ScenarioId | undefined, mainModel?: string) =>
168 (id === 'all-sonnet' || id === 'main-sonnet-sub-haiku') && family(mainModel ?? '') !== 'sonnet'
169hooks/pace.ts 136 lines1import type { Mix, Tokens } from '../types'
2import { HOUR, sameWindow } from './projection'
3import type { RawWindow } from './projection'
4
5/**
6 * Quantos pontos do semanal custa cada ponto do limite de 5h, medido nas
7 * mesmas respostas. O de 5h anda rápido; com essa razão, o ritmo semanal
8 * passa a refletir a última hora de trabalho, não o dia inteiro.
9 */
10export type Calibration = {
11 /** Versão da regra de medição; uma calibração de outra versão começa do zero. */
12 v?: number
13 weekResetsAt: number
14 sum5: number
15 sumWeek: number
16 last?: { p5: number; reset5: number; pWeek: number }
17}
18
19const resetOf = (w: RawWindow) => (w.resetsAt ? Date.parse(w.resetsAt) : NaN)
20
21export const updateCalibration = (
22 held: Calibration | undefined,
23 five: RawWindow,
24 week: RawWindow,
25): Calibration | undefined => {
26 const weekReset = resetOf(week)
27 const reset5 = resetOf(five)
28 if (Number.isNaN(weekReset) || Number.isNaN(reset5)) return held
29 const c: Calibration =
30 held && held.v === CALIBRATION_VERSION && sameWindow(held.weekResetsAt, weekReset)
31 ? { ...held }
32 : { v: CALIBRATION_VERSION, weekResetsAt: weekReset, sum5: 0, sumWeek: 0 }
33 // Só mede dentro da mesma janela de 5h. Entre janelas o mod não vê o fim da
34 // anterior, e o salto do semanal pareceria vir de quase nada do 5h.
35 if (c.last && sameWindow(c.last.reset5, reset5)) {
36 const d5 = five.percentUsed - c.last.p5
37 const dWeek = week.percentUsed - c.last.pWeek
38 if (d5 >= 0 && dWeek >= 0) {
39 c.sum5 += d5
40 c.sumWeek += dWeek
41 }
42 }
43 c.last = { p5: five.percentUsed, reset5, pWeek: week.percentUsed }
44
45 return c
46}
47
48export const CALIBRATION_VERSION = 2
49
50/**
51 * Pontos do semanal por ponto do 5h; só com movimento suficiente para medir e
52 * dentro de uma faixa plausível (um ponto do 5h nunca vale mais que 1,5 do semanal).
53 */
54export const weekPerFive = (c: Calibration | undefined) => {
55 if (!c || c.v !== CALIBRATION_VERSION || c.sum5 < 6 || c.sumWeek <= 0) return undefined
56 const ratio = c.sumWeek / c.sum5
57
58 return ratio >= 0.05 && ratio <= 1.5 ? ratio : undefined
59}
60
61// ---------- horas de trabalho por dia ----------
62
63/** As horas (índice `floor(t / 1h)`) em que houve resposta, dos últimos 14 dias. */
64export const addActivity = (hours: number[] | undefined, now: number): number[] => {
65 const h = Math.floor(now / HOUR)
66 const list = hours ?? []
67 if (list.includes(h)) return list
68
69 return [...list, h].filter(x => x > h - 14 * 24).sort((a, b) => a - b)
70}
71
72/**
73 * Horas de trabalho por dia. Com menos de 2 dias de histórico, supõe pelo menos
74 * 8h por dia, para não tratar a primeira manhã como o ritmo da semana inteira.
75 */
76export const activeHoursPerDay = (hours: number[] | undefined, now: number): number => {
77 const list = hours ?? []
78 const nowH = Math.floor(now / HOUR)
79 const first = list[0]
80 // Dias de calendário cobertos pelo histórico, contando o primeiro dia inteiro.
81 const days = first === undefined ? 0 : Math.ceil((nowH - first + 1) / 24)
82 if (days < 2) return Math.max(8, list.filter(h => h > nowH - 24).length)
83 const span = Math.min(7, days)
84 const inSpan = list.filter(h => h > nowH - span * 24).length
85
86 return Math.min(24, Math.max(1, inSpan / span))
87}
88
89// ---------- gasto recente por modelo ----------
90
91export type HourlyMix = Record<string, { main: Record<string, Tokens>; sub: Record<string, Tokens> }>
92
93const addTokens = (a: Tokens | undefined, b: Tokens): Tokens => ({
94 input: (a?.input ?? 0) + b.input,
95 output: (a?.output ?? 0) + b.output,
96 cacheRead: (a?.cacheRead ?? 0) + b.cacheRead,
97 cacheWrite: (a?.cacheWrite ?? 0) + b.cacheWrite,
98})
99
100/** Soma uma resposta ao balde da hora atual; guarda as últimas 72 horas. */
101export const addHourly = (
102 held: HourlyMix | undefined,
103 now: number,
104 role: 'main' | 'sub',
105 modelKey: string,
106 t: Tokens,
107): HourlyMix => {
108 const h = Math.floor(now / HOUR)
109 const next: HourlyMix = {}
110 for (const [k, v] of Object.entries(held ?? {})) if (Number(k) > h - 72) next[k] = v
111 const bucket = next[h] ?? { main: {}, sub: {} }
112 next[h] = { ...bucket, [role]: { ...bucket[role], [modelKey]: addTokens(bucket[role][modelKey], t) } }
113
114 return next
115}
116
117/** O gasto das últimas `count` horas com resposta, dentro das últimas 12h. */
118export const recentMix = (held: HourlyMix | undefined, now: number, resetsAt: number, count = 3): Mix | undefined => {
119 const h = Math.floor(now / HOUR)
120 const hours = Object.keys(held ?? {})
121 .map(Number)
122 .filter(k => k > h - 12)
123 .sort((a, b) => b - a)
124 .slice(0, count)
125 if (hours.length === 0) return undefined
126 const mix: Mix = { resetsAt, main: {}, sub: {} }
127 for (const k of hours) {
128 const bucket = held![k]!
129 for (const role of ['main', 'sub'] as const) {
130 for (const [model, t] of Object.entries(bucket[role])) mix[role][model] = addTokens(mix[role][model], t)
131 }
132 }
133
134 return mix
135}
136hooks/pricing.ts 79 lines1import type { Tokens } from '../types'
2
3/** US$ por milhão de tokens, preço de API (tabela de 2026-10). */
4type Price = { input: number; output: number; cacheWrite: number; cacheRead: number }
5
6const p = (input: number, output: number, cacheRead: number): Price => ({
7 input,
8 output,
9 cacheWrite: input * 1.25,
10 cacheRead,
11})
12
13const PRICES: Record<string, Price> = {
14 'fable-5-1': p(10, 50, 0.25),
15 'fable-5': p(10, 50, 1),
16 'mythos-5-1': p(10, 50, 0.25),
17 'opus-5-5': p(4, 20, 0.2),
18 'opus-5': p(5, 25, 0.5),
19 'opus-4-8': p(5, 25, 0.5),
20 'opus-4-7': p(5, 25, 0.5),
21 'opus-4-6': p(5, 25, 0.5),
22 'opus-4-5': p(5, 25, 0.5),
23 'opus-4-1': p(15, 75, 1.5),
24 'opus-4': p(15, 75, 1.5),
25 'sonnet-5-5': p(2, 10, 0.2),
26 'sonnet-5': p(2, 10, 0.2),
27 'sonnet-4-6': p(3, 15, 0.3),
28 'sonnet-4-5': p(3, 15, 0.3),
29 'sonnet-4': p(3, 15, 0.3),
30 'haiku-5-5': p(0.1, 0.5, 0.01),
31 'haiku-4-5': p(1, 5, 0.1),
32}
33
34/** Modelo atual de cada família, para ids que a tabela não conhece. */
35const LATEST: Record<string, string> = {
36 fable: 'fable-5-1',
37 mythos: 'mythos-5-1',
38 opus: 'opus-5-5',
39 sonnet: 'sonnet-5-5',
40 haiku: 'haiku-5-5',
41}
42
43export const HAIKU = 'haiku-5-5'
44export const SONNET = 'sonnet-5-5'
45export const HAIKU_ID = 'claude-haiku-5-5'
46
47/** `claude-opus-5-5[1m]`, `claude-opus-4-5-20251101` → `opus-5-5`, `opus-4-5`. */
48export const modelKey = (id: string): string => {
49 const m = /(fable|mythos|opus|sonnet|haiku)-(\d{1,2})(?:-(\d{1,2})(?!\d))?/.exec(id.toLowerCase())
50 if (!m) return id
51 const key = m[3] ? `${m[1]}-${m[2]}-${m[3]}` : `${m[1]}-${m[2]}`
52
53 return PRICES[key] ? key : (LATEST[m[1] ?? ''] ?? key)
54}
55
56export const family = (key: string) => key.split('-')[0] ?? key
57
58/** `opus-5-5` → `Opus 5.5`. */
59export const modelLabel = (key: string): string => {
60 const [name = key, ...version] = key.split('-')
61
62 return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${version.join('.')}`.trim()
63}
64
65const priceOf = (key: string): Price => PRICES[key] ?? PRICES[LATEST[family(key)] ?? ''] ?? p(4, 20, 0.2)
66
67/** Custo em US$ de um lote de tokens num modelo. */
68export const costOf = (t: Tokens, key: string): number => {
69 const price = priceOf(key)
70
71 return (
72 (t.input * price.input +
73 t.output * price.output +
74 t.cacheWrite * price.cacheWrite +
75 t.cacheRead * price.cacheRead) /
76 1e6
77 )
78}
79hooks/projection.ts 291 lines1import type { ProjectBucket, ProjectShare, Totals, Verdict, WindowView } from '../types'
2
3export const MINUTE = 60_000
4export const HOUR = 60 * MINUTE
5export const DAY = 24 * HOUR
6
7export type Sample = { t: number; pct: number }
8
9/** Amostras de uma janela, guardadas em $.store entre sessões. */
10export type SampleLog = { resetsAt: number; points: Sample[] }
11
12/** Tokens contados numa janela, guardados em $.store entre sessões. */
13export type TokenBucket = { resetsAt: number; tokens: number; firstPct: number }
14
15export type RawWindow = { kind: string; percentUsed: number; resetsAt?: string }
16
17export const emptyTotals = (): Totals => ({
18 input: 0,
19 output: 0,
20 cacheRead: 0,
21 cacheWrite: 0,
22 turns: 0,
23})
24
25export const totalTokens = (t: Totals) =>
26 t.input + t.output + t.cacheRead + t.cacheWrite
27
28export const windowLength = (kind: string): number | undefined =>
29 kind === 'five_hour' ? 5 * HOUR : kind.startsWith('seven_day') ? 7 * DAY : undefined
30
31/** Quanto tempo de histórico vale como "ritmo atual". */
32const recentLookback = (length: number) => (length <= 5 * HOUR ? HOUR : DAY)
33
34/**
35 * O menor período por onde dividir o uso. No semanal o ritmo é por dia: as
36 * primeiras horas de trabalho não valem para as noites e pausas da semana.
37 */
38const minPeriod = (length: number) => (length <= 5 * HOUR ? 5 * MINUTE : DAY)
39
40export const windowLabel = (kind: string): string => {
41 if (kind === 'five_hour') return '5 horas'
42 if (kind === 'seven_day') return 'Semanal'
43 if (kind.startsWith('seven_day_')) return `Semanal (${kind.slice('seven_day_'.length)})`
44 if (kind === 'spend_limit') return 'Gasto'
45 return kind
46}
47
48export const shortLabel = (kind: string): string => {
49 if (kind === 'five_hour') return '5h'
50 if (kind === 'seven_day') return 'Sem'
51 if (kind.startsWith('seven_day_')) return `Sem·${kind.slice('seven_day_'.length)}`
52 return kind
53}
54
55/** Dois resets a menos de 10 min um do outro são a mesma janela. */
56export const sameWindow = (a: number, b: number) => Math.abs(a - b) < 10 * MINUTE
57
58/** Junta uma leitura ao log, reiniciando-o quando a janela virou. */
59export const addSample = (
60 log: SampleLog | undefined,
61 resetsAt: number,
62 sample: Sample,
63): SampleLog => {
64 const points = log && sameWindow(log.resetsAt, resetsAt) ? log.points : []
65 const last = points[points.length - 1]
66 if (last && last.pct === sample.pct) return { resetsAt, points }
67
68 return { resetsAt, points: [...points, sample].slice(-500) }
69}
70
71/**
72 * %/h nos últimos `lookback` ms: do valor que valia no começo do período até o
73 * valor de agora. Parado, o ritmo cai para zero sozinho.
74 */
75export const recentRate = (
76 points: Sample[],
77 pctNow: number,
78 now: number,
79 lookback: number,
80 floor = 0,
81): number | undefined => {
82 const from = now - lookback
83 const before = [...points].reverse().find(p => p.t <= from)
84 const start = before ? { t: from, pct: before.pct } : points[0]
85 if (!start) return undefined
86 const span = now - start.t
87 if (span < Math.min(lookback / 4, 2 * HOUR)) return undefined
88
89 return Math.max(0, ((pctNow - start.pct) / Math.max(span, floor)) * HOUR)
90}
91
92/** O que a projeção do semanal precisa para medir o ritmo em horas de trabalho. */
93export type WorkPace = {
94 /** %/h do limite de 5h agora (o mais responsivo que temos). */
95 fiveRate?: number
96 /** Pontos do semanal por ponto do 5h. */
97 weekPerFive?: number
98 activeHoursPerDay: number
99}
100
101export const project = (
102 raw: RawWindow,
103 now: number,
104 log?: SampleLog,
105 bucket?: TokenBucket,
106 work?: WorkPace,
107): WindowView => {
108 const pct = raw.percentUsed
109 const resetsAt = raw.resetsAt ? Date.parse(raw.resetsAt) : undefined
110 const length = windowLength(raw.kind)
111 const view: WindowView = {
112 kind: raw.kind,
113 label: windowLabel(raw.kind),
114 pct,
115 rateSource: 'none',
116 verdict: 'unknown',
117 }
118
119 if (resetsAt === undefined || Number.isNaN(resetsAt)) return view
120 view.resetsAt = resetsAt
121 view.msToReset = Math.max(0, resetsAt - now)
122
123 if (bucket && sameWindow(bucket.resetsAt, resetsAt)) {
124 view.windowTokens = bucket.tokens
125 const moved = pct - bucket.firstPct
126 if (moved >= 1 && bucket.tokens > 0) view.tokensPerPct = bucket.tokens / moved
127 }
128
129 if (pct >= 100) {
130 view.verdict = 'exhausted'
131 return view
132 }
133 if (length === undefined) return view
134
135 const elapsed = Math.max(minPeriod(length), now - (resetsAt - length))
136 view.windowRatePerHour = (pct / elapsed) * HOUR
137
138 const points = log && sameWindow(log.resetsAt, resetsAt) ? log.points : []
139 view.recentRatePerHour = recentRate(points, pct, now, recentLookback(length), minPeriod(length))
140
141 // Extra do semanal: horas de uso no ritmo da última hora, pelo limite de 5h.
142 // A projeção principal segue o número da conta (todas as máquinas, noite inclusive).
143 if (raw.kind === 'seven_day' && work?.weekPerFive !== undefined && work.fiveRate !== undefined && work.fiveRate > 0) {
144 view.workRatePerHour = work.weekPerFive * work.fiveRate
145 view.workHoursLeft = (100 - pct) / view.workRatePerHour
146 view.activeHoursPerDay = work.activeHoursPerDay
147 }
148
149 const rate = view.recentRatePerHour ?? view.windowRatePerHour
150 view.rateSource = view.recentRatePerHour !== undefined ? 'recent' : 'window'
151 view.ratePerHour = rate
152 view.hoursAhead = view.msToReset / HOUR
153 view.pctAtReset = pct + rate * view.hoursAhead
154
155 view.verdict = verdictOf(view.pctAtReset)
156 if (rate > 0) view.hoursLeft = (100 - pct) / rate
157 if (rate > 0 && view.pctAtReset >= 100) {
158 view.exhaustAt = now + ((100 - pct) / rate) * HOUR
159 }
160
161 return view
162}
163
164const verdictOf = (pctAtReset: number): Verdict =>
165 pctAtReset >= 100 ? 'exhausts' : pctAtReset >= 85 ? 'tight' : 'ok'
166
167/** Divide o que a janela andou entre os projetos, pela fração do gasto de cada um. */
168export const projectShares = (bucket: ProjectBucket | undefined, pct: number): ProjectShare[] => {
169 if (!bucket) return []
170 const total = Object.values(bucket.byProject).reduce((sum, n) => sum + n, 0)
171 if (total <= 0) return []
172 const moved = pct - bucket.firstPct
173
174 return Object.entries(bucket.byProject)
175 .map(([name, cost]) => ({
176 name,
177 share: cost / total,
178 points: moved >= 1 ? (cost / total) * moved : undefined,
179 }))
180 .sort((a, b) => b.share - a.share)
181}
182
183/** Nome curto do projeto a partir da raiz do git ou da pasta da sessão. */
184export const projectName = (dir: string): string => {
185 const clean = dir.replace(/[\\/]+$/, '').replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/, '')
186 const parts = clean.split(/[\\/]/).filter(Boolean)
187 const last = parts[parts.length - 1] ?? dir
188 const generic = ['workdir', 'workspace', 'src', 'app', 'repo']
189
190 return generic.includes(last.toLowerCase()) && parts.length > 1 ? (parts[parts.length - 2] ?? last) : last
191}
192
193// ---------- formatação ----------
194
195const WEEKDAYS = ['dom', 'seg', 'ter', 'qua', 'qui', 'sex', 'sáb']
196const pad = (n: number) => String(n).padStart(2, '0')
197
198export const clockTime = (t: number, now: number): string => {
199 const d = new Date(t)
200 const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
201 const sameDay = new Date(now).toDateString() === d.toDateString()
202
203 return sameDay ? hm : `${WEEKDAYS[d.getDay()]} ${hm}`
204}
205
206export const duration = (ms: number): string => {
207 const minutes = Math.max(0, Math.round(ms / MINUTE))
208 if (minutes < 60) return `${minutes}min`
209 const hours = Math.floor(minutes / 60)
210 if (hours < 24) return `${hours}h${pad(minutes % 60)}`
211 const days = Math.floor(hours / 24)
212
213 return `${days}d${hours % 24}h`
214}
215
216export const compact = (n: number): string => {
217 if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`
218 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
219 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
220
221 return String(Math.round(n))
222}
223
224export const percent = (n: number) => `${Math.round(n)}%`
225
226/** Horas em pt-BR: "2,3h" abaixo de 10, "52h" acima. */
227export const hours = (h: number): string =>
228 h < 10 ? `${h.toFixed(1).replace('.', ',')}h` : `${Math.round(h)}h`
229
230export const usd = (n: number) => `US$ ${n.toFixed(2).replace('.', ',')}`
231
232export const bar = (pct: number, width: number): string => {
233 const full = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width)
234
235 return '█'.repeat(full) + '░'.repeat(width - full)
236}
237
238/** "~38h de uso" no semanal medido por trabalho, "~2,3h" no resto. */
239export const durationLabel = (w: WindowView) =>
240 w.hoursLeft === undefined ? '' : `~${hours(w.hoursLeft)}${w.rateSource === 'work' ? ' de uso' : ''}`
241
242/** Há quanto tempo foi a leitura, quando passa de 10 min. */
243export const readingAge = (readAt: number | undefined, now: number) => {
244 if (readAt === undefined || now - readAt < 10 * MINUTE) return undefined
245 const minutes = Math.round((now - readAt) / MINUTE)
246
247 return minutes < 60 ? `há ${minutes}min` : `há ${hours(minutes / 60)}`
248}
249
250/**
251 * O trecho de uma janela na linha de status: curto, porque o terminal corta a
252 * linha; o reset e os detalhes ficam na faixa e no painel.
253 */
254export const statusPart = (w: WindowView): string => {
255 const head = `${shortLabel(w.kind)} ${percent(w.pct)}`
256 if (w.verdict === 'exhausted') {
257 return `${head} ⛔ volta em ${hours((w.msToReset ?? 0) / HOUR)}`
258 }
259 if (w.ratePerHour === undefined) return head
260 if (w.hoursLeft === undefined) return `${head} parado ✓`
261 const mark = w.verdict === 'exhausts' ? '⚠' : w.verdict === 'tight' ? '△' : '✓'
262
263 return `${head} dura ${durationLabel(w)} ${mark}`
264}
265
266export const statusLine = (
267 windows: WindowView[],
268 session: Totals,
269 costUsd: number | undefined,
270 age?: string,
271 extra?: { sessions?: { count: number; tokens: number; costUsd: number }; api?: string },
272) => {
273 const parts = windows.map(statusPart)
274 if (age) parts.push(`leitura ${age}`)
275 const all = extra?.sessions
276 if (all && all.count > 1) {
277 parts.push(`${all.count} sessões · ${compact(all.tokens)} tok · ${usd(all.costUsd)}`)
278 } else {
279 const usage = [`${compact(totalTokens(session))} tok`]
280 if (costUsd !== undefined) usage.push(usd(costUsd))
281 parts.push(usage.join(' · '))
282 }
283 if (extra?.api) parts.push(extra.api)
284
285 return parts.join(' │ ')
286}
287
288/** "API US$ 37/200" para a linha de status. */
289export const apiShort = (spentUsd: number, creditUsd: number) =>
290 `API US$ ${Math.round(spentUsd)}/${Math.round(creditUsd)}`
291hooks/view.tsx 583 lines1import type { Elements } from 'claude-code'
2
3import type { Advice, ApiSpend, ProjectShare, SessionRecord, Snapshot, Totals, WindowView } from '../types'
4import { changesMain, usesSubHaiku } from './advisor'
5import { modelKey, modelLabel } from './pricing'
6import {
7 HOUR,
8 bar,
9 clockTime,
10 compact,
11 durationLabel,
12 readingAge,
13 duration,
14 hours,
15 percent,
16 shortLabel,
17 totalTokens,
18 usd,
19} from './projection'
20import { TOKEN_HEX, limitBarSvg, tokenMixSvg } from './svg'
21
22type TerminalUi = Elements['terminal']
23/** O que desktop, VS Code e mobile têm em comum: caixas, texto, SVG e botões. */
24type RichUi = Pick<Elements['desktop'], 'Box' | 'Text' | 'Svg' | 'Button'>
25
26/** Caixas, texto e botões: o que todas as superfícies desenham. */
27type BasicUi = Pick<TerminalUi, 'Box' | 'Text' | 'Button'>
28
29export type ViewData = { snap: Snapshot; totals: Totals; economy: boolean; api: ApiSpend | null }
30
31/** O que os botões fazem; `details` só existe fora do painel. */
32export type Actions = {
33 details?: () => void
34 /** ↻ Atualizar: relê a leitura mais nova e recalcula tudo. */
35 reload?: () => void
36 toggleEconomy: () => void
37 fillModel: (alias: string) => void
38}
39
40// ---------- textos comuns ----------
41
42const rateText = (rate: number | undefined) =>
43 rate === undefined ? '—' : `${rate.toFixed(rate < 10 ? 1 : 0).replace('.', ',')}%/h`
44
45export const colorOf = (w: WindowView) =>
46 w.verdict === 'exhausted' || w.verdict === 'exhausts'
47 ? 'error'
48 : w.verdict === 'tight'
49 ? 'warning'
50 : w.verdict === 'ok'
51 ? 'success'
52 : 'subtle'
53
54const verdictText = (w: WindowView, now: number): string => {
55 switch (w.verdict) {
56 case 'exhausted':
57 return `⛔ Esgotado — volta às ${w.resetsAt ? clockTime(w.resetsAt, now) : '?'}`
58 case 'exhausts':
59 return w.exhaustAt !== undefined && w.resetsAt !== undefined
60 ? `⚠ No ritmo atual acaba ~${clockTime(w.exhaustAt, now)}, ` +
61 `${duration(w.resetsAt - w.exhaustAt)} antes do reset`
62 : '⚠ No ritmo atual acaba antes do reset'
63 case 'tight':
64 return `△ Apertado: chega a ~${percent(w.pctAtReset ?? w.pct)} no reset`
65 case 'ok':
66 return `✓ Dura até o reset (~${percent(w.pctAtReset ?? w.pct)} no fim)`
67 default:
68 return 'Sem projeção para esta janela'
69 }
70}
71
72const durationText = (w: WindowView) =>
73 w.verdict === 'exhausted'
74 ? 'esgotado'
75 : w.ratePerHour === undefined
76 ? '—'
77 : w.hoursLeft === undefined
78 ? 'sem consumo'
79 : durationLabel(w)
80
81/** Só no semanal: horas de uso no ritmo da última hora de trabalho (pelo limite de 5h). */
82const workDaysText = (w: WindowView) =>
83 w.workHoursLeft !== undefined
84 ? `~${hours(w.workHoursLeft)} de uso no ritmo da última hora` +
85 (w.activeHoursPerDay ? ` (≈ ${(w.workHoursLeft / w.activeHoursPerDay).toFixed(1).replace('.', ',')} dias de ~${Math.round(w.activeHoursPerDay)}h)` : '')
86 : undefined
87
88/** No semanal: quantos pontos por dia a conta está gastando. */
89const perDayText = (w: WindowView) =>
90 w.ratePerHour === undefined ? '—' : `${Math.round(w.ratePerHour * 24)} pts/dia`
91
92const isWeekly = (w: WindowView) => w.kind.startsWith('seven_day')
93
94/** "atualizado 09:12" e, se a leitura for velha, de quando ela é e de onde veio. */
95const freshness = (snap: Snapshot) => {
96 const age = readingAge(snap.readAt, snap.updatedAt)
97 const base = `atualizado ${clockTime(snap.updatedAt, snap.updatedAt)}`
98 if (age) return `${base} · leitura ${age}${snap.isSharedReading ? ' (outra sessão)' : ''}: atualiza na próxima resposta`
99 return snap.isSharedReading ? `${base} · leitura de outra sessão` : base
100}
101
102const resetText = (w: WindowView, now: number) =>
103 w.resetsAt === undefined
104 ? '—'
105 : `${hours((w.msToReset ?? 0) / HOUR)} · ${clockTime(w.resetsAt, now)}`
106
107const tokensLine = (w: WindowView) =>
108 w.windowTokens === undefined
109 ? undefined
110 : `${compact(w.windowTokens)} tokens contados nesta janela` +
111 (w.tokensPerPct !== undefined
112 ? ` · ≈${compact(w.tokensPerPct)} por 1% · ≈${compact(w.tokensPerPct * Math.max(0, 100 - w.pct))} restantes`
113 : '')
114
115const projectLine = (p: ProjectShare) =>
116 `${p.name.padEnd(18)} ${bar(p.share * 100, 10)} ${percent(p.share * 100)} do gasto` +
117 (p.points !== undefined ? ` · ≈${p.points.toFixed(1).replace('.', ',')} pts` : '')
118
119/** Quanto cada projeto gastou na janela (até 5). */
120const ProjectList = (ui: BasicUi, w: WindowView, indent: string) => {
121 const { Box, Text } = ui
122 const list = w.projects ?? []
123 if (list.length === 0) return undefined
124
125 return (
126 <Box key="projects" flexDirection="column">
127 <Text dimColor>{indent}Por projeto (esta máquina):</Text>
128 {list.slice(0, 5).map(p => (
129 <Text key={p.name}>
130 {indent}
131 {' '}
132 {projectLine(p)}
133 </Text>
134 ))}
135 </Box>
136 )
137}
138
139const EMPTY =
140 'Sem dados de limite ainda: eles chegam com a primeira resposta da API e só existem em planos de assinatura (Pro/Max).'
141
142// ---------- sessões e créditos de API ----------
143
144const sessionRow = (r: SessionRecord, totalCost: number, isSelf: boolean) => {
145 const model = r.model ? modelLabel(modelKey(r.model)) : '—'
146 const share = totalCost > 0 ? ` · ${percent((r.costUsd / totalCost) * 100)}` : ''
147
148 return `${isSelf ? '●' : '○'} ${r.project.padEnd(16).slice(0, 16)} ${model.padEnd(11)} ${compact(totalTokens(r.tokens)).padStart(6)} tok ${usd(r.costUsd)}${share}${isSelf ? ' (esta)' : ''}`
149}
150
151/** As sessões abertas nesta máquina, o total delas e o total de hoje. */
152const SessionsList = (ui: BasicUi, snap: Snapshot) => {
153 const { Box, Text } = ui
154 const view = snap.sessions
155 if (!view) return <Text dimColor>Lista de sessões indisponível nesta máquina.</Text>
156
157 return (
158 <Box key="sessions-list" flexDirection="column">
159 {view.running.map(r => (
160 <Text key={r.id} bold={r.id === snap.selfId}>
161 {sessionRow(r, view.total.costUsd, r.id === snap.selfId)}
162 </Text>
163 ))}
164 <Text bold>
165 Total ({view.total.count} {view.total.count === 1 ? 'sessão aberta' : 'sessões abertas'}): {compact(totalTokens(view.total.tokens))} tok ·{' '}
166 {usd(view.total.costUsd)}
167 </Text>
168 <Text dimColor>
169 Hoje nesta máquina, fechadas inclusive: {view.today.count} sessões · {compact(totalTokens(view.today.tokens))} tok ·{' '}
170 {usd(view.today.costUsd)}
171 </Text>
172 <Text dimColor>Custo equivalente em preço de API; no plano, o que conta são os limites de 5h e semanal.</Text>
173 </Box>
174 )
175}
176
177const dayMonth = (t: number) => {
178 const d = new Date(t)
179 return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`
180}
181
182/** Os créditos de API do Console: gasto, ritmo e quando acabam. */
183const ApiCard = (ui: BasicUi, spend: ApiSpend | null, now: number) => {
184 const { Box, Text } = ui
185 if (!spend) return undefined
186 const pct = spend.creditUsd > 0 ? (spend.spentUsd / spend.creditUsd) * 100 : 0
187 const color = spend.runsOutAt !== undefined ? 'error' : spend.projectedUsd > spend.creditUsd * 0.85 ? 'warning' : 'success'
188
189 return (
190 <Box key="api" flexDirection="column" borderStyle="round" borderColor={spend.problem ? 'warning' : color} paddingX={1}>
191 <Text bold>Créditos de API (Console)</Text>
192 {spend.problem && <Text color="warning">{spend.problem}</Text>}
193 <Text>
194 <Text color={color}>{bar(pct, 20)}</Text> {usd(spend.spentUsd)} de {usd(spend.creditUsd)} · {percent(pct)}
195 </Text>
196 <Text dimColor>
197 Ciclo {dayMonth(spend.cycleStart)} a {dayMonth(spend.cycleEnd)} · {usd(spend.dailyUsd)}/dia · renova em{' '}
198 {Math.max(0, Math.ceil((spend.cycleEnd - now) / 86_400_000))} dias
199 </Text>
200 <Text color={color}>
201 {spend.runsOutAt !== undefined
202 ? `⚠ Nesse ritmo o crédito acaba ~${dayMonth(spend.runsOutAt)}, antes de renovar; depois as chamadas param (ou usam crédito comprado).`
203 : `✓ Nesse ritmo o ciclo fecha com ~${usd(spend.projectedUsd)} gastos.`}
204 </Text>
205 {spend.byModel.slice(0, 5).map(m => (
206 <Text key={m.model} dimColor>
207 {' '}
208 {m.model}: {usd(m.usd)}
209 </Text>
210 ))}
211 <Text dimColor>Não conta nos limites de 5h e semanal do plano. Atualiza a cada 10 min (o Console leva ~5 min).</Text>
212 </Box>
213 )
214}
215
216/** "3 sessões · 4.2M tokens · US$ 12,40" ou, sozinha, os números desta. */
217const usageSummary = (snap: Snapshot, totals: Totals, api: ApiSpend | null) => {
218 const all = snap.sessions?.total
219 const head =
220 all && all.count > 1
221 ? `${all.count} sessões · ${compact(totalTokens(all.tokens))} tokens · ${usd(all.costUsd)}`
222 : `${compact(totalTokens(totals))} tokens${snap.costUsd !== undefined ? ` · ${usd(snap.costUsd)}` : ''}`
223
224 return api && !api.problem ? `${head} · API ${usd(api.spentUsd)}/${Math.round(api.creditUsd)}` : head
225}
226
227// ---------- recomendação de modelo ----------
228
229const adviceColor = (a: Advice) => (a.isFine ? 'success' : a.pick ? 'warning' : 'error')
230
231const setupText = (a: Advice, economy: boolean) =>
232 [
233 `principal ${a.mainModel ? modelLabel(a.mainModel) : '—'}${a.mainEffort ? ` · effort ${a.mainEffort}` : ''}`,
234 a.subShare > 0
235 ? `subagentes ${percent(a.subShare * 100)} do gasto (${a.subModels.map(modelLabel).join(', ')})`
236 : 'sem subagentes nesta janela',
237 `economia ${economy ? 'ligada' : 'desligada'}`,
238 ].join(' · ')
239
240/** Botões que aplicam a recomendação; `always` mostra o liga/desliga da economia mesmo sem recomendação. */
241const AdviceButtons = (ui: BasicUi, a: Advice | undefined, economy: boolean, actions: Actions, always: boolean) => {
242 const { Box, Button } = ui
243 const buttons = []
244 if (!economy && (always || usesSubHaiku(a?.pick))) {
245 buttons.push(
246 <Button key="economy-on" label="Haiku nos subagentes" variant="primary" onPress={actions.toggleEconomy} />,
247 )
248 }
249 if (economy) {
250 buttons.push(<Button key="economy-off" label="Desligar economia" onPress={actions.toggleEconomy} />)
251 }
252 if (a && changesMain(a.pick, a.mainModel)) {
253 buttons.push(<Button key="model-sonnet" label="/model sonnet" onPress={() => actions.fillModel('sonnet')} />)
254 }
255 if (a?.pick === 'all-haiku') {
256 buttons.push(<Button key="model-haiku" label="/model haiku" onPress={() => actions.fillModel('haiku')} />)
257 }
258 if (actions.details) {
259 buttons.push(<Button key="details" label="Detalhes" plain onPress={actions.details} />)
260 }
261 // Na faixa o ↻ fica junto dos outros botões; no painel ele fica no topo.
262 if (actions.reload) {
263 buttons.push(<Button key="reload" label="↻ Atualizar" plain onPress={actions.reload} />)
264 }
265
266 return buttons.length > 0 ? (
267 <Box key="buttons" flexDirection="row" columnGap={2} flexWrap="wrap">
268 {buttons}
269 </Box>
270 ) : undefined
271}
272
273/** Cartão "Qual modelo usar" do painel. */
274const AdviceCard = (ui: BasicUi, a: Advice | undefined, economy: boolean, actions: Actions) => {
275 const { Box, Text } = ui
276
277 return (
278 <Box
279 key="advice"
280 flexDirection="column"
281 borderStyle="round"
282 borderColor={a ? adviceColor(a) : 'subtle'}
283 paddingX={1}
284 >
285 <Text bold>Qual modelo usar</Text>
286 {a === undefined ? (
287 <Text dimColor>A recomendação aparece depois de algumas respostas com os limites medidos.</Text>
288 ) : (
289 <Box flexDirection="column">
290 <Text color={adviceColor(a)}>{a.headline}</Text>
291 <Text dimColor>{setupText(a, economy)}</Text>
292 {a.scenarios.length > 0 && (
293 <Box flexDirection="column" marginTop={1}>
294 <Text dimColor>
295 Se o resto da janela {a.windowLabel.toLowerCase()} seguir assim (pelo gasto{' '}
296 {a.mixBasis === 'recent' ? 'das últimas 3 horas com uso' : 'da janela toda'}):
297 </Text>
298 {a.scenarios.map(s => (
299 <Text key={s.id} bold={s.id === a.pick} color={s.lasts ? 'success' : 'error'}>
300 {s.id === a.pick ? '› ' : ' '}
301 {s.label.padEnd(30)} dura {s.hoursLeft === undefined ? '—' : `~${hours(s.hoursLeft)}`} · ~
302 {percent(s.pctAtReset)} no reset · −{percent((1 - s.factor) * 100)} de gasto
303 </Text>
304 ))}
305 </Box>
306 )}
307 {a.tip !== undefined && <Text dimColor>{a.tip}</Text>}
308 </Box>
309 )}
310 {AdviceButtons(ui, a, economy, { ...actions, reload: undefined }, true)}
311 <Text dimColor>
312 Estimativa pelo preço de API de cada modelo; a assinatura pode pesar os modelos de outro jeito.
313 </Text>
314 </Box>
315 )
316}
317
318/** A linha de recomendação das faixas acima do prompt. */
319const AdviceLine = (ui: BasicUi, a: Advice | undefined, economy: boolean, actions: Actions) => {
320 const { Box, Text } = ui
321 if (a === undefined) return AdviceButtons(ui, a, economy, actions, false)
322
323 return (
324 <Box key="advice" flexDirection="column">
325 <Text color={adviceColor(a)} dimColor={a.isFine}>
326 {economy && !a.isFine ? `${a.headline} (economia ligada)` : a.headline}
327 </Text>
328 {AdviceButtons(ui, a, economy, actions, false)}
329 </Box>
330 )
331}
332
333// ---------- desktop, VS Code, mobile ----------
334
335const Stat = (ui: RichUi, key: string, label: string, value: string, color?: string) => {
336 const { Box, Text } = ui
337
338 return (
339 <Box key={key} flexDirection="column" minWidth={12}>
340 <Text dimColor>{label}</Text>
341 <Text bold color={color}>
342 {value}
343 </Text>
344 </Box>
345 )
346}
347
348const WindowCard = (ui: RichUi, w: WindowView, now: number, px: number) => {
349 const { Box, Text, Svg } = ui
350 const tokens = tokensLine(w)
351
352 return (
353 <Box key={w.kind} flexDirection="column" borderStyle="round" borderColor={colorOf(w)} paddingX={1}>
354 <Box flexDirection="row" justifyContent="space-between">
355 <Text bold>{w.label}</Text>
356 <Text bold color={colorOf(w)}>
357 {percent(w.pct)} usado
358 </Text>
359 </Box>
360 <Svg
361 source={limitBarSvg(w, now, px)}
362 alt={`${w.label}: ${percent(w.pct)} usado, ${durationText(w)} restantes`}
363 width={px}
364 height={30}
365 />
366 <Box flexDirection="row" columnGap={3} flexWrap="wrap">
367 {Stat(ui, 'dur', 'Duração estimada', durationText(w), colorOf(w))}
368 {Stat(ui, 'reset', 'Reset em', resetText(w, now))}
369 {isWeekly(w)
370 ? Stat(ui, 'rate', 'Ritmo da conta', perDayText(w))
371 : Stat(ui, 'rate', 'Ritmo recente', rateText(w.recentRatePerHour))}
372 {Stat(ui, 'avg', 'Média da janela', rateText(w.windowRatePerHour))}
373 </Box>
374 {workDaysText(w) !== undefined && <Text dimColor>{workDaysText(w)}</Text>}
375 <Text color={colorOf(w)}>{verdictText(w, now)}</Text>
376 {tokens !== undefined && <Text dimColor>{tokens}</Text>}
377 {ProjectList(ui, w, '')}
378 </Box>
379 )
380}
381
382const SessionCard = (ui: RichUi, { snap, totals }: ViewData, px: number) => {
383 const { Box, Text, Svg } = ui
384 const tokens = totalTokens(totals)
385 const legend = [
386 ['entrada', totals.input, TOKEN_HEX.input],
387 ['saída', totals.output, TOKEN_HEX.output],
388 ['cache escrito', totals.cacheWrite, TOKEN_HEX.cacheWrite],
389 ['cache lido', totals.cacheRead, TOKEN_HEX.cacheRead],
390 ] as const
391
392 return (
393 <Box key="session" flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
394 <Text bold>Sessões</Text>
395 {SessionsList(ui, snap)}
396 <Text bold>Esta sessão</Text>
397 <Box flexDirection="row" columnGap={3} flexWrap="wrap">
398 {Stat(ui, 'tokens', 'Tokens', compact(tokens))}
399 {Stat(ui, 'cost', 'Custo', snap.costUsd === undefined ? '—' : usd(snap.costUsd))}
400 {Stat(ui, 'turns', 'Turnos', String(totals.turns))}
401 {Stat(ui, 'ctx', 'Contexto', snap.contextPercent === undefined ? '—' : `${snap.contextPercent}%`)}
402 </Box>
403 {tokens > 0 && (
404 <Svg source={tokenMixSvg(totals, px)} alt="Composição dos tokens da sessão" width={px} height={12} />
405 )}
406 {tokens > 0 && (
407 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
408 {legend.map(([label, n, color]) => (
409 <Text key={label}>
410 <Text color={color}>■</Text> {label} {compact(n)}
411 </Text>
412 ))}
413 </Box>
414 )}
415 </Box>
416 )
417}
418
419/** Painel `/limites` em superfícies que desenham SVG. */
420export const RichPane = (ui: RichUi, data: ViewData, columns: number, actions: Actions) => {
421 const { Box, Text, Button } = ui
422 const { snap } = data
423 const now = snap.updatedAt
424 const px = Math.max(220, Math.min(820, columns * 7 - 24))
425
426 return (
427 <Box flexDirection="column" rowGap={1}>
428 <Box flexDirection="row" justifyContent="space-between">
429 <Text bold>Limites de uso</Text>
430 <Box flexDirection="row" columnGap={2} alignItems="center">
431 <Text dimColor>{freshness(snap)}</Text>
432 {actions.reload && <Button key="reload-top" label="↻ Atualizar" onPress={actions.reload} />}
433 </Box>
434 </Box>
435 {snap.windows.length === 0 && <Text dimColor>{EMPTY}</Text>}
436 {snap.windows.map(w => WindowCard(ui, w, now, px))}
437 {AdviceCard(ui, snap.advice, data.economy, actions)}
438 {SessionCard(ui, data, px)}
439 {ApiCard(ui, data.api, now)}
440 <Text dimColor>
441 Barra cheia: uso · hachura: projeção até o reset · traço: onde o uso estaria num ritmo uniforme
442 </Text>
443 </Box>
444 )
445}
446
447/** Faixa compacta acima do prompt no desktop. */
448export const RichBand = (ui: RichUi, data: ViewData, actions: Actions) => {
449 const { Box, Text, Svg } = ui
450 const { snap, totals } = data
451 const now = snap.updatedAt
452
453 return (
454 <Box flexDirection="column">
455 <Box flexDirection="row" columnGap={3} alignItems="center" flexWrap="wrap">
456 {snap.windows.map(w => (
457 <Box key={w.kind} flexDirection="row" columnGap={1} alignItems="center">
458 <Text bold>{shortLabel(w.kind)}</Text>
459 <Svg
460 source={limitBarSvg(w, now, 90, { height: 12 })}
461 alt={`${w.label}: ${percent(w.pct)} usado`}
462 width={90}
463 height={12}
464 />
465 <Text bold color={colorOf(w)}>
466 {percent(w.pct)}
467 </Text>
468 <Text dimColor>
469 dura {durationText(w)}
470 {w.workHoursLeft !== undefined ? ` (~${hours(w.workHoursLeft)} de uso)` : ''} · reset{' '}
471 {w.resetsAt === undefined ? '—' : hours((w.msToReset ?? 0) / HOUR)}
472 </Text>
473 </Box>
474 ))}
475 <Text dimColor>
476 {usageSummary(snap, totals, data.api)}
477 {readingAge(snap.readAt, now) !== undefined && ` · leitura ${readingAge(snap.readAt, now)}`}
478 </Text>
479 </Box>
480 {AdviceLine(ui, snap.advice, data.economy, actions)}
481 </Box>
482 )
483}
484
485// ---------- terminal ----------
486
487/** Faixa acima do prompt no terminal: um item por limite, quebrando linha se faltar largura. */
488export const TerminalBand = (ui: TerminalUi, { snap, totals, economy, api }: ViewData, actions: Actions) => {
489 const { Box, Text } = ui
490
491 return (
492 <Box flexDirection="column">
493 <Box flexDirection="row" columnGap={3} flexWrap="wrap">
494 {snap.windows.map(w => (
495 <Text key={w.kind}>
496 <Text bold>{shortLabel(w.kind)}</Text> <Text color={colorOf(w)}>{bar(w.pct, 8)}</Text>{' '}
497 <Text bold color={colorOf(w)}>
498 {percent(w.pct)}
499 </Text>{' '}
500 <Text dimColor>
501 dura {durationText(w)}
502 {w.workHoursLeft !== undefined ? ` (~${hours(w.workHoursLeft)} de uso)` : ''} · reset{' '}
503 {w.resetsAt === undefined ? '—' : hours((w.msToReset ?? 0) / HOUR)}
504 </Text>
505 </Text>
506 ))}
507 <Text key="session" dimColor>
508 {usageSummary(snap, totals, api)}
509 {readingAge(snap.readAt, snap.updatedAt) !== undefined && ` · leitura ${readingAge(snap.readAt, snap.updatedAt)}`} · /limites
510 </Text>
511 </Box>
512 {AdviceLine(ui, snap.advice, economy, { ...actions, details: undefined })}
513 </Box>
514 )
515}
516
517/** Painel `/limites` no terminal: só texto. */
518export const TerminalPane = (ui: TerminalUi, { snap, totals, economy, api }: ViewData, columns: number, actions: Actions) => {
519 const { Box, Text } = ui
520 const now = snap.updatedAt
521 const width = Math.max(10, Math.min(30, columns - 30))
522
523 return (
524 <Box flexDirection="column">
525 <Box flexDirection="row" columnGap={2}>
526 <Text dimColor>{freshness(snap)}</Text>
527 {actions.reload && <ui.Button key="reload-top" label="↻ Atualizar" onPress={actions.reload} />}
528 </Box>
529 {snap.windows.length === 0 && <Text dimColor>{EMPTY}</Text>}
530 {snap.windows.map(w => {
531 const tokens = tokensLine(w)
532
533 return (
534 <Box key={w.kind} flexDirection="column" marginTop={1}>
535 <Text>
536 <Text bold>{w.label.padEnd(10)}</Text>
537 <Text color={colorOf(w)}>{bar(w.pct, width)}</Text> {percent(w.pct)}
538 </Text>
539 <Text bold>
540 {' '}duração estimada {durationText(w)} · reset em {resetText(w, now)}
541 </Text>
542 <Text dimColor>
543 {' '}
544 {isWeekly(w)
545 ? `ritmo da conta ${perDayText(w)}${workDaysText(w) ? ` · ${workDaysText(w)}` : ''}`
546 : `ritmo ${rateText(w.recentRatePerHour)} recente · ${rateText(w.windowRatePerHour)} média da janela`}
547 </Text>
548 <Text color={colorOf(w)}>
549 {' '}
550 {verdictText(w, now)}
551 </Text>
552 {tokens !== undefined && (
553 <Text dimColor>
554 {' '}
555 {tokens}
556 </Text>
557 )}
558 {ProjectList(ui, w, ' ')}
559 </Box>
560 )
561 })}
562 <Box marginTop={1}>{AdviceCard(ui, snap.advice, economy, actions)}</Box>
563 <Box flexDirection="column" marginTop={1}>
564 <Text bold>Sessões</Text>
565 {SessionsList(ui, snap)}
566 </Box>
567 {ApiCard(ui, api, now) !== undefined && <Box marginTop={1}>{ApiCard(ui, api, now)}</Box>}
568 <Box flexDirection="column" marginTop={1}>
569 <Text bold>Esta sessão</Text>
570 <Text>
571 {' '}entrada {compact(totals.input)} · saída {compact(totals.output)} · cache lido{' '}
572 {compact(totals.cacheRead)} · cache escrito {compact(totals.cacheWrite)}
573 </Text>
574 <Text>
575 {' '}total {compact(totalTokens(totals))} em {totals.turns} turno{totals.turns === 1 ? '' : 's'}
576 {snap.costUsd !== undefined && ` · custo ${usd(snap.costUsd)}`}
577 {snap.contextPercent !== undefined && ` · contexto ${snap.contextPercent}%`}
578 </Text>
579 </Box>
580 </Box>
581 )
582}
583hooks/svg.ts 96 lines1import type { Totals, Verdict, WindowView } from '../types'
2import { windowLength } from './projection'
3
4/** Tons médios: leem bem no tema claro e no escuro do app. */
5export const HEX: Record<Verdict, string> = {
6 ok: '#2f9e62',
7 tight: '#d99a00',
8 exhausts: '#e5484d',
9 exhausted: '#e5484d',
10 unknown: '#8a8f98',
11}
12const TRACK = '#8a8f98'
13
14export const TOKEN_HEX = {
15 input: '#5b8def',
16 output: '#b26ad9',
17 cacheWrite: '#e0a458',
18 cacheRead: '#3fb4c4',
19} as const
20
21const clamp = (n: number) => Math.min(100, Math.max(0, n))
22const fixed = (n: number) => Math.round(n * 10) / 10
23
24/**
25 * Barra de um limite: o uso (cheio), o que o ritmo atual projeta até o reset
26 * (hachurado) e um traço onde o uso estaria num ritmo uniforme.
27 */
28export const limitBarSvg = (
29 w: WindowView,
30 now: number,
31 width: number,
32 options: { height?: number; pace?: boolean } = {},
33): string => {
34 const height = options.height ?? 30
35 const barH = Math.max(6, Math.round(height * 0.5))
36 const barY = Math.round((height - barH) / 2)
37 const r = barH / 2
38 const x = (pct: number) => fixed((clamp(pct) / 100) * width)
39 const color = HEX[w.verdict]
40 const used = x(w.pct)
41 const projected = x(w.pctAtReset ?? w.pct)
42
43 const length = windowLength(w.kind)
44 const pace =
45 options.pace !== false && length !== undefined && w.resetsAt !== undefined
46 ? x(((now - (w.resetsAt - length)) / length) * 100)
47 : undefined
48
49 return [
50 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
51 '<defs>',
52 `<pattern id="h" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">`,
53 `<rect width="3" height="6" fill="${color}" fill-opacity="0.45"/></pattern>`,
54 `<clipPath id="c"><rect x="0" y="${barY}" width="${width}" height="${barH}" rx="${r}"/></clipPath>`,
55 '</defs>',
56 `<rect x="0" y="${barY}" width="${width}" height="${barH}" rx="${r}" fill="${TRACK}" fill-opacity="0.2"/>`,
57 '<g clip-path="url(#c)">',
58 projected > used
59 ? `<rect x="${used}" y="${barY}" width="${fixed(projected - used)}" height="${barH}" fill="url(#h)"/>`
60 : '',
61 `<rect x="0" y="${barY}" width="${used}" height="${barH}" fill="${color}"/>`,
62 '</g>',
63 pace !== undefined
64 ? `<line x1="${pace}" y1="${Math.max(1, barY - 4)}" x2="${pace}" y2="${Math.min(height - 1, barY + barH + 4)}" stroke="${TRACK}" stroke-width="2" stroke-linecap="round"/>`
65 : '',
66 '</svg>',
67 ].join('')
68}
69
70/** Barra empilhada da composição dos tokens da sessão. */
71export const tokenMixSvg = (t: Totals, width: number, height = 12): string => {
72 const parts = [
73 [t.input, TOKEN_HEX.input],
74 [t.output, TOKEN_HEX.output],
75 [t.cacheWrite, TOKEN_HEX.cacheWrite],
76 [t.cacheRead, TOKEN_HEX.cacheRead],
77 ] as const
78 const total = parts.reduce((sum, [n]) => sum + n, 0)
79 let at = 0
80 const rects = parts.map(([n, color]) => {
81 const w = total > 0 ? (n / total) * width : 0
82 const rect = w > 0 ? `<rect x="${fixed(at)}" y="0" width="${fixed(w)}" height="${height}" fill="${color}"/>` : ''
83 at += w
84
85 return rect
86 })
87
88 return [
89 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
90 `<clipPath id="c"><rect width="${width}" height="${height}" rx="${height / 2}"/></clipPath>`,
91 `<rect width="${width}" height="${height}" rx="${height / 2}" fill="${TRACK}" fill-opacity="0.2"/>`,
92 `<g clip-path="url(#c)">${rects.join('')}</g>`,
93 '</svg>',
94 ].join('')
95}
96types/index.d.ts 183 lines1export type Totals = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6 turns: number
7}
8
9/** As quatro contagens de tokens de uma resposta. */
10export type Tokens = {
11 input: number
12 output: number
13 cacheRead: number
14 cacheWrite: number
15}
16
17/** Tokens de uma janela por papel (principal ou subagente) e por modelo. */
18export type Mix = {
19 resetsAt: number
20 main: Record<string, Tokens>
21 sub: Record<string, Tokens>
22}
23
24/** O que a sessão está usando no fio principal. */
25export type Setup = { mainModel?: string; mainEffort?: string }
26
27export type ScenarioId = 'sub-haiku' | 'main-sonnet-sub-haiku' | 'all-sonnet' | 'all-haiku'
28
29export type Scenario = {
30 id: ScenarioId
31 label: string
32 /** Custo do cenário sobre o custo atual (0,4 = 60% mais barato). */
33 factor: number
34 hoursLeft?: number
35 pctAtReset: number
36 lasts: boolean
37}
38
39export type Advice = {
40 windowKind: string
41 windowLabel: string
42 mainModel?: string
43 mainEffort?: string
44 /** Parte do gasto da janela feita por subagentes, 0 a 1. */
45 subShare: number
46 subModels: string[]
47 scenarios: Scenario[]
48 /** O cenário recomendado: o menos disruptivo que faz o limite durar. */
49 pick?: ScenarioId
50 /** true quando nada precisa mudar. */
51 isFine: boolean
52 /** O gasto por modelo das últimas horas (`recent`) ou da janela toda. */
53 mixBasis: 'recent' | 'window'
54 headline: string
55 tip?: string
56}
57
58/** A parte de um projeto no gasto de uma janela. */
59export type ProjectShare = {
60 name: string
61 /** Fração do gasto da janela, 0 a 1. */
62 share: number
63 /** Pontos do limite atribuídos ao projeto, pela fração do gasto. */
64 points?: number
65}
66
67/** Gasto em US$ (preço de API) por projeto numa janela. */
68export type ProjectBucket = { resetsAt: number; firstPct: number; byProject: Record<string, number> }
69
70/** Uma sessão do Claude Code desta máquina, como ela mesma se registra. */
71export type SessionRecord = {
72 id: string
73 project: string
74 model?: string
75 startedAt: number
76 /** O último sinal da sessão (a cada minuto enquanto aberta). */
77 updatedAt: number
78 tokens: Totals
79 /** Custo equivalente em preço de API, como o /cost soma. */
80 costUsd: number
81 ended?: boolean
82}
83
84export type SessionsView = {
85 /** As abertas agora, esta primeiro. */
86 running: SessionRecord[]
87 total: { tokens: Totals; costUsd: number; count: number }
88 /** Todas as de hoje, fechadas inclusive. */
89 today: { tokens: Totals; costUsd: number; count: number }
90}
91
92/** O gasto dos créditos de API no ciclo, lido do relatório de custo do Console. */
93export type ApiSpend = {
94 cycleStart: number
95 cycleEnd: number
96 spentUsd: number
97 creditUsd: number
98 byModel: { model: string; usd: number }[]
99 dailyUsd: number
100 projectedUsd: number
101 /** Quando o crédito acaba no ritmo atual, se for antes do fim do ciclo. */
102 runsOutAt?: number
103 fetchedAt: number
104 problem?: string
105}
106
107export type Verdict = 'ok' | 'tight' | 'exhausts' | 'exhausted' | 'unknown'
108
109export type WindowView = {
110 kind: string
111 label: string
112 pct: number
113 resetsAt?: number
114 msToReset?: number
115 /** % por hora usado na projeção. */
116 ratePerHour?: number
117 /**
118 * De onde vem o ritmo: últimas horas (`recent`), média da janela (`window`)
119 * ou, no semanal, o ritmo de trabalho medido pelo limite de 5h (`work`).
120 */
121 rateSource: 'recent' | 'window' | 'work' | 'none'
122 /**
123 * Horas de consumo até o reset no ritmo de `ratePerHour`: no 5h, o relógio;
124 * no semanal por trabalho, as horas de trabalho que cabem até o reset.
125 */
126 hoursAhead?: number
127 /** Horas de trabalho por dia usadas na projeção do semanal. */
128 activeHoursPerDay?: number
129 /**
130 * Só no semanal: horas de uso que restam no ritmo da última hora de trabalho,
131 * medido pelo limite de 5h. Informação extra; a projeção usa o ritmo da conta.
132 */
133 workHoursLeft?: number
134 /** %/h de uso do semanal no ritmo da última hora de trabalho. */
135 workRatePerHour?: number
136 /** Média de %/h desde o início da janela. */
137 windowRatePerHour?: number
138 /** %/h na última hora (5h) ou nas últimas 24h (semanal). */
139 recentRatePerHour?: number
140 /** Horas até chegar a 100% no ritmo usado; ausente quando parado. */
141 hoursLeft?: number
142 /** Quando chega a 100% no ritmo usado, se antes do reset. */
143 exhaustAt?: number
144 /** % projetado no momento do reset. */
145 pctAtReset?: number
146 verdict: Verdict
147 /** Tokens contados nesta janela (todas as sessões com o mod). */
148 windowTokens?: number
149 /** Tokens por 1% do limite, medido nesta janela. */
150 tokensPerPct?: number
151 /** Quanto cada projeto gastou nesta janela, nesta máquina. */
152 projects?: ProjectShare[]
153}
154
155export type Snapshot = {
156 updatedAt: number
157 windows: WindowView[]
158 costUsd?: number
159 contextPercent?: number
160 advice?: Advice
161 /** Quando os percentuais foram lidos de uma resposta da API. */
162 readAt?: number
163 /** A leitura veio de outra sessão desta máquina, mais recente que a desta. */
164 isSharedReading?: boolean
165 /** As sessões abertas nesta máquina e o total. */
166 sessions?: SessionsView
167 /** O id desta sessão, para marcá-la na lista. */
168 selfId?: string
169}
170
171declare module 'claude-code' {
172 interface PluginState {
173 'usage-limits': {
174 snapshot: Snapshot | null
175 session: Totals
176 economy: boolean
177 setup: Setup
178 project: string
179 api: ApiSpend | null
180 }
181 }
182}
183