SLOPSHOPPER

Wavy Usage

Liquid-filled rings above the Claude Code prompt for the 5-hour and 7-day limit windows, context fill and prompt-cache lifetime, a fighter jet that shows the…

newpanebandcommandtoasttimer
★ 3v1.2.7MITupdated 2026-10-05BatuhanCakmakk/wavy-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wavy-usage
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ The wavy-usage pane draws on desktop only │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wavy-usage │ ⎿ wavy-usage: Pane opened │ │ ◔ 5h 31% ◑ ctx 49% 97k ● cache 1h $0.42 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 5h 31% ◑ ctx 49% 97k ● cache 1h $0.42 ⟨Claude Code's own drawing⟩
Pane · Usage
The wavy-usage pane draws on desktop only
README

wavy-usage

Liquid-filled rings above the Claude Code prompt: your 5-hour and 7-day limit windows, how full the context is, and how long the prompt cache stays warm. A small fighter jet shows the model's effort: the lower the effort, the faster it flies. A side pane shows your last turns, what each one cost, and which projects and models filled the window.

Zero tokens: everything is read from what Claude Code already knows. No network, no model calls.

The band above the prompt

The pane

The band on a dark theme

The pane on a dark theme

The effort jet

What you get

The band (above the prompt, Claude Code desktop)

RingShows
5hShare of the 5-hour limit window used
7dShare of the 7-day limit window used
ctxContext window fill, with the token count
cacheTime left before the prompt cache goes cold

The liquid rises and falls with the value and keeps a gentle wave. Rings turn amber past 50% and red past 75%. The cache ring drains as the cache lifetime runs out, in 5-minute steps for the 1-hour lifetime, and turns amber in its last fifth. The rings hold still while Claude works and move when the prompt finishes, and the band redraws only when something on it changes. The session cost sits at the end of the band, and Panel opens the pane.

The jet: a small fighter jet flies just left of Panel and shows the model's effort. It banks right and left, then rolls through a corkscrew, with wind streaming past. Lower effort answers faster, so the jet flies faster: low races with a violet afterburner and shock diamonds, then medium (red), high (orange) and xhigh (amber), down to max, which cruises slowly with a small blue flame because it thinks longest. It hides on a narrow band, and the terminal shows ✈ low and so on instead.

The pane (Panel or /wavy-usage)

  • The jet, larger, with the effort level
  • The four rings, larger
  • Last 5 turns: a table with a total line and column headers. Each turn shows its number and time, a stacked bar of its tokens (hover a segment for its name and size), its token total, its cost and how much the 5-hour window grew during it. The legend says what each color means: cache reads are the cheap re-reading of the conversation, output is the priciest
  • What filled the window: the 5-hour or 7-day window broken down by project and by model, ranked by cost
  • Resets: when each window resets, with a progress bar

In the terminal the band is one colored line:

◔ 5h 34%  ○ 7d 12%  ◑ ctx 62% 124k  ◕ cache 47m  $1.23

The pane is drawn on desktop only. Both the band and the pane follow the app's light or dark theme.

When the context passes 55%, a toast suggests /clear for a new task or /compact to keep going, once per new tenth.

Install

Requires Claude Code with mods (function hooks). On 2.1.287 and later they are on by default. On earlier builds with early access, set this first in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Then, in a Claude Code session:

/plugin marketplace add BatuhanCakmakk/wavy-usage
/plugin install wavy-usage@wavy-usage

Or from your shell:

claude plugin marketplace add BatuhanCakmakk/wavy-usage
claude plugin install wavy-usage@wavy-usage

Or both at once inside a session (Claude Code 2.1.275 or later):

/plugin install wavy-usage --marketplace BatuhanCakmakk/wavy-usage

Start a new session and the band appears after the first reply.

To get a new release later, run claude plugin update wavy-usage@wavy-usage, or turn on auto-update for the marketplace in /plugin.

Commands

CommandDoes
/wavy-usageOpens or closes the pane
/wavy-usage offHides the band and the pane, and stops the toasts. Kept across sessions
/wavy-usage onTurns it back on
/wavy-usage ttl 5m / ttl 1hSets the cache lifetime the countdown uses (default 1 hour, what Claude Code uses on a subscription)

Languages

English, Türkçe, Français, Deutsch, 日本語, 한국어, Português (Brasil) and Español. The languages after English and Turkish follow the countries with the largest share of Claude.ai use in the Anthropic Economic Index (February 2026).

Pick one under Language in /config. With auto (the default), wavy-usage follows Claude Code's own language setting, then your system locale, then English.

Translations other than English and Turkish have not yet been reviewed by native speakers. Corrections are very welcome: every string lives in hooks/i18n.ts.

How it works

  • Limit windows, context and cost come from Claude Code's own session.measure event: the figures the last API response already reported. Nothing extra is requested.
  • Turn tokens come from turn.complete. A turn's counts are the sum of all its requests, so cache reads grow with context size times the number of steps.
  • Turn cost is the change in Claude Code's own session cost between turns, so it follows the real pricing, cache lifetimes and subagents.
  • Cache countdown starts when a main-conversation turn ends. Subagent turns do not refresh it. It is an upper bound: the service can drop a cache entry early.

Privacy and footprint

  • No network access, no model calls, no files read or written outside the plugin store.
  • The plugin store keeps: whether the band is on, the cache lifetime setting, the last effort level seen, and for each session a list of turns (time, project folder name, model, token total, cost). A session's list is deleted once its last turn is more than 8 days old.
  • Everything stays on your computer.

Run claude plugin validate on the folder to see every event it hooks and every call it makes.

To switch it off without uninstalling, set CLAUDE_MODS_DISABLE=wavy-usage (or all) in the environment.

Limitations

  • Plan limits count all your usage: claude.ai, the apps and other computers. The rings show the true window fill, but What filled the window only knows the Claude Code sessions on this computer, since wavy-usage was installed.
  • The cache countdown is an estimate (see above).
  • The pane is desktop only. The terminal gets the one-line band.

Development

claude plugin validate .
claude plugin test .

Tests run against the engine's own test kit and cover the pure logic (hooks/model.ts, hooks/svg.ts, hooks/terminal.ts, hooks/i18n.ts) and the hooks on both the desktop and the terminal surface.

Credits

Inspired by usage-band by Yash Gadodia, and by cache-ttl-timer and claude-runway.

License

MIT © 2026 Batuhan Çakmak

