SLOPSHOPPER

usage-limits

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

newpanebandcommandtoaststatus
★ 1v0.6.0MITupdated 2026-10-09kevinmedeiros/claude-mods/plugins/usage-limits
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-limits
│ ┃ Limites ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ atualizado 08:53 [ ↻ Atualizar ] │ usage-limits │ │ ┃ ⏺ Read(src/auth.ts) │ Modo economia ligado: o fio principal │ │ ┃ 5 horas ████████░░░░░░░░░░░░░░░░░░ 31% ⎿ Read 6 lines │ orquestra, os novos subagentes executam em │ │ ┃ duração estimada — · reset em — ⏺ Update(src/auth.ts) │ Haiku 5.5. │ │ ┃ ritmo — recente · — média da janela ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ Sem projeção para esta janela ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Qual modelo usar │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ A recomendação aparece depois de algumas │ │ ┃ │ respostas com os limites medidos. │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ Desligar economia ] │ │ ┃ │ Estimativa pelo preço de API de cada │ › /limites │ ┃ │ modelo; a assinatura pode pesar os │ ⎿ usage-limits: Painel de limites aberto. │ ┃ │ modelos de outro jeito. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ Sessões │ ┃ ● work Opus 5.5 98.9k tok │ ┃ US$ 0,42 · 100% (esta) │ ┃ Total (1 sessão aberta): 98.9k tok · US$ │ ┃ 0,42 │ ┃ Hoje nesta máquina, fechadas inclusive: 1 │ ┃ sessões · 98.9k tok · US$ 0,42 │ ┃ Custo equivalente em preço de API; no plano, 5h ██░░░░░░ 31% dura — · reset — 98.9k tokens · US$ 0,42 · /limites [ Desligar economia ] ↻ Atualizar ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-limits: 5h 31% │ 98.9k tok · US$ 0,42

Draws

Band
5h ██░░░░░░ 31% dura — · reset — 98.9k tokens · US$ 0,42 · /limites [ Desligar economia ] ↻ Atualizar ⟨Claude Code's own drawing⟩
Pane · Limites
atualizado 08:53 [ ↻ Atualizar ] 5 horas ████████░░░░░░░░░░░░░░░░░░ 31% duração estimada — · reset em — ritmo — recente · — média da janela Sem projeção para esta janela ╭──────────────────────────────────────────────────────────╮ │ Qual modelo usar │ │ A recomendação aparece depois de algumas respostas com │ │ os limites medidos. │ │ [ Desligar economia ] │ │ Estimativa pelo preço de API de cada modelo; a │ │ assinatura pode pesar os modelos de outro jeito. │ ╰──────────────────────────────────────────────────────────╯ Sessões ● work Opus 5.5 98.9k tok US$ 0,42 · 100% (esta) Total (1 sessão aberta): 98.9k tok · US$ 0,42 Hoje nesta máquina, fechadas inclusive: 1 sessões · 98.9k tok · US$ 0,42 Custo equivalente em preço de API; no plano, o que conta são os limites de 5h e semanal. Esta sessão entrada 2.1k · saída 1.5k · cache lido 91.0k · cache escrito 4.3k total 98.9k em 1 turno · custo US$ 0,42 · contexto 49%
README

usage-limits

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.

Source 10 files
hooks/register.tsx 613 lines
1import { 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}
613
hooks/api.ts 105 lines
1import 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}
105
hooks/sessions.ts 49 lines
1import 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)
49
hooks/advisor.ts 169 lines
1import 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'
169
hooks/pace.ts 136 lines
1import 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}
136
hooks/pricing.ts 79 lines
1import 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}
79
hooks/projection.ts 291 lines
1import 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)}`
291
hooks/view.tsx 583 lines
1import 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}
583
hooks/svg.ts 96 lines
1import 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}
96
types/index.d.ts 183 lines
1export 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