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…

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, Claude Code desktop)
| Ring | Shows |
|---|---|
5h | Share of the 5-hour limit window used |
7d | Share of the 7-day limit window used |
ctx | Context window fill, with the token count |
cache | Time 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)
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.
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.
| Command | Does |
|---|---|
/wavy-usage | Opens or closes the pane |
/wavy-usage off | Hides the band and the pane, and stops the toasts. Kept across sessions |
/wavy-usage on | Turns it back on |
/wavy-usage ttl 5m / ttl 1h | Sets the cache lifetime the countdown uses (default 1 hour, what Claude Code uses on a subscription) |
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.
session.measure event: the figures the last API response already reported. Nothing extra is requested.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.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.
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.
Inspired by usage-band by Yash Gadodia, and by cache-ttl-timer and claude-runway.
MIT © 2026 Batuhan Çakmak
hooks/register.tsx 743 lines1import { 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, '&').replace(/</g, '<')}</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}
743hooks/i18n.ts 605 lines1// 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}
605hooks/model.ts 360 lines1import 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}
360hooks/jet.ts 247 lines1// 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, '&').replace(/</g, '<')
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}
247hooks/svg.ts 184 lines1import 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, '&').replace(/</g, '<')}</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}
184hooks/terminal.ts 42 lines1import 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}
42types/index.d.ts 56 lines1// 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