Source 7 files
hooks/register.tsx 743 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LogEntry, Reading, Turn } from '../types'
5import type { Lang, Strings } from './i18n'
6import { resolveLang, STRINGS } from './i18n'
7import type { Group, Sample, WindowView } from './model'
8import {
9  addSample,
10  bandLayout,
11  breakdown,
12  cacheState,
13  clockTime,
14  costText,
15  countdown,
16  COUNTDOWN_STEP,
17  ctxDeltaText,
18  fiveDeltaText,
19  dayClock,
20  FIVE_HOURS,
21  forecast,
22  heat,
23  isStaleLog,
24  isUnknown,
25  nudge,
26  nudgeText,
27  parseTtl,
28  percentText,
29  projectName,
30  recentTurns,
31  ringStep,
32  recordTurn,
33  SEVEN_DAYS,
34  SETTLE_MS,
35  settleTurn,
36  syncLog,
37  takeReading,
38  tokens,
39  TTL_1H,
40  TTL_5M,
41  ttlText,
42  turnsSummary,
43  turnTotal,
44  upgradeTurn,
45  windowElapsed,
46  windowStart,
47  windowTotals,
48} from './model'
49import type { JetLevel } from './jet'
50import { jetLevel, jetStrip } from './jet'
51import { COLORS, liquidRing, resetBar, SEGMENTS, ttlColor, turnBar } from './svg'
52import { terminalParts } from './terminal'
53
54// Limit windows, context fill, cache warmth and cost: rings above the prompt, detail in a side pane.
55// The figures arrive on session.measure and turn.complete (what the last API response already reported): no extra
56// requests, no tokens. Svg rings and a pane on desktop, a one-line text band in the terminal. Strings live in i18n.ts.
57
58const MOD = 'wavy-usage'
59const PANE = 'wavy-usage'
60const ENABLED_KEY = 'wavy-usage:enabled'
61const TTL_KEY = 'wavy-usage:ttl'
62const LOG_PREFIX = 'wavy-usage:log:'
63const EFFORT_KEY = 'wavy-usage:effort'
64const TICK_MS = 60 * 1000
65// While a long prompt runs, the band and the pane catch up with new measurements at most this often.
66const LIVE_MS = 2 * 60 * 1000
67// Band rings at 2x: the text keeps the app's size (Text has no size prop), the ring grows.
68const BAND_RING = 44
69const BAND_STROKE = 5
70const JET_W = 130
71const JET_H = 44
72const JET_SCALE = 1.25
73const JET_PANE_H = 84
74const JET_PANE_SCALE = 2.6
75// The terminal tag takes the flame's color family.
76const JET_TERMINAL_COLOR: Record<JetLevel, string> = { low: 'magenta', medium: 'red', high: 'yellow', xhigh: 'yellow', max: 'cyan' }
77
78const reading = atom({ plugin: 'wavy-usage', key: 'reading' } as const, null as Reading | null)
79// What the band and the pane draw, published in one piece: once when a prompt finishes (after the measurement that
80// settles it), never on mid-turn or idle measurements. One publish is one redraw; an identical one is skipped.
81type Screen = { reading: Reading | null; turns: Turn[]; lastRequestAt: number | null; log: LogEntry[] }
82const screen = atom({ plugin: 'wavy-usage', key: 'screen' } as const, { reading: null, turns: [], lastRequestAt: null, log: [] } as Screen)
83const history = atom({ plugin: 'wavy-usage', key: 'history' } as const, [] as Turn[])
84const isPaneOpen = atom({ plugin: 'wavy-usage', key: 'isPaneOpen' } as const, false)
85const isEnabled = atom({ plugin: 'wavy-usage', key: 'isEnabled' } as const, true)
86const lastRequestAt = atom({ plugin: 'wavy-usage', key: 'lastRequestAt' } as const, null as number | null)
87const ttlMs = atom({ plugin: 'wavy-usage', key: 'ttlMs' } as const, TTL_1H)
88const log = atom({ plugin: 'wavy-usage', key: 'log' } as const, [] as LogEntry[])
89const others = atom({ plugin: 'wavy-usage', key: 'others' } as const, [] as LogEntry[])
90const windowView = atom({ plugin: 'wavy-usage', key: 'windowView' } as const, '5h' as WindowView)
91// The main loop's effort, from turn.step; the last one seen is kept so a new session shows the jet before its first request.
92const effort = atom({ plugin: 'wavy-usage', key: 'effort' } as const, null as JetLevel | null)
93// The session's cost when it started (0 when new, the restored total when resumed): the first turn's cost counts from it.
94const costBase = atom({ plugin: 'wavy-usage', key: 'costBase' } as const, undefined as number | undefined)
95// The 5-hour percent over the last hour, for the pane's run-out forecast.
96const samples = atom({ plugin: 'wavy-usage', key: 'samples' } as const, [] as Sample[])
97
98let disabled = false
99let nudgedAt = 0
100let hasLoggedRenderError = false
101let lastTurnEnd = 0
102let lastFace = ''
103let lastMeasureAt = 0
104let lastPublishAt = 0
105let awaitingSettle = false
106let sessionId = '?'
107let project = '?'
108let lang: Lang = 'en'
109let t: Strings = STRINGS.en
110
111// Each ring's last move: the liquid slides from `from` to `to`. A redraw at the same level returns the same `from`, so
112// the Svg source stays byte-identical and the desktop keeps its frame instead of reloading it (a reload blinks and
113// restarts the waves). Keys are language-neutral.
114const lastLevel = new Map<string, { from: number; to: number }>()
115
116const levelFrom = (key: string, percent: number | undefined): number => {
117  const to = percent ?? 0
118  const last = lastLevel.get(key)
119  if (last && last.to === to) return last.from
120  const from = last?.to ?? 0
121  lastLevel.set(key, { from, to })
122  return from
123}
124
125const shownName = (name: string): string => (isUnknown(name) ? t.unknown : name)
126
127// CLAUDE_MODS_DISABLE=all, or wavy-usage in its comma list: every hook passes through and no command is registered.
128const readDisabled = async ($: EngineInterface): Promise<boolean> => {
129  const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
130  disabled = raw
131    .split(',')
132    .map(v => v.trim())
133    .some(v => v === 'all' || v === MOD)
134  return disabled
135}
136
137const logRenderError = ($: EngineInterface, err: unknown): void => {
138  if (hasLoggedRenderError) return
139  hasLoggedRenderError = true
140  $.ui.log(`wavy-usage: render failed, fell back to the engine's drawing: ${err}`)
141}
142
143// Other sessions' logs: read once when the pane opens and when the tab changes; stale keys are deleted.
144const loadOthers = async ($: EngineInterface): Promise<void> => {
145  const now = await $.clock.now()
146  const own = LOG_PREFIX + sessionId
147  const keys = (await $.store.keys().catch(() => [] as string[])).filter(k => k.startsWith(LOG_PREFIX) && k !== own)
148  const all: LogEntry[] = []
149  for (const key of keys) {
150    const entries = await $.store.get(key).catch(() => undefined)
151    if (isStaleLog(entries, now)) {
152      await $.store.delete(key).catch(() => undefined)
153      continue
154    }
155    all.push(...(entries as LogEntry[]))
156  }
157  await update($, others, () => all)
158}
159
160// This session's last turn (new, or corrected by a settle) is written to the log and to $.store.
161const syncOwnLog = async ($: EngineInterface): Promise<void> => {
162  const h = await read($, history)
163  const last = h.at(-1)
164  if (!last) return
165  const sessionModel = await $.session.model().catch(() => undefined)
166  const next = syncLog(await read($, log), last, h.at(-2), project, sessionModel, await read($, costBase))
167  await update($, log, () => next)
168  await $.store.set(LOG_PREFIX + sessionId, next).catch(() => undefined)
169}
170
171const openPane = async ($: EngineInterface): Promise<void> => {
172  await $.ui.open({ id: PANE, title: t.paneTitle })
173  await update($, isPaneOpen, () => true)
174  await loadOthers($)
175}
176
177// Always close through $.ui.close: the ui.close hook clears isPaneOpen the same way as when the person closes it.
178// It is cleared here too in case the plugin's own call skips its own hook.
179const closePane = async ($: EngineInterface): Promise<void> => {
180  await $.ui.close({ id: PANE })
181  await update($, isPaneOpen, () => false)
182}
183
184const togglePane = async ($: EngineInterface): Promise<void> =>
185  (await read($, isPaneOpen)) ? closePane($) : openPane($)
186
187const switchWindow = async ($: EngineInterface, view: WindowView): Promise<void> => {
188  await update($, windowView, () => view)
189  await loadOthers($)
190}
191
192const publish = async ($: EngineInterface): Promise<void> => {
193  const next: Screen = {
194    reading: await read($, reading),
195    turns: await read($, history),
196    lastRequestAt: await read($, lastRequestAt),
197    log: await read($, log),
198  }
199  if (JSON.stringify(await read($, screen)) === JSON.stringify(next)) return
200  await update($, screen, () => next)
201  lastPublishAt = await $.clock.now()
202}
203
204// The time-driven figures as drawn now, or null while the band is off.
205const clockFace = async ($: EngineInterface): Promise<string | null> => {
206  if (!(await read($, isEnabled))) return null
207  const now = await $.clock.now()
208  const shownNow = await read($, screen)
209  const cache = cacheState(shownNow.lastRequestAt, await read($, ttlMs), now, t)
210  const parts = [cache === null ? '' : `${ringStep(cache.percent)} ${cache.text}`]
211  if (await read($, isPaneOpen)) {
212    const r = shownNow.reading
213    for (const at of [r?.fiveHourResetsAt, r?.sevenDayResetsAt]) {
214      if (at !== undefined) parts.push(countdown(at - now, t.units, COUNTDOWN_STEP))
215    }
216  }
217  return parts.join('|')
218}
219
220export const register: Register = (on, options) => {
221  on('session.start', async ($, e, next) => {
222    const r = await next(e)
223    // Module variables start clean on every load (and in every test of a file).
224    nudgedAt = 0
225    lastTurnEnd = 0
226    lastFace = ''
227    lastMeasureAt = 0
228    lastPublishAt = 0
229    awaitingSettle = false
230    hasLoggedRenderError = false
231    lastLevel.clear()
232    if (await readDisabled($)) return r
233    // Language: userConfig > Claude Code's language setting > system locale > English.
234    const rows = await $.config.list().catch(() => [])
235    const claudeLanguage = rows.find(row => row.key === 'language')?.value
236    lang = resolveLang(options.language, claudeLanguage, Intl.DateTimeFormat().resolvedOptions().locale)
237    t = STRINGS[lang]
238    const stored = await $.store.get(ENABLED_KEY).catch(() => undefined)
239    await update($, isEnabled, () => stored !== false)
240    // A hot reload keeps $.state: turns from earlier versions with a single cache field are upgraded.
241    await update($, history, h => h.map(turn => upgradeTurn(turn)))
242    // Taken before the first turn only: a hot reload mid-session keeps the base it had.
243    if ((await read($, history)).length === 0) {
244      const startUsd = (await $.session.usage().catch(() => undefined))?.cost?.usd
245      await update($, costBase, () => startUsd)
246    }
247    sessionId = await $.session.id().catch(() => '?')
248    project = projectName((await $.session.cwd().catch(() => undefined)) ?? '')
249    // A restart or a compact starts $.state empty: this session's earlier turns come back from $.store, or the next
250    // turn would overwrite them.
251    if ((await read($, log)).length === 0) {
252      const saved = await $.store.get(LOG_PREFIX + sessionId).catch(() => undefined)
253      if (Array.isArray(saved)) await update($, log, () => saved as LogEntry[])
254    }
255    const ttl = await $.store.get(TTL_KEY).catch(() => undefined)
256    await update($, ttlMs, () => (ttl === TTL_5M ? TTL_5M : TTL_1H))
257    const lastEffort = await $.store.get(EFFORT_KEY).catch(() => undefined)
258    await update($, effort, () => jetLevel(lastEffort))
259    await loadOthers($)
260    await publish($)
261    await $.command
262      .register({
263        name: 'wavy-usage',
264        description: t.cmdDescription,
265        argumentHint: '[on | off | ttl 5m | ttl 1h]',
266        immediate: true,
267      })
268      .catch(err => $.ui.log(`wavy-usage: /wavy-usage not registered: ${err}`))
269    // Once a minute while on: the cache countdown and the pane's reset counters.
270    // Once a minute, redraw only if a time-driven figure on screen would read differently: the cache ring's step and
271    // countdown, and while the pane is open its reset countdowns. An unchanged screen is left alone.
272    $.clock.every(TICK_MS, () => {
273      void (async () => {
274        // A finished prompt whose settling measurement never came is published on the next tick.
275        if (awaitingSettle && (await $.clock.now()) - lastTurnEnd > SETTLE_MS) {
276          awaitingSettle = false
277          await publish($)
278        }
279        // Mid-prompt: newer measurements are drawn once LIVE_MS has passed since the last draw.
280        const now = await $.clock.now()
281        if (!awaitingSettle && now - lastPublishAt >= LIVE_MS) {
282          const shown = (await read($, screen)).reading
283          if (JSON.stringify(shown) !== JSON.stringify(await read($, reading))) await publish($)
284        }
285        return clockFace($)
286      })()
287        .then(face => {
288          if (face === null || face === lastFace) return
289          lastFace = face
290          $.ui.invalidate('ui.render')
291        })
292        .catch(() => undefined)
293    })
294    return r
295  })
296
297  on('session.measure', async ($, e, next) => {
298    const r = await next(e)
299    if (disabled) return r
300    const fresh = takeReading(e)
301    await update($, reading, () => fresh)
302    const at = await $.clock.now()
303    const five = fresh.fiveHour
304    if (five !== undefined) await update($, samples, s => addSample(s, five, at))
305    lastMeasureAt = at
306    const pct = fresh.ctxPercent
307    const ctxChanged = e.changed.includes('context') && pct !== undefined
308    const costChanged = e.changed.includes('cost') && fresh.usd !== undefined
309    const fiveChanged = e.changed.includes('rateLimits') && fresh.fiveHour !== undefined
310    if (ctxChanged || costChanged || fiveChanged) {
311      const now = await $.clock.now()
312      const patch = {
313        ...(ctxChanged && { ctxPercent: pct }),
314        ...(costChanged && { usd: fresh.usd }),
315        ...(fresh.fiveHour !== undefined && { fiveHour: fresh.fiveHour }),
316      }
317      await update($, history, h => settleTurn(h, patch, now))
318      await syncOwnLog($)
319    }
320    // The first reading of a session shows at once; after that only the measurement settling a finished prompt.
321    if ((await read($, screen)).reading === null || (awaitingSettle && at - lastTurnEnd <= SETTLE_MS)) {
322      awaitingSettle = false
323      await publish($)
324    }
325    if (pct === undefined || !ctxChanged) return r
326    const n = nudge(pct, nudgedAt)
327    nudgedAt = n.nudgedAt
328    if (n.shouldToast && (await read($, isEnabled))) $.ui.toast(nudgeText(pct, t), { timeoutMs: 8000 })
329    return r
330  })
331
332  // Subagent turns stay out of the history and do not refresh the main conversation's cache clock.
333  on('turn.complete', async ($, e, next) => {
334    const r = await next(e)
335    if (disabled || e.agentId || !e.usage) return r
336    const usage = e.usage
337    const current = await read($, reading)
338    const now = await $.clock.now()
339    await update($, history, h => recordTurn(h, usage, current?.ctxPercent ?? 0, current?.usd, now, current?.fiveHour))
340    await update($, lastRequestAt, () => now)
341    lastTurnEnd = now
342    await syncOwnLog($)
343    // The settling measurement may already have come (the two arrive in no fixed order): publish now. Otherwise wait
344    // for it, so the prompt's end draws once.
345    if (now - lastMeasureAt <= SETTLE_MS) await publish($)
346    else awaitingSettle = true
347    return r
348  })
349
350  // Every main-loop request names its effort: the jet follows a change on the next request.
351  on('turn.step', async function* ($, e, next) {
352    if (!disabled && !e.agentId) {
353      const level = jetLevel(e.effort)
354      if (level !== (await read($, effort).catch(() => null))) {
355        await update($, effort, () => level).catch(() => undefined)
356        await $.store.set(EFFORT_KEY, level).catch(() => undefined)
357      }
358    }
359    return yield* next(e)
360  })
361
362  on('ui.close', async ($, e, next) => {
363    const r = await next(e)
364    if (e.id === PANE) await update($, isPaneOpen, () => false)
365    return r
366  })
367
368  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
369    if (disabled) return next(e)
370    if (e.surface !== 'desktop') {
371      const { Text } = $.ui.resolve(e)
372      return <Text dimColor>{t.desktopOnlyPane}</Text>
373    }
374    // Closing is the desktop header's own × control; the ui.close hook clears isPaneOpen.
375    try {
376      const { Box, Button, Svg, Text } = $.ui.resolve(e)
377      const shownNow = await read($, screen)
378      const r = shownNow.reading
379      const turns = shownNow.turns
380      const now = await $.clock.now()
381      const width = Math.min(640, Math.max(160, e.props.bodyColumns * 7))
382      const recent = recentTurns(turns, await read($, costBase))
383      const scale = Math.max(0, ...recent.map(({ turn }) => turnTotal(turn)))
384      const barWidth = Math.max(60, width - 230)
385      const view = await read($, windowView)
386      const since = windowStart(view, r, now)
387      const entries = [...(await read($, others)), ...shownNow.log]
388      const totals = windowTotals(entries, since)
389      const shareWidth = Math.max(60, width - 230)
390      const ttl = await read($, ttlMs)
391      const cache = cacheState(shownNow.lastRequestAt, ttl, now, t)
392      const big = (id: string, label: string, percent: number | undefined, extra?: string, value?: string, color?: string) => (
393        <Box key={`big-${id}`} flexDirection="column" alignItems="center">
394          <Svg
395            source={liquidRing(percent, 64, 6, levelFrom(`pane-${id}`, percent), color)}
396            alt={`${label} ${value ?? percentText(percent)}`}
397            width={64}
398            height={64}
399            isInteractive
400          />
401          <Text dimColor>{label}</Text>
402          <Text bold>{value ?? percentText(percent)}</Text>
403          {extra !== undefined && <Text dimColor>{extra}</Text>}
404        </Box>
405      )
406      const resetWidth = Math.max(60, width - 40)
407      const paceSamples = await read($, samples)
408      // Elapsed time on the blue bar, the limit used on a thin bar under it, and where it ends up at this pace.
409      const resetRow = (label: string, at: number, length: number, clock: string, used: number | undefined, fullClock: (ms: number) => string) => {
410        const pace = forecast(used, at, length, now, length === FIVE_HOURS ? paceSamples : [])
411        return (
412          <Box key={`reset-${label}`} flexDirection="column">
413            <Box flexDirection="row" gap={1} alignItems="center">
414              <Box width={6}>
415                <Text bold>{label}</Text>
416              </Box>
417              <Box flexDirection="column">
418                <Svg source={resetBar(windowElapsed(at, now, length), resetWidth)} alt={t.elapsedAlt(label)} width={resetWidth} height={8} />
419                {used !== undefined && (
420                  <Svg source={resetBar(used / 100, resetWidth, COLORS[heat(used)], 4)} alt={t.usedAlt(label)} width={resetWidth} height={4} />
421                )}
422              </Box>
423            </Box>
424            <Text dimColor>{t.inTime(countdown(at - now, t.units, COUNTDOWN_STEP), clock)}</Text>
425            {pace?.fullAt !== undefined ? (
426              <Text color={COLORS.warn}>{t.paceFull(fullClock(pace.fullAt), countdown(at - pace.fullAt, t.units, COUNTDOWN_STEP))}</Text>
427            ) : (
428              pace && <Text dimColor>{t.paceAtReset(Math.round(pace.atReset))}</Text>
429            )}
430          </Box>
431        )
432      }
433      const groupRows = (groups: Group[]) => {
434        const max = Math.max(0, ...groups.map(g => g.usd))
435        return groups.map(g => (
436          <Box key={`g-${g.name}`} flexDirection="row" gap={1} alignItems="center">
437            <Box width={14}>
438              <Text wrap="truncate">{shownName(g.name)}</Text>
439            </Box>
440            <Svg
441              source={resetBar(max > 0 ? g.usd / max : 0, shareWidth)}
442              alt={`${shownName(g.name)}: ${g.usd > 0 ? costText(g.usd) : t.noCost}`}
443              width={shareWidth}
444              height={8}
445            />
446            <Box width={7}>
447              <Text bold>{g.usd > 0 ? costText(g.usd) : '-'}</Text>
448            </Box>
449            <Text dimColor>{t.turns(g.turns)}</Text>
450          </Box>
451        ))
452      }
453      const legend = [
454        ['in', t.legendIn, t.hintIn],
455        ['out', t.legendOut, t.hintOut],
456        ['cacheWrite', t.legendCacheWrite, t.hintCacheWrite],
457        ['cacheRead', t.legendCacheRead, t.hintCacheRead],
458      ] as const
459      const summary = turnsSummary(recent)
460      // Column widths in cells; the bar column's header spans the bar's pixels (about 7 px a cell).
461      const COL_TURN = 12
462      const COL_TOKENS = 6
463      const COL_COST = 7
464      const COL_FIVE = 7
465      // The bar column's header is drawn at the bar's own pixel width so the columns after it line up.
466      const headerCell = (label: string, w: number) =>
467        `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="14"><text x="0" y="11" font-family="system-ui, sans-serif" font-size="12" fill="#888780">${label.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</text></svg>`
468      const legendWidth = Math.max(16, Math.floor(width / 7 / 2) - 2)
469      const legendItem = ([k, label, hint]: (typeof legend)[number]) => (
470        <Box key={`legend-${k}`} flexDirection="row" gap={1} width={legendWidth}>
471          <Text color={SEGMENTS[k]}>■</Text>
472          <Text dimColor>{`${label}: ${hint}`}</Text>
473        </Box>
474      )
475      const fiveAt = r?.fiveHourResetsAt
476      const sevenAt = r?.sevenDayResetsAt
477      const level = await read($, effort)
478      return (
479        <Box flexDirection="column" gap={1}>
480          {level !== null && (
481            <Box key="jet" flexDirection="column">
482              <Svg
483                source={jetStrip(level, width, JET_PANE_H, JET_PANE_SCALE, t.effort(level), 'jp')}
484                alt={t.effort(level)}
485                width={width}
486                height={JET_PANE_H}
487                isInteractive
488              />
489              <Text dimColor>{t.effort(level)}</Text>
490            </Box>
491          )}
492          {r === null ? (
493            <Text dimColor>{t.waitingPane}</Text>
494          ) : (
495            <Box flexDirection="row" justifyContent="space-around">
496              {r.fiveHour !== undefined && big('5h', t.win5h, r.fiveHour)}
497              {r.sevenDay !== undefined && big('7d', t.win7d, r.sevenDay)}
498              {big('ctx', t.ctx, r.ctxPercent, r.ctxTokens !== undefined ? `${tokens(r.ctxTokens)} / ${tokens(r.ctxWindow)}` : undefined)}
499              {cache !== null && big('cache', t.cache, ringStep(cache.percent), t.ttl(ttlText(ttl, t)), cache.text, ttlColor(cache.percent))}
500            </Box>
501          )}
502          <Box flexDirection="column">
503            <Text bold>{t.lastTurns}</Text>
504            <Text dimColor>{t.lastTurnsHint}</Text>
505          </Box>
506          {recent.length === 0 ? (
507            <Text dimColor>{t.noTurns}</Text>
508          ) : (
509            <Box flexDirection="column">
510              <Text dimColor>
511                {t.turnsTotal(
512                  tokens(summary.tokens),
513                  summary.cost === null ? '-' : costText(summary.cost),
514                  `${t.win5h} ${summary.five === null ? '-' : fiveDeltaText(summary.five, t.fiveReset)}`,
515                )}
516              </Text>
517              <Box key="turn-head" flexDirection="row" gap={1}>
518                <Box width={COL_TURN}>
519                  <Text dimColor>{t.colTurn}</Text>
520                </Box>
521                <Svg source={headerCell(t.colSplit, barWidth)} alt={t.colSplit} width={barWidth} height={14} />
522                <Box width={COL_TOKENS}>
523                  <Text dimColor>{t.colTokens}</Text>
524                </Box>
525                <Box width={COL_COST}>
526                  <Text dimColor>{t.colCost}</Text>
527                </Box>
528                <Box width={COL_FIVE}>
529                  <Text dimColor>{t.win5h}</Text>
530                </Box>
531              </Box>
532              {recent.map(({ turn, delta, cost, five }) => (
533                <Box key={`turn-${turn.n}`} flexDirection="row" gap={1} alignItems="center">
534                  <Box width={COL_TURN}>
535                    <Text dimColor wrap="truncate">{`#${turn.n} ${clockTime(turn.at)}`}</Text>
536                  </Box>
537                  <Svg
538                    source={turnBar(turn, scale, barWidth, [
539                      `${t.legendIn}: ${tokens(turn.in)}`,
540                      `${t.legendOut}: ${tokens(turn.out)}`,
541                      `${t.legendCacheWrite}: ${tokens(turn.cacheWrite)}`,
542                      `${t.legendCacheRead}: ${tokens(turn.cacheRead)}`,
543                    ])}
544                    alt={`${t.barAlt(turn.n, tokens(turn.in), tokens(turn.out), tokens(turn.cacheWrite), tokens(turn.cacheRead))} · ctx ${ctxDeltaText(delta, turn.ctxPercent)}`}
545                    width={barWidth}
546                    height={10}
547                    isInteractive
548                  />
549                  <Box width={COL_TOKENS}>
550                    <Text bold>{tokens(turnTotal(turn))}</Text>
551                  </Box>
552                  <Box width={COL_COST}>
553                    <Text>{costText(cost)}</Text>
554                  </Box>
555                  <Box width={COL_FIVE}>
556                    <Text dimColor>{fiveDeltaText(five, t.fiveReset)}</Text>
557                  </Box>
558                </Box>
559              ))}
560              <Box flexDirection="row" gap={2}>
561                {legendItem(legend[3])}
562                {legendItem(legend[2])}
563              </Box>
564              <Box flexDirection="row" gap={2}>
565                {legendItem(legend[0])}
566                {legendItem(legend[1])}
567              </Box>
568              <Text dimColor>{t.barHint}</Text>
569            </Box>
570          )}
571          <Box flexDirection="row" gap={1} alignItems="center">
572            <Text bold>{t.filledBy}</Text>
573            <Box flexGrow={1} />
574            <Button key="win-5h" label={t.win5h} variant={view === '5h' ? 'primary' : 'secondary'} onPress={() => switchWindow($, '5h')} />
575            <Button key="win-7d" label={t.win7d} variant={view === '7d' ? 'primary' : 'secondary'} onPress={() => switchWindow($, '7d')} />
576          </Box>
577          {totals.turns === 0 ? (
578            <Text dimColor>{t.noEntries}</Text>
579          ) : (
580            <Box flexDirection="column">
581              <Text dimColor>{t.summary(view, totals.turns, tokens(totals.tokens), costText(totals.usd))}</Text>
582              <Text bold>{t.byProject}</Text>
583              {groupRows(breakdown(entries, since, 'project'))}
584              <Text bold>{t.byModel}</Text>
585              {groupRows(breakdown(entries, since, 'model'))}
586            </Box>
587          )}
588          <Text dimColor>{t.localOnly}</Text>
589          {(fiveAt !== undefined || sevenAt !== undefined) && (
590            <Box flexDirection="column">
591              <Text bold>{t.resets}</Text>
592              {fiveAt !== undefined && resetRow(t.win5h, fiveAt, FIVE_HOURS, t.atClock(clockTime(fiveAt)), r?.fiveHour, ms => clockTime(ms))}
593              {sevenAt !== undefined && resetRow(t.win7d, sevenAt, SEVEN_DAYS, dayClock(sevenAt, lang), r?.sevenDay, ms => dayClock(ms, lang))}
594            </Box>
595          )}
596        </Box>
597      )
598    } catch (err) {
599      logRenderError($, err)
600      return next(e)
601    }
602  })
603
604  // The band: liquid rings and a pane button on desktop, one text line in the terminal. Other plugins' bands stay below.
605  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
606    if (disabled || e.props.hasSurvey) return next(e)
607    if (!(await read($, isEnabled))) return next(e)
608    if (e.surface === 'terminal') {
609      try {
610        const { Box, Text } = $.ui.resolve(e)
611        const shownNow = await read($, screen)
612        const r = shownNow.reading
613        const cache = cacheState(shownNow.lastRequestAt, await read($, ttlMs), await $.clock.now(), t)
614        const rest = await next(e)
615        const level = await read($, effort)
616        const parts = [
617          ...terminalParts(r, cache, e.props.bodyColumns, t),
618          ...(level === null ? [] : [{ text: `  ✈ ${level}`, color: JET_TERMINAL_COLOR[level] }]),
619        ]
620        return (
621          <Box flexDirection="column">
622            <Text wrap="truncate">
623              {parts.map((p, i) => (
624                <Text key={String(i)} color={p.color} dimColor={p.dim} bold={p.bold}>
625                  {p.text}
626                </Text>
627              ))}
628            </Text>
629            {rest}
630          </Box>
631        )
632      } catch (err) {
633        logRenderError($, err)
634        return next(e)
635      }
636    }
637    if (e.surface !== 'desktop') return next(e)
638    try {
639      const { Box, Button, Svg, Text } = $.ui.resolve(e)
640      const shownNow = await read($, screen)
641      const r = shownNow.reading
642      const cache = cacheState(shownNow.lastRequestAt, await read($, ttlMs), await $.clock.now(), t)
643      const rest = await next(e)
644      const layout = bandLayout(e.props.bodyColumns)
645      const level = await read($, effort)
646      const metric = (id: string, label: string, percent: number | undefined, extra?: string, value?: string, color?: string) => (
647        <Box key={`m-${id}`} flexDirection="row" gap={1} alignItems="center">
648          <Svg
649            source={liquidRing(percent, BAND_RING, BAND_STROKE, levelFrom(`band-${id}`, percent), color)}
650            alt={`${label} ${value ?? percentText(percent)}`}
651            width={BAND_RING}
652            height={BAND_RING}
653            isInteractive
654          />
655          <Box flexDirection="column">
656            <Text dimColor>{label}</Text>
657            <Box flexDirection="row" gap={1}>
658              <Text bold>{value ?? percentText(percent)}</Text>
659              {extra !== undefined && <Text dimColor>{`· ${extra}`}</Text>}
660            </Box>
661          </Box>
662        </Box>
663      )
664      // A finished turn means the API answered: the cache ring shows even before a measurement.
665      const cacheMetric = cache !== null && metric('cache', t.cache, ringStep(cache.percent), undefined, cache.text, ttlColor(cache.percent))
666      const metrics =
667        r === null
668          ? [
669              metric('5h', t.win5h, undefined),
670              metric('7d', t.win7d, undefined),
671              metric('ctx', t.ctx, undefined),
672              cacheMetric,
673              <Text dimColor>{t.waitingBand}</Text>,
674            ]
675          : [
676              r.fiveHour !== undefined && metric('5h', t.win5h, r.fiveHour),
677              r.sevenDay !== undefined && metric('7d', t.win7d, r.sevenDay),
678              metric('ctx', t.ctx, r.ctxPercent, layout.showCtxTokens && r.ctxTokens !== undefined ? tokens(r.ctxTokens) : undefined),
679              cacheMetric,
680              layout.showCost && r.usd !== undefined && <Text dimColor>{`$${r.usd.toFixed(2)}`}</Text>,
681            ]
682      return (
683        <Box flexDirection="column">
684          <Box flexDirection="row" gap={2} alignItems="center">
685            {metrics}
686            <Box flexGrow={1} />
687            {level !== null && layout.showJet && (
688              <Svg
689                key="jet"
690                source={jetStrip(level, JET_W, JET_H, JET_SCALE, t.effort(level), 'jb')}
691                alt={t.effort(level)}
692                width={JET_W}
693                height={JET_H}
694                isInteractive
695              />
696            )}
697            <Button key="pane" label={t.panel} dimColor onPress={() => togglePane($)} />
698          </Box>
699          {rest}
700        </Box>
701      )
702    } catch (err) {
703      logRenderError($, err)
704      return next(e)
705    }
706  })
707
708  on('command.run', { command: 'wavy-usage' }, async ($, e) => {
709    const arg = e.args.trim().toLowerCase()
710    if (arg === 'on') {
711      await update($, isEnabled, () => true)
712      await $.store.set(ENABLED_KEY, true).catch(() => undefined)
713      const u = await $.session.usage().catch(() => undefined)
714      if (u) {
715        await update($, reading, () => takeReading(u))
716        await publish($)
717      }
718      return { text: t.turnedOn }
719    }
720    if (arg === 'off') {
721      await update($, isEnabled, () => false)
722      await $.store.set(ENABLED_KEY, false).catch(() => undefined)
723      if (await read($, isPaneOpen)) await closePane($)
724      return { text: t.turnedOff }
725    }
726    const ttl = /^ttl\s+(\S+)$/.exec(arg)
727    if (ttl) {
728      const ms = parseTtl(ttl[1] ?? '')
729      if (ms === undefined) return { text: t.ttlUnknown(ttl[1] ?? '') }
730      await update($, ttlMs, () => ms)
731      await $.store.set(TTL_KEY, ms).catch(() => undefined)
732      return { text: t.ttlSet(ttlText(ms, t)) }
733    }
734    if (arg === '') {
735      if (!(await read($, isEnabled))) return { text: t.turnedOff }
736      const wasOpen = await read($, isPaneOpen)
737      await togglePane($)
738      return { text: wasOpen ? t.paneClosed : t.paneOpened }
739    }
740    return { text: t.argUnknown(arg) }
741  })
742}
743
hooks/i18n.ts 605 lines
1// Every string on screen, in eight languages. Stored data is language-neutral; this is display only.
2// Languages: those of the 10 countries with the largest share of Claude.ai use (Anthropic Economic Index, February 2026), plus Turkish.
3
4export type Lang = 'en' | 'tr' | 'fr' | 'de' | 'ja' | 'ko' | 'pt-BR' | 'es'
5
6export const LANGS: readonly Lang[] = ['en', 'tr', 'fr', 'de', 'ja', 'ko', 'pt-BR', 'es']
7
8// numSep: between a number and its unit, partSep: between two parts ("2s 10dk", "2 Std. 10 Min.", "2時間10分").
9export type Units = { m: string; h: string; d: string; numSep: string; partSep: string }
10
11export type Strings = {
12  units: Units
13  win5h: string
14  win7d: string
15  ctx: string
16  cache: string
17  panel: string
18  paneTitle: string
19  waitingBand: string
20  waitingPane: string
21  cold: string
22  lastTurns: string
23  noTurns: string
24  lastTurnsHint: string
25  turnsTotal: (tokens: string, cost: string, five: string) => string
26  colTurn: string
27  colSplit: string
28  colTokens: string
29  colCost: string
30  hintIn: string
31  hintOut: string
32  hintCacheWrite: string
33  hintCacheRead: string
34  barHint: string
35  fiveReset: string
36  legendIn: string
37  legendOut: string
38  legendCacheWrite: string
39  legendCacheRead: string
40  filledBy: string
41  noEntries: string
42  byProject: string
43  byModel: string
44  summary: (view: '5h' | '7d', turns: number, tokens: string, cost: string) => string
45  turns: (n: number) => string
46  localOnly: string
47  resets: string
48  inTime: (duration: string, clock: string) => string
49  atClock: (hhmm: string) => string
50  ttl: (text: string) => string
51  ttl5m: string
52  ttl1h: string
53  unknown: string
54  desktopOnlyPane: string
55  nudge: (pct: number) => string
56  cmdDescription: string
57  turnedOn: string
58  turnedOff: string
59  paneOpened: string
60  paneClosed: string
61  ttlSet: (text: string) => string
62  ttlUnknown: (arg: string) => string
63  argUnknown: (arg: string) => string
64  barAlt: (n: number, inp: string, out: string, write: string, readTokens: string) => string
65  elapsedAlt: (label: string) => string
66  usedAlt: (label: string) => string
67  paceAtReset: (pct: number) => string
68  paceFull: (clock: string, early: string) => string
69  noCost: string
70  effort: (level: string) => string
71}
72
73const USAGE = '[on | off | ttl 5m | ttl 1h]'
74
75const en: Strings = {
76  units: { m: 'm', h: 'h', d: 'd', numSep: '', partSep: ' ' },
77  win5h: '5h',
78  win7d: '7d',
79  ctx: 'ctx',
80  cache: 'cache',
81  panel: 'Panel',
82  paneTitle: 'Usage',
83  waitingBand: 'waiting for the first reply',
84  waitingPane: 'Waiting for the first reply',
85  cold: 'cold',
86  lastTurns: 'Last 5 turns',
87  noTurns: 'No finished turns yet',
88  lastTurnsHint: 'Each message you send is one turn. The bar shows the tokens it processed.',
89  turnsTotal: (tokens, cost, five) => `Total ${tokens} · ${cost} · ${five}`,
90  colTurn: 'Turn',
91  colSplit: 'Token split',
92  colTokens: 'Tokens',
93  colCost: 'Cost',
94  hintIn: 'new message and files',
95  hintOut: "Claude's writing (priciest)",
96  hintCacheWrite: 'storing new context',
97  hintCacheRead: 're-reading the conversation (cheap)',
98  barHint: 'Hover a bar segment to see its name and size.',
99  fiveReset: 'reset',
100  legendIn: 'in',
101  legendOut: 'out',
102  legendCacheWrite: 'cache write',
103  legendCacheRead: 'cache read',
104  filledBy: 'What filled the window',
105  noEntries: 'Nothing recorded in this window',
106  byProject: 'By project',
107  byModel: 'By model',
108  summary: (view, turns, tokens, cost) =>
109    `${view === '5h' ? 'Last 5 hours' : 'Last 7 days'}: ${turns === 1 ? '1 turn' : `${turns} turns`} · ${tokens} tokens · ${cost}`,
110  turns: n => (n === 1 ? '1 turn' : `${n} turns`),
111  localOnly: 'Only Claude Code sessions on this computer, recorded since install.',
112  resets: 'Resets',
113  inTime: (duration, clock) => `in ${duration} · ${clock}`,
114  atClock: hhmm => `at ${hhmm}`,
115  ttl: text => `TTL ${text}`,
116  ttl5m: '5 minutes',
117  ttl1h: '1 hour',
118  unknown: 'unknown',
119  desktopOnlyPane: 'The wavy-usage pane draws on desktop only',
120  nudge: pct => `context ${pct}% · /clear if the next thing is a new task, /compact to keep going`,
121  cmdDescription: 'Usage rings: no argument toggles the pane; on/off; ttl 5m/1h sets the cache lifetime (wavy-usage)',
122  turnedOn: 'wavy-usage on',
123  turnedOff: 'wavy-usage off. Turn it back on with /wavy-usage on',
124  paneOpened: 'Pane opened',
125  paneClosed: 'Pane closed',
126  ttlSet: text => `cache TTL: ${text}`,
127  ttlUnknown: arg => `wavy-usage: "${arg}" not recognized. Usage: /wavy-usage ttl 5m | ttl 1h`,
128  argUnknown: arg => `wavy-usage: "${arg}" not recognized. Usage: /wavy-usage ${USAGE}`,
129  barAlt: (n, inp, out, write, readTokens) => `Turn ${n}: ${inp} in, ${out} out, ${write} cache write, ${readTokens} cache read`,
130  elapsedAlt: label => `Elapsed part of the ${label} window`,
131  usedAlt: label => `Used part of the ${label} limit`,
132  paceAtReset: pct => `at this pace: ${pct}% at reset`,
133  paceFull: (clock, early) => `at this pace: full by ${clock}, ${early} before reset`,
134  noCost: 'no cost',
135  effort: l => `Effort: ${l}`,
136}
137
138const tr: Strings = {
139  units: { m: 'dk', h: 's', d: 'g', numSep: '', partSep: ' ' },
140  win5h: '5s',
141  win7d: '7g',
142  ctx: 'ctx',
143  cache: 'cache',
144  panel: 'Panel',
145  paneTitle: 'Kullanım',
146  waitingBand: 'ilk yanıt bekleniyor',
147  waitingPane: 'İlk yanıt bekleniyor',
148  cold: 'soğuk',
149  lastTurns: 'Son 5 tur',
150  noTurns: 'Henüz tamamlanan tur yok',
151  lastTurnsHint: "Her mesaj bir tur. Çubuk o turda işlenen token'ları gösterir.",
152  turnsTotal: (tokens, cost, five) => `Toplam ${tokens} · ${cost} · ${five}`,
153  colTurn: 'Tur',
154  colSplit: 'Token dağılımı',
155  colTokens: 'Token',
156  colCost: 'Maliyet',
157  hintIn: 'yeni mesaj ve dosyalar',
158  hintOut: "Claude'un yazdıkları (en pahalı)",
159  hintCacheWrite: 'yeni içeriği saklama',
160  hintCacheRead: 'sohbeti yeniden okuma (ucuz)',
161  barHint: 'Adını ve miktarını görmek için çubuk parçalarının üzerine gelin.',
162  fiveReset: 'sıfırlandı',
163  legendIn: 'girdi',
164  legendOut: 'çıktı',
165  legendCacheWrite: 'cache yazma',
166  legendCacheRead: 'cache okuma',
167  filledBy: 'Pencereyi dolduranlar',
168  noEntries: 'Bu pencerede kayıt yok',
169  byProject: 'Projeye göre',
170  byModel: 'Modele göre',
171  summary: (view, turns, tokens, cost) => `Son ${view === '5h' ? '5 saatte' : '7 günde'} ${turns} tur · ${tokens} token · ${cost}`,
172  turns: n => `${n} tur`,
173  localOnly: 'Yalnızca bu bilgisayardaki Claude Code oturumları; kayıt kurulumdan itibaren.',
174  resets: 'Sıfırlamalar',
175  inTime: (duration, clock) => `${duration} sonra · ${clock}`,
176  atClock: hhmm => `saat ${hhmm}`,
177  ttl: text => `TTL ${text}`,
178  ttl5m: '5 dakika',
179  ttl1h: '1 saat',
180  unknown: 'bilinmiyor',
181  desktopOnlyPane: "wavy-usage paneli yalnızca desktop'ta çizilir",
182  nudge: pct => `context %${pct} · yeni iş ise /clear, devam için /compact`,
183  cmdDescription: 'Kullanım halkaları: argümansız paneli açar/kapatır, on/off modu, ttl 5m/1h cache süresini ayarlar (wavy-usage)',
184  turnedOn: 'wavy-usage açık',
185  turnedOff: 'wavy-usage kapalı. Açmak için /wavy-usage on',
186  paneOpened: 'Panel açıldı',
187  paneClosed: 'Panel kapandı',
188  ttlSet: text => `cache TTL: ${text}`,
189  ttlUnknown: arg => `wavy-usage: "${arg}" tanınmadı. Kullanım: /wavy-usage ttl 5m | ttl 1h`,
190  argUnknown: arg => `wavy-usage: "${arg}" tanınmadı. Kullanım: /wavy-usage ${USAGE}`,
191  barAlt: (n, inp, out, write, readTokens) => `Tur ${n}: ${inp} in, ${out} out, ${write} cache yazma, ${readTokens} cache okuma`,
192  elapsedAlt: label => `${label} penceresinin geçen kısmı`,
193  usedAlt: label => `${label} limitinin kullanılan kısmı`,
194  paceAtReset: pct => `bu hızla sıfırlanmada %${pct}`,
195  paceFull: (clock, early) => `bu hızla ${clock} civarı dolar, sıfırlanmadan ${early} önce`,
196  noCost: 'maliyet yok',
197  effort: l => `Efor: ${l}`,
198}
199
200const fr: Strings = {
201  units: { m: 'min', h: 'h', d: 'j', numSep: '', partSep: ' ' },
202  win5h: '5h',
203  win7d: '7j',
204  ctx: 'ctx',
205  cache: 'cache',
206  panel: 'Panneau',
207  paneTitle: 'Utilisation',
208  waitingBand: 'en attente de la première réponse',
209  waitingPane: 'En attente de la première réponse',
210  cold: 'froid',
211  lastTurns: '5 derniers tours',
212  noTurns: 'Aucun tour terminé pour le moment',
213  lastTurnsHint: 'Chaque message envoyé est un tour. La barre montre les tokens traités.',
214  turnsTotal: (tokens, cost, five) => `Total ${tokens} · ${cost} · ${five}`,
215  colTurn: 'Tour',
216  colSplit: 'Répartition des tokens',
217  colTokens: 'Tokens',
218  colCost: 'Coût',
219  hintIn: 'nouveau message et fichiers',
220  hintOut: 'texte écrit par Claude (le plus cher)',
221  hintCacheWrite: 'stockage du nouveau contexte',
222  hintCacheRead: 'relecture de la conversation (bon marché)',
223  barHint: 'Survolez un segment pour voir son nom et sa taille.',
224  fiveReset: 'réinitialisé',
225  legendIn: 'entrée',
226  legendOut: 'sortie',
227  legendCacheWrite: 'écriture cache',
228  legendCacheRead: 'lecture cache',
229  filledBy: 'Ce qui remplit la fenêtre',
230  noEntries: 'Aucun enregistrement dans cette fenêtre',
231  byProject: 'Par projet',
232  byModel: 'Par modèle',
233  summary: (view, turns, tokens, cost) =>
234    `${view === '5h' ? '5 dernières heures' : '7 derniers jours'} : ${turns === 1 ? '1 tour' : `${turns} tours`} · ${tokens} tokens · ${cost}`,
235  turns: n => (n === 1 ? '1 tour' : `${n} tours`),
236  localOnly: "Uniquement les sessions Claude Code de cet ordinateur, enregistrées depuis l'installation.",
237  resets: 'Réinitialisations',
238  inTime: (duration, clock) => `dans ${duration} · ${clock}`,
239  atClock: hhmm => `à ${hhmm}`,
240  ttl: text => `TTL ${text}`,
241  ttl5m: '5 minutes',
242  ttl1h: '1 heure',
243  unknown: 'inconnu',
244  desktopOnlyPane: "Le panneau wavy-usage ne s'affiche que sur desktop",
245  nudge: pct => `contexte ${pct} % · /clear pour une nouvelle tâche, /compact pour continuer`,
246  cmdDescription: "Anneaux d'utilisation : sans argument, ouvre/ferme le panneau ; on/off ; ttl 5m/1h règle la durée du cache (wavy-usage)",
247  turnedOn: 'wavy-usage activé',
248  turnedOff: 'wavy-usage désactivé. Réactivez-le avec /wavy-usage on',
249  paneOpened: 'Panneau ouvert',
250  paneClosed: 'Panneau fermé',
251  ttlSet: text => `TTL du cache : ${text}`,
252  ttlUnknown: arg => `wavy-usage : « ${arg} » non reconnu. Utilisation : /wavy-usage ttl 5m | ttl 1h`,
253  argUnknown: arg => `wavy-usage : « ${arg} » non reconnu. Utilisation : /wavy-usage ${USAGE}`,
254  barAlt: (n, inp, out, write, readTokens) =>
255    `Tour ${n} : ${inp} en entrée, ${out} en sortie, ${write} écrits en cache, ${readTokens} lus en cache`,
256  elapsedAlt: label => `Partie écoulée de la fenêtre ${label}`,
257  usedAlt: label => `Partie utilisée de la limite ${label}`,
258  paceAtReset: pct => `à ce rythme : ${pct} % à la réinitialisation`,
259  paceFull: (clock, early) => `à ce rythme : pleine vers ${clock}, ${early} avant la réinitialisation`,
260  noCost: 'aucun coût',
261  effort: l => `Effort : ${l}`,
262}
263
264const de: Strings = {
265  units: { m: 'Min.', h: 'Std.', d: 'T.', numSep: ' ', partSep: ' ' },
266  win5h: '5h',
267  win7d: '7T',
268  ctx: 'ctx',
269  cache: 'Cache',
270  panel: 'Panel',
271  paneTitle: 'Nutzung',
272  waitingBand: 'warte auf die erste Antwort',
273  waitingPane: 'Warte auf die erste Antwort',
274  cold: 'kalt',
275  lastTurns: 'Letzte 5 Runden',
276  noTurns: 'Noch keine abgeschlossene Runde',
277  lastTurnsHint: 'Jede gesendete Nachricht ist eine Runde. Der Balken zeigt die verarbeiteten Tokens.',
278  turnsTotal: (tokens, cost, five) => `Gesamt ${tokens} · ${cost} · ${five}`,
279  colTurn: 'Runde',
280  colSplit: 'Token-Aufteilung',
281  colTokens: 'Tokens',
282  colCost: 'Kosten',
283  hintIn: 'neue Nachricht und Dateien',
284  hintOut: 'von Claude geschrieben (am teuersten)',
285  hintCacheWrite: 'neuen Kontext speichern',
286  hintCacheRead: 'Unterhaltung erneut lesen (günstig)',
287  barHint: 'Fahre über ein Segment, um Name und Menge zu sehen.',
288  fiveReset: 'zurückgesetzt',
289  legendIn: 'Eingabe',
290  legendOut: 'Ausgabe',
291  legendCacheWrite: 'Cache-Schreiben',
292  legendCacheRead: 'Cache-Lesen',
293  filledBy: 'Was das Fenster füllt',
294  noEntries: 'Keine Einträge in diesem Fenster',
295  byProject: 'Nach Projekt',
296  byModel: 'Nach Modell',
297  summary: (view, turns, tokens, cost) =>
298    `${view === '5h' ? 'Letzte 5 Stunden' : 'Letzte 7 Tage'}: ${turns === 1 ? '1 Runde' : `${turns} Runden`} · ${tokens} Tokens · ${cost}`,
299  turns: n => (n === 1 ? '1 Runde' : `${n} Runden`),
300  localOnly: 'Nur Claude-Code-Sitzungen auf diesem Computer, erfasst seit der Installation.',
301  resets: 'Zurücksetzungen',
302  inTime: (duration, clock) => `in ${duration} · ${clock}`,
303  atClock: hhmm => `um ${hhmm}`,
304  ttl: text => `TTL ${text}`,
305  ttl5m: '5 Minuten',
306  ttl1h: '1 Stunde',
307  unknown: 'unbekannt',
308  desktopOnlyPane: 'Das wavy-usage-Panel wird nur auf dem Desktop gezeichnet',
309  nudge: pct => `Kontext ${pct} % · /clear bei einer neuen Aufgabe, /compact zum Weitermachen`,
310  cmdDescription: 'Nutzungsringe: ohne Argument Panel ein/aus; on/off; ttl 5m/1h setzt die Cache-Dauer (wavy-usage)',
311  turnedOn: 'wavy-usage an',
312  turnedOff: 'wavy-usage aus. Mit /wavy-usage on wieder einschalten',
313  paneOpened: 'Panel geöffnet',
314  paneClosed: 'Panel geschlossen',
315  ttlSet: text => `Cache-TTL: ${text}`,
316  ttlUnknown: arg => `wavy-usage: „${arg}“ nicht erkannt. Verwendung: /wavy-usage ttl 5m | ttl 1h`,
317  argUnknown: arg => `wavy-usage: „${arg}“ nicht erkannt. Verwendung: /wavy-usage ${USAGE}`,
318  barAlt: (n, inp, out, write, readTokens) =>
319    `Runde ${n}: ${inp} Eingabe, ${out} Ausgabe, ${write} Cache-Schreiben, ${readTokens} Cache-Lesen`,
320  elapsedAlt: label => `Verstrichener Teil des ${label}-Fensters`,
321  usedAlt: label => `Genutzter Teil des ${label}-Limits`,
322  paceAtReset: pct => `bei diesem Tempo: ${pct} % beim Zurücksetzen`,
323  paceFull: (clock, early) => `bei diesem Tempo: voll gegen ${clock}, ${early} vor dem Zurücksetzen`,
324  noCost: 'keine Kosten',
325  effort: l => `Aufwand: ${l}`,
326}
327
328const ja: Strings = {
329  units: { m: '分', h: '時間', d: '日', numSep: '', partSep: '' },
330  win5h: '5時間',
331  win7d: '7日',
332  ctx: 'ctx',
333  cache: 'キャッシュ',
334  panel: 'パネル',
335  paneTitle: '使用状況',
336  waitingBand: '最初の応答を待っています',
337  waitingPane: '最初の応答を待っています',
338  cold: '期限切れ',
339  lastTurns: '直近5ターン',
340  noTurns: '完了したターンはまだありません',
341  lastTurnsHint: '送信したメッセージ1件が1ターンです。バーはそのターンで処理したトークンを示します。',
342  turnsTotal: (tokens, cost, five) => `合計 ${tokens} · ${cost} · ${five}`,
343  colTurn: 'ターン',
344  colSplit: 'トークン内訳',
345  colTokens: 'トークン',
346  colCost: 'コスト',
347  hintIn: '新しいメッセージとファイル',
348  hintOut: 'Claudeの出力(最も高価)',
349  hintCacheWrite: '新しいコンテキストの保存',
350  hintCacheRead: '会話の再読み込み(安価)',
351  barHint: 'バーの区間にカーソルを合わせると名前と量が表示されます。',
352  fiveReset: 'リセット',
353  legendIn: '入力',
354  legendOut: '出力',
355  legendCacheWrite: 'キャッシュ書込',
356  legendCacheRead: 'キャッシュ読込',
357  filledBy: 'ウィンドウの内訳',
358  noEntries: 'このウィンドウの記録はありません',
359  byProject: 'プロジェクト別',
360  byModel: 'モデル別',
361  summary: (view, turns, tokens, cost) => `${view === '5h' ? '直近5時間' : '直近7日'}: ${turns}ターン · ${tokens}トークン · ${cost}`,
362  turns: n => `${n}ターン`,
363  localOnly: 'このコンピューター上の Claude Code セッションのみ。インストール以降の記録です。',
364  resets: 'リセット',
365  inTime: (duration, clock) => `${duration}後 · ${clock}`,
366  atClock: hhmm => hhmm,
367  ttl: text => `TTL ${text}`,
368  ttl5m: '5分',
369  ttl1h: '1時間',
370  unknown: '不明',
371  desktopOnlyPane: 'wavy-usage パネルはデスクトップでのみ表示されます',
372  nudge: pct => `コンテキスト ${pct}% · 新しい作業なら /clear、続けるなら /compact`,
373  cmdDescription: '使用状況リング: 引数なしでパネルを開閉、on/off、ttl 5m/1h でキャッシュ期間を設定 (wavy-usage)',
374  turnedOn: 'wavy-usage オン',
375  turnedOff: 'wavy-usage オフ。/wavy-usage on で再開します',
376  paneOpened: 'パネルを開きました',
377  paneClosed: 'パネルを閉じました',
378  ttlSet: text => `キャッシュ TTL: ${text}`,
379  ttlUnknown: arg => `wavy-usage: 「${arg}」は認識できません。使い方: /wavy-usage ttl 5m | ttl 1h`,
380  argUnknown: arg => `wavy-usage: 「${arg}」は認識できません。使い方: /wavy-usage ${USAGE}`,
381  barAlt: (n, inp, out, write, readTokens) => `ターン${n}: 入力 ${inp}、出力 ${out}、キャッシュ書込 ${write}、キャッシュ読込 ${readTokens}`,
382  elapsedAlt: label => `${label}ウィンドウの経過分`,
383  usedAlt: label => `${label}上限の使用分`,
384  paceAtReset: pct => `このペースだとリセット時 ${pct}%`,
385  paceFull: (clock, early) => `このペースだと ${clock} ごろ上限、リセットの ${early} 前`,
386  noCost: 'コストなし',
387  effort: l => `エフォート: ${l}`,
388}
389
390const ko: Strings = {
391  units: { m: '분', h: '시간', d: '일', numSep: '', partSep: ' ' },
392  win5h: '5시간',
393  win7d: '7일',
394  ctx: 'ctx',
395  cache: '캐시',
396  panel: '패널',
397  paneTitle: '사용량',
398  waitingBand: '첫 응답을 기다리는 중',
399  waitingPane: '첫 응답을 기다리는 중',
400  cold: '만료됨',
401  lastTurns: '최근 5턴',
402  noTurns: '아직 완료된 턴이 없습니다',
403  lastTurnsHint: '보낸 메시지 하나가 한 턴입니다. 막대는 그 턴에서 처리한 토큰을 보여줍니다.',
404  turnsTotal: (tokens, cost, five) => `합계 ${tokens} · ${cost} · ${five}`,
405  colTurn: '턴',
406  colSplit: '토큰 구성',
407  colTokens: '토큰',
408  colCost: '비용',
409  hintIn: '새 메시지와 파일',
410  hintOut: 'Claude가 쓴 내용 (가장 비쌈)',
411  hintCacheWrite: '새 컨텍스트 저장',
412  hintCacheRead: '대화 다시 읽기 (저렴)',
413  barHint: '막대 구간에 마우스를 올리면 이름과 양이 보입니다.',
414  fiveReset: '초기화됨',
415  legendIn: '입력',
416  legendOut: '출력',
417  legendCacheWrite: '캐시 쓰기',
418  legendCacheRead: '캐시 읽기',
419  filledBy: '윈도우를 채운 항목',
420  noEntries: '이 윈도우에는 기록이 없습니다',
421  byProject: '프로젝트별',
422  byModel: '모델별',
423  summary: (view, turns, tokens, cost) => `${view === '5h' ? '최근 5시간' : '최근 7일'}: ${turns}턴 · 토큰 ${tokens} · ${cost}`,
424  turns: n => `${n}턴`,
425  localOnly: '이 컴퓨터의 Claude Code 세션만 포함하며, 설치 이후부터 기록됩니다.',
426  resets: '초기화',
427  inTime: (duration, clock) => `${duration} 후 · ${clock}`,
428  atClock: hhmm => hhmm,
429  ttl: text => `TTL ${text}`,
430  ttl5m: '5분',
431  ttl1h: '1시간',
432  unknown: '알 수 없음',
433  desktopOnlyPane: 'wavy-usage 패널은 데스크톱에서만 표시됩니다',
434  nudge: pct => `컨텍스트 ${pct}% · 새 작업이면 /clear, 계속하려면 /compact`,
435  cmdDescription: '사용량 링: 인수 없이 패널 열기/닫기, on/off, ttl 5m/1h로 캐시 유지 시간 설정 (wavy-usage)',
436  turnedOn: 'wavy-usage 켜짐',
437  turnedOff: 'wavy-usage 꺼짐. /wavy-usage on 으로 다시 켭니다',
438  paneOpened: '패널을 열었습니다',
439  paneClosed: '패널을 닫았습니다',
440  ttlSet: text => `캐시 TTL: ${text}`,
441  ttlUnknown: arg => `wavy-usage: "${arg}"을(를) 인식할 수 없습니다. 사용법: /wavy-usage ttl 5m | ttl 1h`,
442  argUnknown: arg => `wavy-usage: "${arg}"을(를) 인식할 수 없습니다. 사용법: /wavy-usage ${USAGE}`,
443  barAlt: (n, inp, out, write, readTokens) => `턴 ${n}: 입력 ${inp}, 출력 ${out}, 캐시 쓰기 ${write}, 캐시 읽기 ${readTokens}`,
444  elapsedAlt: label => `${label} 윈도우의 경과 부분`,
445  usedAlt: label => `${label} 한도의 사용 부분`,
446  paceAtReset: pct => `이 속도면 초기화 시점에 ${pct}%`,
447  paceFull: (clock, early) => `이 속도면 ${clock}쯤 한도 도달, 초기화 ${early} 전`,
448  noCost: '비용 없음',
449  effort: l => `노력 수준: ${l}`,
450}
451
452const ptBR: Strings = {
453  units: { m: 'min', h: 'h', d: 'd', numSep: '', partSep: ' ' },
454  win5h: '5h',
455  win7d: '7d',
456  ctx: 'ctx',
457  cache: 'cache',
458  panel: 'Painel',
459  paneTitle: 'Uso',
460  waitingBand: 'aguardando a primeira resposta',
461  waitingPane: 'Aguardando a primeira resposta',
462  cold: 'frio',
463  lastTurns: 'Últimos 5 turnos',
464  noTurns: 'Nenhum turno concluído ainda',
465  lastTurnsHint: 'Cada mensagem enviada é um turno. A barra mostra os tokens processados.',
466  turnsTotal: (tokens, cost, five) => `Total ${tokens} · ${cost} · ${five}`,
467  colTurn: 'Turno',
468  colSplit: 'Divisão de tokens',
469  colTokens: 'Tokens',
470  colCost: 'Custo',
471  hintIn: 'nova mensagem e arquivos',
472  hintOut: 'texto escrito pelo Claude (o mais caro)',
473  hintCacheWrite: 'armazenar novo contexto',
474  hintCacheRead: 'reler a conversa (barato)',
475  barHint: 'Passe o mouse sobre um segmento para ver nome e quantidade.',
476  fiveReset: 'reiniciado',
477  legendIn: 'entrada',
478  legendOut: 'saída',
479  legendCacheWrite: 'escrita em cache',
480  legendCacheRead: 'leitura de cache',
481  filledBy: 'O que encheu a janela',
482  noEntries: 'Nenhum registro nesta janela',
483  byProject: 'Por projeto',
484  byModel: 'Por modelo',
485  summary: (view, turns, tokens, cost) =>
486    `${view === '5h' ? 'Últimas 5 horas' : 'Últimos 7 dias'}: ${turns === 1 ? '1 turno' : `${turns} turnos`} · ${tokens} tokens · ${cost}`,
487  turns: n => (n === 1 ? '1 turno' : `${n} turnos`),
488  localOnly: 'Apenas sessões do Claude Code neste computador, registradas desde a instalação.',
489  resets: 'Redefinições',
490  inTime: (duration, clock) => `em ${duration} · ${clock}`,
491  atClock: hhmm => `às ${hhmm}`,
492  ttl: text => `TTL ${text}`,
493  ttl5m: '5 minutos',
494  ttl1h: '1 hora',
495  unknown: 'desconhecido',
496  desktopOnlyPane: 'O painel do wavy-usage só é desenhado no desktop',
497  nudge: pct => `contexto ${pct}% · /clear se for uma nova tarefa, /compact para continuar`,
498  cmdDescription: 'Anéis de uso: sem argumento abre/fecha o painel; on/off; ttl 5m/1h define a duração do cache (wavy-usage)',
499  turnedOn: 'wavy-usage ligado',
500  turnedOff: 'wavy-usage desligado. Ligue de novo com /wavy-usage on',
501  paneOpened: 'Painel aberto',
502  paneClosed: 'Painel fechado',
503  ttlSet: text => `TTL do cache: ${text}`,
504  ttlUnknown: arg => `wavy-usage: "${arg}" não reconhecido. Uso: /wavy-usage ttl 5m | ttl 1h`,
505  argUnknown: arg => `wavy-usage: "${arg}" não reconhecido. Uso: /wavy-usage ${USAGE}`,
506  barAlt: (n, inp, out, write, readTokens) =>
507    `Turno ${n}: ${inp} de entrada, ${out} de saída, ${write} escritos em cache, ${readTokens} lidos do cache`,
508  elapsedAlt: label => `Parte decorrida da janela de ${label}`,
509  usedAlt: label => `Parte usada do limite de ${label}`,
510  paceAtReset: pct => `neste ritmo: ${pct}% na redefinição`,
511  paceFull: (clock, early) => `neste ritmo: cheio por volta de ${clock}, ${early} antes da redefinição`,
512  noCost: 'sem custo',
513  effort: l => `Esforço: ${l}`,
514}
515
516const es: Strings = {
517  units: { m: 'min', h: 'h', d: 'd', numSep: '', partSep: ' ' },
518  win5h: '5h',
519  win7d: '7d',
520  ctx: 'ctx',
521  cache: 'caché',
522  panel: 'Panel',
523  paneTitle: 'Uso',
524  waitingBand: 'esperando la primera respuesta',
525  waitingPane: 'Esperando la primera respuesta',
526  cold: 'frío',
527  lastTurns: 'Últimos 5 turnos',
528  noTurns: 'Aún no hay turnos terminados',
529  lastTurnsHint: 'Cada mensaje enviado es un turno. La barra muestra los tokens procesados.',
530  turnsTotal: (tokens, cost, five) => `Total ${tokens} · ${cost} · ${five}`,
531  colTurn: 'Turno',
532  colSplit: 'Reparto de tokens',
533  colTokens: 'Tokens',
534  colCost: 'Costo',
535  hintIn: 'nuevo mensaje y archivos',
536  hintOut: 'texto escrito por Claude (lo más caro)',
537  hintCacheWrite: 'guardar contexto nuevo',
538  hintCacheRead: 'releer la conversación (barato)',
539  barHint: 'Pasa el cursor sobre un segmento para ver su nombre y cantidad.',
540  fiveReset: 'reiniciado',
541  legendIn: 'entrada',
542  legendOut: 'salida',
543  legendCacheWrite: 'escritura de caché',
544  legendCacheRead: 'lectura de caché',
545  filledBy: 'Qué llenó la ventana',
546  noEntries: 'No hay registros en esta ventana',
547  byProject: 'Por proyecto',
548  byModel: 'Por modelo',
549  summary: (view, turns, tokens, cost) =>
550    `${view === '5h' ? 'Últimas 5 horas' : 'Últimos 7 días'}: ${turns === 1 ? '1 turno' : `${turns} turnos`} · ${tokens} tokens · ${cost}`,
551  turns: n => (n === 1 ? '1 turno' : `${n} turnos`),
552  localOnly: 'Solo sesiones de Claude Code en este equipo, registradas desde la instalación.',
553  resets: 'Reinicios',
554  inTime: (duration, clock) => `en ${duration} · ${clock}`,
555  atClock: hhmm => `a las ${hhmm}`,
556  ttl: text => `TTL ${text}`,
557  ttl5m: '5 minutos',
558  ttl1h: '1 hora',
559  unknown: 'desconocido',
560  desktopOnlyPane: 'El panel de wavy-usage solo se dibuja en el escritorio',
561  nudge: pct => `contexto ${pct}% · /clear si es una tarea nueva, /compact para seguir`,
562  cmdDescription: 'Anillos de uso: sin argumento abre/cierra el panel; on/off; ttl 5m/1h fija la duración de la caché (wavy-usage)',
563  turnedOn: 'wavy-usage activado',
564  turnedOff: 'wavy-usage desactivado. Vuelve a activarlo con /wavy-usage on',
565  paneOpened: 'Panel abierto',
566  paneClosed: 'Panel cerrado',
567  ttlSet: text => `TTL de la caché: ${text}`,
568  ttlUnknown: arg => `wavy-usage: "${arg}" no reconocido. Uso: /wavy-usage ttl 5m | ttl 1h`,
569  argUnknown: arg => `wavy-usage: "${arg}" no reconocido. Uso: /wavy-usage ${USAGE}`,
570  barAlt: (n, inp, out, write, readTokens) =>
571    `Turno ${n}: ${inp} de entrada, ${out} de salida, ${write} escritos en caché, ${readTokens} leídos de caché`,
572  elapsedAlt: label => `Parte transcurrida de la ventana de ${label}`,
573  usedAlt: label => `Parte usada del límite de ${label}`,
574  paceAtReset: pct => `a este ritmo: ${pct}% al reiniciar`,
575  paceFull: (clock, early) => `a este ritmo: lleno hacia ${clock}, ${early} antes del reinicio`,
576  noCost: 'sin costo',
577  effort: l => `Esfuerzo: ${l}`,
578}
579
580export const STRINGS: Record<Lang, Strings> = { en, tr, fr, de, ja, ko, 'pt-BR': ptBR, es }
581
582// Claude Code's language setting is free text; English names, native names and language codes all map.
583const NAMES: Record<string, Lang> = {
584  en: 'en', english: 'en',
585  tr: 'tr', turkish: 'tr', türkçe: 'tr', turkce: 'tr',
586  fr: 'fr', french: 'fr', français: 'fr', francais: 'fr',
587  de: 'de', german: 'de', deutsch: 'de',
588  ja: 'ja', japanese: 'ja', 日本語: 'ja',
589  ko: 'ko', korean: 'ko', 한국어: 'ko',
590  pt: 'pt-BR', 'pt-br': 'pt-BR', portuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 'brazilian portuguese': 'pt-BR',
591  es: 'es', spanish: 'es', español: 'es', espanol: 'es',
592}
593
594export const langFromText = (text: string | undefined): Lang | undefined => {
595  if (!text) return undefined
596  const s = text.trim().toLowerCase()
597  return NAMES[s] ?? NAMES[s.split(/[-_]/)[0] ?? '']
598}
599
600// Explicit choice (userConfig) > Claude Code's language setting > system locale > English.
601export const resolveLang = (pref: unknown, claudeLanguage: unknown, locale: string | undefined): Lang => {
602  if (typeof pref === 'string' && pref !== 'auto' && (LANGS as readonly string[]).includes(pref)) return pref as Lang
603  return langFromText(typeof claudeLanguage === 'string' ? claudeLanguage : undefined) ?? langFromText(locale) ?? 'en'
604}
605
hooks/model.ts 360 lines
1import type { SessionMeasureInput } from 'claude-code'
2
3import type { LogEntry, Reading, Turn } from '../types'
4import type { Lang, Strings, Units } from './i18n'
5import { STRINGS } from './i18n'
6
7// Pure data logic: no $, so it is tested on its own.
8
9export const FIVE_HOURS = 5 * 60 * 60 * 1000
10
11export type Heat = 'calm' | 'warn' | 'hot'
12
13export type Measure = Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>
14
15export const heat = (percent: number): Heat => (percent >= 75 ? 'hot' : percent >= 50 ? 'warn' : 'calm')
16
17export const takeReading = (m: Measure): Reading => {
18  const limit = (kind: string) => m.rateLimits.find(l => l.kind === kind)
19  const resetsAt = (kind: string) => {
20    const at = Date.parse(limit(kind)?.resetsAt ?? '')
21    return Number.isFinite(at) ? at : undefined
22  }
23  return {
24    fiveHour: limit('five_hour')?.percentUsed,
25    fiveHourResetsAt: resetsAt('five_hour'),
26    sevenDay: limit('seven_day')?.percentUsed,
27    sevenDayResetsAt: resetsAt('seven_day'),
28    ctxPercent: m.context.percent,
29    ctxTokens: m.context.tokens,
30    ctxWindow: m.context.window,
31    usd: m.cost?.usd,
32  }
33}
34
35export const tokens = (n: number): string => {
36  if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`
37  return n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
38}
39
40export const percentText = (percent: number | undefined): string =>
41  percent === undefined ? '-' : `${Math.round(percent)}%`
42
43// Under a day: hours and minutes; over a day: days and hours (minutes are noise for the 7-day window).
44// step: minutes are rounded up to it (5 keeps a long countdown from changing, and redrawing, every minute).
45export const countdown = (ms: number, u: Units = STRINGS.tr.units, step = 1): string => {
46  const part = (n: number, unit: string) => `${n}${u.numSep}${unit}`
47  const m = Math.max(0, Math.ceil(ms / 60000 / step) * step)
48  if (m < 60) return part(m, u.m)
49  if (m >= 24 * 60) {
50    const days = Math.floor(m / (24 * 60))
51    const hours = Math.floor((m % (24 * 60)) / 60)
52    return hours === 0 ? part(days, u.d) : `${part(days, u.d)}${u.partSep}${part(hours, u.h)}`
53  }
54  const h = Math.floor(m / 60)
55  const rest = m % 60
56  return rest === 0 ? part(h, u.h) : `${part(h, u.h)}${u.partSep}${part(rest, u.m)}`
57}
58
59// The time in the system locale's own format: 16:20 in Turkey, 4:20 PM in the US.
60// 24-hour locales keep the leading zero (09:05).
61export const clockTime = (ms: number, locale?: string): string => {
62  const cycle = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle
63  const hour = cycle === 'h23' || cycle === 'h24' ? '2-digit' : 'numeric'
64  return new Intl.DateTimeFormat(locale, { hour, minute: '2-digit' }).format(new Date(ms))
65}
66
67// The weekday comes from Intl in that language (Pzt, Mon, lun., 月 ...).
68export const dayClock = (ms: number, lang: Lang = 'tr'): string =>
69  `${new Intl.DateTimeFormat(lang, { weekday: 'short' }).format(new Date(ms))} ${clockTime(ms)}`
70
71export const windowElapsed = (resetsAt: number, now: number, length = FIVE_HOURS): number =>
72  Math.min(1, Math.max(0, 1 - (resetsAt - now) / length))
73
74// What a narrow band drops: the cost first (under 70 columns), then the ctx token count (under 55).
75// The rings and the percentages are never dropped.
76export const bandLayout = (columns: number): { showJet: boolean; showCost: boolean; showCtxTokens: boolean } => ({
77  showJet: columns >= 85,
78  showCost: columns >= 70,
79  showCtxTokens: columns >= 55,
80})
81
82export const HISTORY_LIMIT = 6
83export const NUDGE_AT = 55
84export const SETTLE_MS = 2000
85
86export type TurnUsage = {
87  input_tokens: number
88  output_tokens: number
89  cache_read_input_tokens: number
90  cache_creation_input_tokens: number
91  model?: string
92}
93
94// turn.complete's usage sums every response of the turn, so the context fill cannot come from it.
95// A turn is recorded with the latest reading's ctx and total cost; settleTurn corrects it with a later measurement.
96export const recordTurn = (
97  history: readonly Turn[],
98  usage: TurnUsage,
99  ctxPercent: number,
100  usd: number | undefined,
101  at: number,
102  fiveHour?: number,
103): Turn[] => {
104  const turn: Turn = {
105    n: (history.at(-1)?.n ?? 0) + 1,
106    at,
107    ctxPercent,
108    ...(usd !== undefined && { usd }),
109    ...(fiveHour !== undefined && { fiveHour }),
110    ...(usage.model !== undefined && { model: usage.model }),
111    in: usage.input_tokens,
112    out: usage.output_tokens,
113    cacheRead: usage.cache_read_input_tokens,
114    cacheWrite: usage.cache_creation_input_tokens,
115  }
116  return [...history, turn].slice(-HISTORY_LIMIT)
117}
118
119// turn.complete and session.measure arrive in no promised order. A measurement within SETTLE_MS of the turn's end
120// belongs to that turn and corrects the fields it carries; a later one is the middle of the next turn.
121export const settleTurn = (
122  history: readonly Turn[],
123  patch: { ctxPercent?: number; usd?: number; fiveHour?: number },
124  now: number,
125): Turn[] => {
126  const last = history.at(-1)
127  if (!last || now - last.at > SETTLE_MS) return [...history]
128  return [
129    ...history.slice(0, -1),
130    {
131      ...last,
132      ...(patch.ctxPercent !== undefined && { ctxPercent: patch.ctxPercent }),
133      ...(patch.usd !== undefined && { usd: patch.usd }),
134      ...(patch.fiveHour !== undefined && { fiveHour: patch.fiveHour }),
135    },
136  ]
137}
138
139// $.state history from v0.4 and earlier carries a single cache field: it counts as reads, writes 0.
140export type LegacyTurn = Omit<Turn, 'cacheRead' | 'cacheWrite'> & { cache?: number; cacheRead?: number; cacheWrite?: number }
141
142export const upgradeTurn = (t: LegacyTurn): Turn => {
143  const { cache, ...rest } = t
144  return { ...rest, cacheRead: t.cacheRead ?? cache ?? 0, cacheWrite: t.cacheWrite ?? 0 }
145}
146
147// Pass the returned nudgedAt to the next call. When the fill drops below nudgedAt (clear, compact) the nudge re-arms.
148export const nudge = (ctxPercent: number, nudgedAt: number): { nudgedAt: number; shouldToast: boolean } => {
149  const armed = ctxPercent < nudgedAt ? 0 : nudgedAt
150  if (ctxPercent >= NUDGE_AT && ctxPercent >= armed + 10) {
151    return { nudgedAt: Math.floor(ctxPercent / 10) * 10, shouldToast: true }
152  }
153  return { nudgedAt: armed, shouldToast: false }
154}
155
156export const nudgeText = (ctxPercent: number, t: Pick<Strings, 'nudge'> = STRINGS.tr): string =>
157  t.nudge(Math.round(ctxPercent))
158
159export const RECENT = 5
160
161export const turnTotal = (t: Turn): number => t.in + t.out + t.cacheRead + t.cacheWrite
162
163// The last RECENT turns, newest first. delta: the turn's ctx minus the previous turn's; cost: the session's total
164// cost at the turn's end minus the previous one's (the engine's own ledger); five: how far the 5-hour window moved
165// over the turn (negative when it reset in between). All null without a previous turn, except the session's first
166// turn's cost, which counts from baseUsd (the session's cost when it started).
167export type RecentTurn = { turn: Turn; delta: number | null; cost: number | null; five: number | null }
168
169// The session's total before the turn is the previous turn's, or for the first turn the cost at session start.
170const turnCost = (turn: Turn, prev: Turn | undefined, baseUsd: number | undefined): number | null => {
171  const before = prev ? prev.usd : turn.n === 1 ? baseUsd : undefined
172  return before !== undefined && turn.usd !== undefined ? turn.usd - before : null
173}
174
175export const recentTurns = (history: readonly Turn[], baseUsd?: number): RecentTurn[] =>
176  history
177    .map((turn, i) => {
178      const prev = i > 0 ? history[i - 1] : undefined
179      return {
180        turn,
181        delta: prev ? turn.ctxPercent - prev.ctxPercent : null,
182        cost: turnCost(turn, prev, baseUsd),
183        five: prev?.fiveHour !== undefined && turn.fiveHour !== undefined ? turn.fiveHour - prev.fiveHour : null,
184      }
185    })
186    .slice(-RECENT)
187    .reverse()
188
189export const ctxDeltaText = (delta: number | null, ctxPercent: number): string => {
190  if (delta === null) return `${Math.round(ctxPercent)}%`
191  const d = Math.round(delta)
192  return d > 0 ? `+${d}%` : d < 0 ? `${d}%` : '±0%'
193}
194
195// The 5-hour window's move over one turn: +0.4%, +1%, ±0%, the reset word when it went down, '' when unknown.
196export const fiveDeltaText = (five: number | null, resetWord: string): string => {
197  if (five === null) return ''
198  if (five < 0) return resetWord
199  const n = Number(five.toFixed(1))
200  return n === 0 ? '±0%' : `+${n}%`
201}
202
203// The section's summary line: tokens, cost and the 5-hour window's growth over the shown turns (resets skipped).
204export const turnsSummary = (rows: readonly RecentTurn[]): { tokens: number; cost: number | null; five: number | null } => {
205  const costs = rows.map(r => r.cost).filter((c): c is number => c !== null)
206  const fives = rows.map(r => r.five).filter((f): f is number => f !== null && f >= 0)
207  return {
208    tokens: rows.reduce((a, r) => a + turnTotal(r.turn), 0),
209    cost: costs.length ? costs.reduce((a, c) => a + c, 0) : null,
210    five: fives.length ? fives.reduce((a, f) => a + f, 0) : null,
211  }
212}
213
214export const costText = (cost: number | null): string => {
215  if (cost === null) return ''
216  return cost < 0.01 ? '<$0.01' : `$${cost.toFixed(2)}`
217}
218
219// The cache ring drains every minute; drawn in 5% steps its Svg changes (and reloads) every few minutes instead.
220export const ringStep = (percent: number): number => Math.round(percent / 5) * 5
221
222export const TTL_1H = 60 * 60 * 1000
223export const TTL_5M = 5 * 60 * 1000
224export const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000
225export const LOG_KEEP_MS = 8 * 24 * 60 * 60 * 1000
226export const LOG_LIMIT = 2000
227
228export type CacheState = { percent: number; text: string; isWarm: boolean }
229
230// The countdown starts at the turn's end, a little after its last request began, so it is an upper bound.
231export const COUNTDOWN_STEP = 5
232
233export const cacheState = (
234  lastRequestAt: number | null,
235  ttlMs: number,
236  now: number,
237  t: Pick<Strings, 'units' | 'cold'> = STRINGS.tr,
238): CacheState | null => {
239  if (lastRequestAt === null) return null
240  const left = lastRequestAt + ttlMs - now
241  if (left <= 0) return { percent: 0, text: t.cold, isWarm: false }
242  // A long lifetime counts down in 5-minute steps, matching the ring's 5% steps; the 5-minute one by the minute.
243  const step = ttlMs >= 30 * 60 * 1000 ? COUNTDOWN_STEP : 1
244  const text = left < 60_000 ? `<1${t.units.numSep}${t.units.m}` : countdown(left, t.units, step)
245  // The ring drains with the shown countdown, so both change on the same minute.
246  const stepMs = step * 60_000
247  return { percent: Math.min(100, (Math.ceil(left / stepMs) * stepMs * 100) / ttlMs), text, isWarm: true }
248}
249
250export const parseTtl = (arg: string): number | undefined => (arg === '5m' ? TTL_5M : arg === '1h' ? TTL_1H : undefined)
251
252export const ttlText = (ms: number, t: Pick<Strings, 'ttl5m' | 'ttl1h'> = STRINGS.tr): string =>
253  ms === TTL_5M ? t.ttl5m : t.ttl1h
254
255// An unknown project or model is stored as the language-neutral ?; shown as t.unknown. v0.7 logs wrote bilinmiyor.
256export const UNKNOWN = '?'
257const LEGACY_UNKNOWN = 'bilinmiyor'
258export const isUnknown = (name: string): boolean => name === UNKNOWN || name === LEGACY_UNKNOWN
259
260export const projectName = (cwd: string): string => cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? UNKNOWN
261
262export const modelName = (id: string | undefined): string =>
263  id === undefined ? UNKNOWN : id.replace(/^claude-/, '').replace(/\[1m\]$/, '')
264
265// Adds this session's last turn to the log, or updates it under the same id after a settle. When the turn has no
266// model (an interrupted turn, the turn of a hot reload) the session's model is used; earlier unknown rows are fixed too.
267export const syncLog = (
268  log: readonly LogEntry[],
269  turn: Turn,
270  prev: Turn | undefined,
271  project: string,
272  sessionModel?: string,
273  baseUsd?: number,
274): LogEntry[] => {
275  const fallback = sessionModel === undefined ? UNKNOWN : modelName(sessionModel)
276  const usd = turnCost(turn, prev, baseUsd)
277  const entry: LogEntry = {
278    id: String(turn.at),
279    at: turn.at,
280    project,
281    model: turn.model === undefined ? fallback : modelName(turn.model),
282    tokens: turnTotal(turn),
283    ...(usd !== null && { usd }),
284  }
285  const repaired = log.map(e => (isUnknown(e.model) ? { ...e, model: fallback } : e))
286  // The same turn may sit under its older id (the turn number before v1.2.5): its time matches.
287  return [...repaired.filter(e => e.id !== entry.id && e.at !== entry.at), entry].slice(-LOG_LIMIT)
288}
289
290export type WindowView = '5h' | '7d'
291
292export const windowStart = (view: WindowView, r: Reading | null, now: number): number =>
293  view === '5h' ? (r?.fiveHourResetsAt ?? now) - FIVE_HOURS : (r?.sevenDayResetsAt ?? now) - SEVEN_DAYS
294
295export type Group = { name: string; tokens: number; turns: number; usd: number }
296
297export const breakdown = (entries: readonly LogEntry[], since: number, by: 'project' | 'model', limit = 5): Group[] => {
298  const groups = new Map<string, Group>()
299  for (const e of entries) {
300    if (e.at < since) continue
301    const name = isUnknown(e[by]) ? UNKNOWN : e[by]
302    const g = groups.get(name) ?? { name, tokens: 0, turns: 0, usd: 0 }
303    groups.set(name, { name, tokens: g.tokens + e.tokens, turns: g.turns + 1, usd: g.usd + (e.usd ?? 0) })
304  }
305  return [...groups.values()].sort((a, b) => b.usd - a.usd || b.tokens - a.tokens).slice(0, limit)
306}
307
308export const windowTotals = (entries: readonly LogEntry[], since: number): { turns: number; tokens: number; usd: number } =>
309  entries
310    .filter(e => e.at >= since)
311    .reduce((t, e) => ({ turns: t.turns + 1, tokens: t.tokens + e.tokens, usd: t.usd + (e.usd ?? 0) }), { turns: 0, tokens: 0, usd: 0 })
312
313// Another session's log is deleted when it is empty, malformed, or its last turn is older than LOG_KEEP_MS.
314export const isStaleLog = (entries: unknown, now: number): boolean => {
315  if (!Array.isArray(entries) || entries.length === 0) return true
316  const last: unknown = entries.at(-1)
317  const at = typeof last === 'object' && last !== null ? Reflect.get(last, 'at') : undefined
318  return typeof at !== 'number' || at < now - LOG_KEEP_MS
319}
320
321// The 5-hour window's percent at a moment, kept for the run-out forecast's recent pace.
322export type Sample = { at: number; pct: number }
323export const RECENT_MS = 30 * 60 * 1000
324const MIN_SPAN_MS = 5 * 60 * 1000
325
326// Adds a reading when the percent moved; a drop (a new window) starts over, and samples past twice RECENT_MS go.
327export const addSample = (samples: readonly Sample[], pct: number, at: number): Sample[] => {
328  const last = samples.at(-1)
329  if (last && last.pct === pct) return [...samples]
330  const kept = last && pct < last.pct ? [] : samples.filter(s => at - s.at <= 2 * RECENT_MS)
331  return [...kept, { at, pct }]
332}
333
334export type Forecast = { atReset: number; fullAt?: number }
335
336// Where the window ends up at this pace. The pace is the last RECENT_MS of samples when they span at least 5 minutes
337// and moved at least 1 point, otherwise the window's average so far (not before 5% of it has passed). Null when there
338// is not enough to go on, or the window is already full.
339export const forecast = (
340  pct: number | undefined,
341  resetsAt: number | undefined,
342  length: number,
343  now: number,
344  samples: readonly Sample[] = [],
345): Forecast | null => {
346  if (pct === undefined || resetsAt === undefined || resetsAt <= now || pct >= 100) return null
347  const start = resetsAt - length
348  const first = samples.find(s => s.at >= start && now - s.at <= RECENT_MS)
349  const elapsed = now - start
350  const rate =
351    first && now - first.at >= MIN_SPAN_MS && pct - first.pct >= 1
352      ? (pct - first.pct) / (now - first.at)
353      : elapsed >= length * 0.05 && pct >= 1
354        ? pct / elapsed
355        : null
356  if (rate === null) return null
357  const atReset = pct + rate * (resetsAt - now)
358  return atReset > 100 ? { atReset, fullAt: now + (100 - pct) / rate } : { atReset }
359}
360
hooks/jet.ts 247 lines
1// A fighter jet that shows the model's effort. Side view of a small 3D model: the wings,
2// tail and canopy are rolled about the jet's long axis and projected each frame, so banks and rolls show the wings
3// tilting. Pure SMIL, precomputed here: the desktop Svg element runs it without scripts.
4
5import { SCHEME } from './svg'
6
7export type JetLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
8
9type Pace = { period: number; wind: number; flame: number; color: string; lines: number; shock: boolean }
10
11// Lower effort answers faster: low flies fastest (short cycle, fast wind, full afterburner with shock diamonds), max
12// cruises slowest (long cycle, slow wind, a small blue flame) since it thinks longest before answering.
13export const PACE: Record<JetLevel, Pace> = {
14  low: { period: 3.6, wind: 0.35, flame: 12, color: '#7F77DD', lines: 8, shock: true },
15  medium: { period: 4.8, wind: 0.5, flame: 10, color: '#E24B4A', lines: 6, shock: false },
16  high: { period: 6, wind: 0.7, flame: 8, color: '#EF9F27', lines: 5, shock: false },
17  xhigh: { period: 7.2, wind: 0.95, flame: 6, color: '#F2C14E', lines: 4, shock: false },
18  max: { period: 8.4, wind: 1.3, flame: 4, color: '#85B7EB', lines: 3, shock: false },
19}
20
21const LEVELS: readonly JetLevel[] = ['low', 'medium', 'high', 'xhigh', 'max']
22
23// The effort turn.step reports: a level, or an integer thinking budget, flown as max (the slowest). A model without
24// effort reports none: no jet.
25export const jetLevel = (effort: unknown): JetLevel | null => {
26  if (typeof effort === 'number') return 'max'
27  return LEVELS.find(l => l === effort) ?? null
28}
29
30type P3 = readonly [number, number, number]
31type Frame = { phi: number; dx: number; dy: number; pitch: number }
32
33const FRAMES = 96
34const BANK = 24
35const ELEV = (14 * Math.PI) / 180 // the camera looks down a little, so level flight shows the near wing's top
36const WIND = '#888780'
37const GREY = '#9AA5B4'
38const CAM = { y: -Math.sin(ELEV), z: Math.cos(ELEV) }
39const LIGHT = (() => {
40  const m = Math.hypot(0.75, 0.66)
41  return { y: -0.75 / m, z: 0.66 / m }
42})()
43
44// Body frame: x forward, y down, z toward the viewer.
45const FUSELAGE =
46  'M15,0.4 C12,-0.6 9,-1.6 6,-2.0 L-2,-2.3 C-6,-2.4 -9,-2.2 -12,-1.9 L-13.2,-1.6 L-13.2,1.6 L-10,2.2 C-4,2.6 2,2.6 6,2.2 C9,1.9 12,1.2 15,0.4 Z'
47const WING: P3[] = [[7.5, 0.6, 1.4], [2, 0.6, 2.6], [-5.5, 0.6, 8.6], [-8.6, 0.6, 8.6], [-9, 0.6, 2.0]]
48const STAB: P3[] = [[-10, 0.4, 1.6], [-12.4, 0.4, 5.6], [-14.2, 0.4, 5.6], [-13.6, 0.4, 1.5]]
49const FIN: P3[] = [[-6, -2.2, 0], [-10.8, -8.3, 0], [-12.8, -8.3, 0], [-12.6, -1.9, 0]]
50const FLASH: P3[] = [[-9.78, -7, 0], [-10.8, -8.3, 0], [-12.8, -8.3, 0], [-12.76, -7, 0]]
51const MISSILE: P3[] = [[-3.6, 0.6, 9.0], [-9.6, 0.6, 9.0]]
52const UP: P3 = [0, -1, 0]
53const SIDE: P3 = [0, 0, 1]
54
55const mirror = (ps: P3[]): P3[] => ps.map(([x, y, z]) => [x, y, -z] as const)
56const n2 = (n: number): string => String(Number(n.toFixed(2)))
57
58const roll = (y: number, z: number, phi: number) => {
59  const c = Math.cos(phi)
60  const s = Math.sin(phi)
61  return { y: y * c - z * s, z: y * s + z * c }
62}
63
64const project = ([x, y, z]: P3, phi: number) => {
65  const r = roll(y, z, phi)
66  return { x, y: r.y * Math.cos(ELEV) + r.z * Math.sin(ELEV), depth: r.z * Math.cos(ELEV) - r.y * Math.sin(ELEV) }
67}
68
69const shade = (hex: string, b: number): string => {
70  const n = parseInt(hex.slice(1), 16)
71  return `#${[(n >> 16) & 255, (n >> 8) & 255, n & 255]
72    .map(v => Math.min(255, Math.round(v * b)).toString(16).padStart(2, '0'))
73    .join('')}`
74}
75
76const ease = (t: number): number => (1 - Math.cos(Math.PI * t)) / 2
77
78// One cycle: bank right, level, bank left, level, then a full roll while climbing a little (a corkscrew).
79const KEYS: ReadonlyArray<readonly [number, number]> = [
80  [0, 0], [0.12, BANK], [0.24, BANK], [0.36, 0], [0.48, -BANK], [0.6, -BANK], [0.7, 0], [0.72, 0], [0.9, 360], [1, 360],
81]
82
83const rollAt = (u: number): number => {
84  for (let i = 0; i < KEYS.length - 1; i++) {
85    const [a, from] = KEYS[i]
86    const [b, to] = KEYS[i + 1]
87    if (u <= b) return from + (to - from) * ease((u - a) / (b - a))
88  }
89  return 0
90}
91
92const frames = (): Frame[] =>
93  Array.from({ length: FRAMES + 1 }, (_, i) => {
94    const u = i / FRAMES
95    let dy = -2.5 * Math.sin(4 * Math.PI * u)
96    let dx = 0
97    let pitch = -3 * Math.cos(4 * Math.PI * u)
98    if (u > 0.72 && u < 0.9) {
99      const g = (u - 0.72) / 0.18
100      dy += -6 * Math.sin(Math.PI * g)
101      dx += 3 * Math.sin(2 * Math.PI * g)
102      pitch += -9 * Math.sin(2 * Math.PI * g)
103    }
104    return { phi: (rollAt(u) * Math.PI) / 180, dx, dy, pitch }
105  })
106
107const animate = (attr: string, values: string[], period: number, extra = ''): string =>
108  `<animate attributeName="${attr}" values="${values.join(';')}" dur="${period}s" repeatCount="indefinite"${extra}/>`
109
110const DISCRETE = ' calcMode="discrete"'
111
112// A flat part drawn twice: behind the fuselage always, and in front of it (a <use>) while it faces the viewer.
113const part = (id: string, pts: P3[], normal: P3 | null, base: string, fs: Frame[], period: number, stroke?: number) => {
114  const shots = fs.map(f => {
115    const q = pts.map(p => project(p, f.phi))
116    const d = stroke
117      ? `M${n2(q[0].x)},${n2(q[0].y)} L${n2(q[1].x)},${n2(q[1].y)}`
118      : `M${q.map(p => `${n2(p.x)},${n2(p.y)}`).join(' L')} Z`
119    const depth = q.reduce((a, p) => a + p.depth, 0) / q.length
120    let color = base
121    if (normal) {
122      let n = roll(normal[1], normal[2], f.phi)
123      if (n.y * CAM.y + n.z * CAM.z < 0) n = { y: -n.y, z: -n.z }
124      color = shade(base, 0.55 + 0.6 * Math.max(0, n.y * LIGHT.y + n.z * LIGHT.z))
125    }
126    return { d, depth, color }
127  })
128  const paint = stroke ? 'stroke' : 'fill'
129  const extra = stroke ? ` fill="none" stroke-width="${stroke}" stroke-linecap="round"` : ''
130  return {
131    back:
132      `<path id="${id}" d="${shots[0].d}" ${paint}="${shots[0].color}"${extra}>` +
133      animate('d', shots.map(s => s.d), period) +
134      (normal ? animate(paint, shots.map(s => s.color), period) : '') +
135      '</path>',
136    front: `<use href="#${id}" opacity="0">${animate('opacity', shots.map(s => (s.depth > 0 ? '1' : '0')), period, DISCRETE)}</use>`,
137  }
138}
139
140const flame = (pace: Pace): string => {
141  const x = -15.4
142  const shape = (len: number, h: number) =>
143    `M${x},${n2(-h + 0.05)} Q${n2(x - len * 0.45)},${n2(-h)} ${n2(x - len)},0.05 Q${n2(x - len * 0.45)},${n2(h + 0.1)} ${x},${n2(h + 0.05)} Z`
144  const layer = (len: number, h: number, fill: string, opacity: number) => {
145    const a = shape(len, h)
146    const b = shape(len * 0.78, h)
147    return `<path d="${a}" fill="${fill}" opacity="${opacity}"><animate attributeName="d" values="${a};${b};${a}" dur="0.16s" repeatCount="indefinite"/></path>`
148  }
149  let s = layer(pace.flame * 1.35, 1.25, pace.color, 0.3) + layer(pace.flame, 0.95, pace.color, 0.9) + layer(pace.flame * 0.45, 0.55, '#FFF4D6', 0.95)
150  // Shock diamonds in the plume at max.
151  if (pace.shock) {
152    for (const at of [0.35, 0.62]) {
153      const cx = x - pace.flame * at
154      s += `<path d="M${n2(cx + 0.5)},0.05 L${n2(cx)},-0.4 L${n2(cx - 0.5)},0.05 L${n2(cx)},0.5 Z" fill="#FFFFFF"><animate attributeName="opacity" values="0.9;0.35;0.9" dur="0.2s" repeatCount="indefinite"/></path>`
155    }
156  }
157  return s
158}
159
160const jet = (pace: Pace, k: string): { defs: string; body: string } => {
161  const T = pace.period
162  const fs = frames()
163  const wingFar = part(`${k}wf`, mirror(WING), UP, GREY, fs, T)
164  const stabFar = part(`${k}sf`, mirror(STAB), UP, GREY, fs, T)
165  const missileFar = part(`${k}mf`, mirror(MISSILE), null, '#E6EAF0', fs, T, 0.85)
166  const wingNear = part(`${k}wn`, WING, UP, GREY, fs, T)
167  const stabNear = part(`${k}sn`, STAB, UP, GREY, fs, T)
168  const missileNear = part(`${k}mn`, MISSILE, null, '#E6EAF0', fs, T, 0.85)
169  const fin = part(`${k}fn`, FIN, SIDE, '#97A2B1', fs, T)
170  const flash = part(`${k}fl`, FLASH, SIDE, pace.color, fs, T)
171  // The canopy is a dome on the spine: moved to its rolled place and squashed (flipped under) by cos(roll).
172  const base = fs.map(f => project([3.2, -2.0, 0], f.phi))
173  const top = fs.map(f => project([3.2, -2.3, 0], f.phi))
174  const dome =
175    `<g id="${k}cd"><animateTransform attributeName="transform" type="translate" values="${base.map(q => `${n2(q.x)} ${n2(q.y)}`).join(';')}" dur="${T}s" repeatCount="indefinite"/>` +
176    `<g><animateTransform attributeName="transform" type="scale" values="${fs.map(f => `1 ${n2(Math.cos(f.phi))}`).join(';')}" dur="${T}s" repeatCount="indefinite"/>` +
177    `<path d="M4.4,0 C3.6,-1.3 2.2,-2.1 0.4,-2.1 C-1.8,-2.1 -3.4,-1.1 -4.6,0 Z" fill="url(#${k}gg)"/>` +
178    '<path d="M2.6,-1.55 C1.6,-1.85 0,-1.9 -1.4,-1.6" fill="none" stroke="#FFFFFF" stroke-width="0.35" stroke-linecap="round" opacity="0.75"/>' +
179    '<path d="M1.6,-1.95 L2.1,-0.2" stroke="#1B2B3D" stroke-width="0.16" opacity="0.7"/></g></g>'
180  const domeFront = `<use href="#${k}cd" opacity="0">${animate('opacity', base.map(q => (q.depth > -0.2 ? '1' : '0')), T, DISCRETE)}</use>`
181  // Seen from above (rolled toward the viewer) the canopy reads as an oval on the fuselage.
182  const oval =
183    `<ellipse cx="3.2" cy="${n2(top[0].y)}" rx="4.1" ry="1.25" fill="url(#${k}gg)" opacity="0">` +
184    animate('cy', top.map(q => n2(q.y)), T) +
185    animate('opacity', fs.map(f => n2(Math.max(0, -Math.sin(f.phi)) ** 1.5)), T) +
186    '</ellipse>'
187  const fuselage =
188    `<path d="${FUSELAGE}" fill="url(#${k}fg)"/>` +
189    `<g clip-path="url(#${k}cp)"><rect x="11.4" y="-4" width="6" height="8" fill="#5B6573"/>` +
190    '<path d="M-4,-1.9 C0,-1.3 6,-1.1 11,-1.0" fill="none" stroke="#FFFFFF" stroke-width="0.4" opacity="0.35"/></g>' +
191    `<path d="${FUSELAGE}" fill="none" stroke="#3E4652" stroke-width="0.3" stroke-opacity="0.6"/>` +
192    '<line x1="15" y1="0.4" x2="17.2" y2="0.4" stroke="#5B6573" stroke-width="0.3"/>' +
193    '<path d="M-13.2,-1.6 L-15.4,-1.25 L-15.4,1.35 L-13.2,1.6 Z" fill="#454C57"/>' +
194    '<line x1="-14.1" y1="-1.42" x2="-14.1" y2="1.48" stroke="#6B7480" stroke-width="0.25"/>' +
195    '<line x1="-13.2" y1="-1.6" x2="-13.2" y2="1.6" stroke="#2F343C" stroke-width="0.35"/>'
196  const defs =
197    `<linearGradient id="${k}fg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#DCE2EA"/><stop offset="0.3" stop-color="#B3BDCA"/><stop offset="0.7" stop-color="#848FA0"/><stop offset="1" stop-color="#5E6878"/></linearGradient>` +
198    `<linearGradient id="${k}gg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0E2A47"/><stop offset="0.55" stop-color="#2C6E9E"/><stop offset="1" stop-color="#6FB3DB"/></linearGradient>` +
199    `<clipPath id="${k}cp"><path d="${FUSELAGE}"/></clipPath>`
200  const back = [wingFar, missileFar, stabFar, fin, flash, wingNear, missileNear, stabNear].map(p => p.back).join('')
201  const front = [fin, flash, stabFar, wingFar, missileFar, stabNear, wingNear, missileNear].map(p => p.front).join('')
202  const body =
203    `<g><animateTransform attributeName="transform" type="translate" values="${fs.map(f => `${n2(f.dx)},${n2(f.dy)}`).join(';')}" dur="${T}s" repeatCount="indefinite"/>` +
204    `<g><animateTransform attributeName="transform" type="rotate" values="${fs.map(f => n2(f.pitch)).join(';')}" dur="${T}s" repeatCount="indefinite"/>` +
205    `${flame(pace)}${back}${dome}${fuselage}${front}${domeFront}${oval}</g></g>`
206  return { defs, body }
207}
208
209// Speed lines streaming past: faster wind, more lines at higher effort. The wind moves at the same speed relative to
210// the jet's size on every strip (pace.wind is the time to cross WIND_REF pixels at scale 1.25), so neither the band
211// nor the larger pane looks slower than the other.
212const WIND_REF = 340
213const WIND_SCALE = 1.25
214const wind = (pace: Pace, width: number, height: number, scale: number): string => {
215  const dur = Number(Math.max(0.15, (pace.wind * (width + 40) * WIND_SCALE) / (WIND_REF * scale)).toFixed(2))
216  let s = ''
217  for (let i = 0; i < pace.lines; i++) {
218    const y = 6 + ((i * 37) % Math.max(1, height - 10))
219    const len = 10 + ((i * 7) % 14)
220    const delay = -((i * dur) / pace.lines)
221    s +=
222      `<line x1="0" y1="${y}" x2="${len}" y2="${y}" stroke="${WIND}" stroke-width="1" stroke-linecap="round" opacity="0.45">` +
223      `<animateTransform attributeName="transform" type="translate" values="${width + 10},0;-30,0" dur="${dur}s" begin="${delay.toFixed(2)}s" repeatCount="indefinite"/></line>`
224  }
225  return s
226}
227
228const cache = new Map<string, string>()
229
230const escape = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
231
232// The whole strip: wind and the jet centered. Memoized: the frames are the same on every draw.
233export const jetStrip = (level: JetLevel, width: number, height: number, scale: number, title: string, id = 'jet'): string => {
234  const key = `${level}|${width}|${height}|${scale}|${title}|${id}`
235  const hit = cache.get(key)
236  if (hit !== undefined) return hit
237  const pace = PACE[level]
238  const { defs, body } = jet(pace, id)
239  const svg =
240    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${SCHEME}` +
241    `<title>${escape(title)}</title><defs>${defs}</defs>${wind(pace, width, height, scale)}` +
242    `<g transform="translate(${n2(width / 2 + 6)},${n2(height / 2 + 2)}) scale(${scale})">${body}</g></svg>`
243  if (cache.size > 24) cache.clear()
244  cache.set(key, svg)
245  return svg
246}
247
hooks/svg.ts 184 lines
1import type { Turn } from '../types'
2import type { Heat } from './model'
3import { heat } from './model'
4
5// Pure SVG builders. The desktop draws an Svg as an isolated image without the theme's variables,
6// so the colors are mid tones that read on light and dark themes. Text goes in Text elements, not the SVG.
7
8export const COLORS: Record<Heat, string> = { calm: '#00C853', warn: '#FF9800', hot: '#FF3D00' }
9export const TRACK = '#888780'
10export const CHART = '#378ADD'
11
12const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n))
13const f = (n: number): string => n.toFixed(2)
14// An interactive Svg is a framed document: without a color scheme it takes the light one and, on a dark theme, the
15// frame is painted white behind it. Following the page's scheme keeps the frame transparent on both.
16export const SCHEME = '<style>:root,svg{color-scheme:light dark}</style>'
17
18const open = (width: number, height: number): string =>
19  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${SCHEME}`
20
21// A level change moves the liquid, the arc and its head together: past the target by OVERSHOOT, then back, like a slosh.
22const MOVE = 'keyTimes="0;0.72;1" calcMode="spline" keySplines="0.25 0.8 0.35 1;0.45 0 0.55 1" dur="1.5s" fill="freeze"'
23const OVERSHOOT = 0.07
24
25// The fuller the ring, the faster and higher its waves and the more bubbles rise (fewer on a band-sized ring).
26type Motion = { front: number; back: number; amp: number; bubbles: number; climb: number }
27const MOTION: Record<Heat, Motion> = {
28  calm: { front: 2.6, back: 4, amp: 0.045, bubbles: 2, climb: 3.4 },
29  warn: { front: 1.9, back: 3, amp: 0.06, bubbles: 3, climb: 2.6 },
30  hot: { front: 1.25, back: 2.1, amp: 0.08, bubbles: 5, climb: 1.8 },
31}
32
33const level = (p: number, cy: number, inner: number): number => cy + inner - (2 * inner * p) / 100
34
35// The color moved toward white (255) or black (0) by t.
36const shade = (hex: string, t: number, to: 0 | 255): string => {
37  const n = parseInt(hex.slice(1), 16)
38  return `#${[n >> 16, (n >> 8) & 255, n & 255].map(v => Math.round(v + (to - v) * t).toString(16).padStart(2, '0')).join('')}`
39}
40
41// Seven half waves from the left edge: shifted by ±w, the inner circle stays covered at every moment.
42const crest = (x0: number, y: number, w: number, amp: number): string => {
43  const half = f(w / 2)
44  const rest = Array.from({ length: 6 }, () => ` t${half} 0`).join('')
45  return `M${f(x0)} ${f(y)} q${f(w / 4)} ${f(-amp)} ${half} 0${rest}`
46}
47const wavePath = (x0: number, y: number, w: number, amp: number, bottom: number): string =>
48  `${crest(x0, y, w, amp)} V${f(bottom)} H${f(x0)} Z`
49
50const slide = (from: number, to: number, dur: number): string =>
51  `<animateTransform attributeName="transform" type="translate" from="${f(from)} 0" to="${f(to)} 0" dur="${dur}s" repeatCount="indefinite"/>`
52
53// Bubbles at fixed spots (the same source on every redraw), each rising from the bottom to just under the surface.
54const bubbles = (count: number, h: number, inner: number, y: number, size: number, climb: number): string =>
55  Array.from({ length: count }, (_, i) => {
56    const x = h - inner * 0.55 + inner * 1.1 * ((i * 0.618 + 0.2) % 1)
57    const r = size * (0.016 + 0.008 * (i % 3))
58    const dur = climb + (i % 3) * 0.45
59    const begin = `begin="${f((-i * dur) / count)}s" repeatCount="indefinite"`
60    const y0 = h + inner - r - 1
61    return (
62      `<circle cx="${f(x)}" cy="${f(y0)}" r="${f(r)}" fill="#fff" opacity="0">` +
63      `<animate attributeName="cy" values="${f(y0)};${f(y)}" dur="${f(dur)}s" ${begin}/>` +
64      `<animate attributeName="opacity" values="0;0.7;0.7;0" keyTimes="0;0.15;0.8;1" dur="${f(dur)}s" ${begin}/>` +
65      `<animate attributeName="cx" values="${f(x)};${f(x + r * 1.4)};${f(x - r * 1.4)};${f(x)}" dur="${f(dur * 0.6)}s" repeatCount="indefinite"/></circle>`
66    )
67  }).join('')
68
69const liquid = (p: number, from: number | undefined, h: number, inner: number, size: number, color: string, m: Motion): string => {
70  const w = 2 * inner
71  const y = level(p, h, inner)
72  const amp = Math.max(0.8, size * m.amp)
73  const bottom = h + inner + 1
74  const dy = from === undefined ? 0 : level(from, h, inner) - y
75  const rise =
76    Math.abs(dy) > 0.01
77      ? `<animateTransform attributeName="transform" type="translate" values="0 ${f(dy)};0 ${f(-dy * OVERSHOOT)};0 0" ${MOVE}/>`
78      : ''
79  // Lighter at the surface, deeper at the bottom.
80  const depth = `<linearGradient id="dep" gradientUnits="userSpaceOnUse" x1="0" y1="${f(y - amp)}" x2="0" y2="${f(bottom)}"><stop offset="0" stop-color="${shade(color, 0.4, 255)}"/><stop offset="0.45" stop-color="${color}"/><stop offset="1" stop-color="${shade(color, 0.35, 0)}"/></linearGradient>`
81  const back = `<path d="${wavePath(h - inner - w / 2, y, w, amp * 0.8, bottom)}" fill="${color}" fill-opacity="0.3">${slide(-w, 0, m.back)}</path>`
82  const front = `<path d="${wavePath(h - inner, y, w, amp, bottom)}" fill="url(#dep)" fill-opacity="0.92">${slide(0, -w, m.front)}</path>`
83  const surface = `<path d="${crest(h - inner, y, w, amp)}" fill="none" stroke="#fff" stroke-opacity="0.55" stroke-width="${f(Math.max(0.6, size * 0.014))}">${slide(0, -w, m.front)}</path>`
84  const rising = p > 8 ? bubbles(size < 50 ? Math.ceil(m.bubbles * 0.6) : m.bubbles, h, inner, y + amp * 0.5, size, m.climb) : ''
85  return `<defs><clipPath id="liq"><circle cx="${h}" cy="${h}" r="${f(inner)}"/></clipPath>${depth}</defs><g clip-path="url(#liq)"><g>${rise}${back}${front}${surface}${rising}</g></g>`
86}
87
88// A glass shine on the inner circle's upper left.
89const glass = (h: number, inner: number, size: number): string => {
90  const at = (deg: number): [string, string] => {
91    const a = (deg * Math.PI) / 180
92    return [f(h + inner * 0.74 * Math.cos(a)), f(h + inner * 0.74 * Math.sin(a))]
93  }
94  const [x1, y1] = at(200)
95  const [x2, y2] = at(248)
96  const [dx, dy] = at(265)
97  return (
98    `<path d="M${x1} ${y1} A${f(inner * 0.74)} ${f(inner * 0.74)} 0 0 1 ${x2} ${y2}" fill="none" stroke="#fff" stroke-opacity="0.3" stroke-width="${f(size * 0.04)}" stroke-linecap="round"/>` +
99    `<circle cx="${dx}" cy="${dy}" r="${f(size * 0.022)}" fill="#fff" fill-opacity="0.3"/>`
100  )
101}
102
103// Ring plus liquid: the outer arc shows the percentage, the inner circle fills with waving liquid to the same level.
104// Given a different from, the level, the arc and its head slide from there once; draw it as an isInteractive Svg.
105// Given an override, it replaces the heat color and the motion stays calm (for rings whose meaning is inverted, like
106// the cache TTL ring).
107export const liquidRing = (
108  percent: number | undefined,
109  size: number,
110  stroke: number,
111  from?: number,
112  override?: string,
113): string => {
114  const h = size / 2
115  const r = (size - stroke) / 2
116  const c = 2 * Math.PI * r
117  const inner = r - stroke / 2 - (size < 30 ? 1 : 2)
118  const p = percent === undefined ? 0 : clamp(percent, 0, 100)
119  const start = from === undefined ? undefined : clamp(from, 0, 100)
120  const mood: Heat = override === undefined ? heat(p) : 'calm'
121  const color = override ?? COLORS[heat(p)]
122  const base = `<circle cx="${h}" cy="${h}" r="${f(inner)}" fill="${TRACK}" fill-opacity="0.12"/>`
123  const track = `<circle cx="${h}" cy="${h}" r="${f(r)}" fill="none" stroke="${TRACK}" stroke-opacity="0.3" stroke-width="${stroke}"/>`
124  if (p === 0) return `${open(size, size)}${base}${glass(h, inner, size)}${track}</svg>`
125  const moved = start !== undefined && Math.abs(start - p) > 0.01
126  const past = start !== undefined && moved ? clamp(p + (p - start) * OVERSHOOT, 0, 100) : p
127  const len = (q: number): string => `${f((c * q) / 100)} ${f(c)}`
128  const turn = (q: number): string => `${f(3.6 * q)} ${h} ${h}`
129  const sweep = start !== undefined && moved ? `<animate attributeName="stroke-dasharray" values="${len(start)};${len(past)};${len(p)}" ${MOVE}/>` : ''
130  const arc = `<circle cx="${h}" cy="${h}" r="${f(r)}" fill="none" stroke="${color}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${len(p)}" transform="rotate(-90 ${h} ${h})">${sweep}</circle>`
131  // The arc's head: a white dot riding its end, with a pulse once the ring runs hot.
132  const follow = start !== undefined && moved ? `<animateTransform attributeName="transform" type="rotate" values="${turn(start)};${turn(past)};${turn(p)}" ${MOVE}/>` : ''
133  const pulse =
134    mood === 'hot'
135      ? `<circle cx="${f(h + r)}" cy="${h}" r="${f(stroke * 0.5)}" fill="${color}"><animate attributeName="r" values="${f(stroke * 0.5)};${f(stroke * 0.9)}" dur="1.3s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.7;0" dur="1.3s" repeatCount="indefinite"/></circle>`
136      : ''
137  const head = `<g transform="rotate(-90 ${h} ${h})"><g transform="rotate(${turn(p)})">${follow}${pulse}<circle cx="${f(h + r)}" cy="${h}" r="${f(stroke * 0.28)}" fill="#fff" fill-opacity="0.9"/></g></g>`
138  const fill = liquid(p, start, h, inner, size, color, MOTION[mood])
139  return `${open(size, size)}${base}${fill}${glass(h, inner, size)}${track}${arc}${head}</svg>`
140}
141
142// Cache TTL ring: calm while plenty is left, the warning color in its last fifth.
143export const ttlColor = (percent: number): string => (percent > 20 ? COLORS.calm : COLORS.warn)
144
145// A rounded progress bar; the pane's reset rows also draw a thinner one for the limit used, in its heat color.
146export const resetBar = (fraction: number, width: number, color = CHART, height = 8): string => {
147  const filled = clamp(fraction, 0, 1) * width
148  const track = `<rect width="${width}" height="${height}" rx="${height / 2}" fill="${TRACK}" fill-opacity="0.3"/>`
149  const bar = filled > 0 ? `<rect width="${f(Math.max(height, filled))}" height="${height}" rx="${height / 2}" fill="${color}"/>` : ''
150  return `${open(width, height)}${track}${bar}</svg>`
151}
152
153export const SEGMENTS = { in: '#378ADD', out: '#D85A30', cacheWrite: '#7F77DD', cacheRead: TRACK } as const
154
155// A turn's stacked in/out/cache write/cache read bar. scale: the largest total among the turns shown.
156// Cache reads are the largest but cheapest part, so they are muted on purpose.
157export const turnBar = (
158  t: Pick<Turn, 'in' | 'out' | 'cacheWrite' | 'cacheRead'>,
159  scale: number,
160  width: number,
161  titles?: readonly [string, string, string, string],
162): string => {
163  const height = 10
164  const unit = scale > 0 ? width / scale : 0
165  const parts: [number, string, string][] = [
166    [t.in, SEGMENTS.in, ''],
167    [t.out, SEGMENTS.out, ''],
168    [t.cacheWrite, SEGMENTS.cacheWrite, ''],
169    [t.cacheRead, SEGMENTS.cacheRead, ' fill-opacity="0.45"'],
170  ]
171  let x = 0
172  // With titles, each segment names itself on hover (the Svg must be interactive).
173  const rects = parts
174    .map(([n, color, opacity], i) => {
175      const w = n * unit
176      const title = titles ? `<title>${titles[i].replace(/&/g, '&amp;').replace(/</g, '&lt;')}</title>` : ''
177      const rect = w > 0 ? `<rect x="${f(x)}" width="${f(w)}" height="${height}" fill="${color}"${opacity}>${title}</rect>` : ''
178      x += w
179      return rect
180    })
181    .join('')
182  return `${open(width, height)}<defs><clipPath id="bar"><rect width="${width}" height="${height}" rx="3"/></clipPath></defs><g clip-path="url(#bar)"><rect width="${width}" height="${height}" fill="${TRACK}" fill-opacity="0.1"/>${rects}</g></svg>`
183}
184
hooks/terminal.ts 42 lines
1import type { Reading } from '../types'
2import type { Strings } from './i18n'
3import type { CacheState } from './model'
4import { bandLayout, heat, percentText, tokens } from './model'
5
6// The terminal has no Svg: quarter-circle glyphs instead of rings, one line, text colored by heat.
7
8export type Part = { text: string; color?: string; dim?: boolean; bold?: boolean }
9
10const HEAT_COLOR = { calm: 'green', warn: 'yellow', hot: 'red' } as const
11
12export const glyph = (percent: number | undefined): string => {
13  const p = percent ?? 0
14  return p < 12.5 ? '○' : p < 37.5 ? '◔' : p < 62.5 ? '◑' : p < 87.5 ? '◕' : '●'
15}
16
17const metric = (label: string, percent: number | undefined, value?: string, color?: string, extra?: string): Part[] => {
18  const c = color ?? (percent === undefined ? undefined : HEAT_COLOR[heat(percent)])
19  return [
20    { text: `${glyph(percent)} `, color: c, dim: c === undefined },
21    { text: `${label} `, dim: true },
22    { text: value ?? percentText(percent), bold: true, color: c },
23    ...(extra !== undefined ? [{ text: ` ${extra}`, dim: true }] : []),
24  ]
25}
26
27export const terminalParts = (r: Reading | null, cache: CacheState | null, columns: number, t: Strings): Part[] => {
28  const layout = bandLayout(columns)
29  const cacheParts = cache === null ? [] : [metric(t.cache, cache.percent, cache.text, cache.isWarm ? (cache.percent > 20 ? 'green' : 'yellow') : undefined)]
30  const groups: Part[][] =
31    r === null
32      ? [metric(t.win5h, undefined), metric(t.win7d, undefined), metric(t.ctx, undefined), ...cacheParts, [{ text: t.waitingBand, dim: true }]]
33      : [
34          ...(r.fiveHour !== undefined ? [metric(t.win5h, r.fiveHour)] : []),
35          ...(r.sevenDay !== undefined ? [metric(t.win7d, r.sevenDay)] : []),
36          metric(t.ctx, r.ctxPercent, undefined, undefined, layout.showCtxTokens && r.ctxTokens !== undefined ? tokens(r.ctxTokens) : undefined),
37          ...cacheParts,
38          ...(layout.showCost && r.usd !== undefined ? [[{ text: `$${r.usd.toFixed(2)}`, dim: true }]] : []),
39        ]
40  return groups.flatMap((g, i) => (i === 0 ? g : [{ text: '  ' }, ...g]))
41}
42
types/index.d.ts 56 lines
1// The part of the latest measurement the mod uses. A field the measurement lacks stays undefined.
2export type Reading = {
3  fiveHour?: number
4  fiveHourResetsAt?: number
5  sevenDay?: number
6  sevenDayResetsAt?: number
7  ctxPercent?: number
8  ctxTokens?: number
9  ctxWindow: number
10  usd?: number
11}
12
13// A main-agent turn: its number, end time, context fill, the session's total cost at its end
14// (absent when unknown) and the turn's summed token counts.
15export type Turn = {
16  n: number
17  at: number
18  ctxPercent: number
19  usd?: number
20  // The 5-hour window's percent used at the turn's end (account-wide).
21  fiveHour?: number
22  model?: string
23  in: number
24  out: number
25  cacheRead: number
26  cacheWrite: number
27}
28
29// A turn for the window breakdown: kept in $.store across sessions.
30export type LogEntry = {
31  id: string
32  at: number
33  project: string
34  model: string
35  tokens: number
36  usd?: number
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'wavy-usage': {
42      reading: Reading | null
43      history: Turn[]
44      isPaneOpen: boolean
45      isEnabled: boolean
46      lastRequestAt: number | null
47      ttlMs: number
48      log: LogEntry[]
49      others: LogEntry[]
50      windowView: '5h' | '7d'
51      screen: { reading: Reading | null; turns: Turn[]; lastRequestAt: number | null; log: LogEntry[] }
52      effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null
53    }
54  }
55}
56