Watch the token tide rise: a ring above the prompt, a Rings pane, a prompt-cache countdown and a heads-up before auto-compact.

Watch the token tide rise. See how full Claude's context window is at a glance, before auto-compact surprises you. Built for the Code tab of the Claude Desktop app. The terminal and VS Code show a text version.
/tide). Concentric rings for context, prompt-cache hit rate and your 5-hour plan window, with the weekly window listed below. The Now tab shows the breakdown, and Turns charts the fill after each turn against the auto-compact line./tide compact, asks before it compacts.All figures come from Claude Code itself ($.session.usage() and its measurements). The breakdown uses the local estimate, so the mod makes no extra API calls.
/context shows, and each reply's token counts for the cache hit rate). It reads no files./tide compact) and confirm, it asks Claude Code to compact the conversation.In /config, under TokenTide:
| Setting | Default | What it does |
|---|---|---|
| Prompt cache lifetime | 5m | Set 1h if your account uses the long prompt cache. |
| Heads-up at (%) | 80 | When the first toast shows. |
| Critical at (%) | 92 | When the ring turns red and shows Compact. |
At the prompt of a Claude Code terminal session:
/plugin install tokentide --marketplace hansraj316/hansraj-mods
Answer y to add the marketplace, then pick a scope (user scope installs it for every session, including the Desktop app's Code tab).
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with hansraj316/hansraj-mods, then Add plugin.
hooks/register.tsx 382 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { HaloBreakdown, HaloCache, HaloTab, HaloUsage } from '../types'
5import {
6 COLORS,
7 SLICE_COLORS,
8 TEXT,
9 WORD,
10 barSvg,
11 cacheText,
12 growthSvg,
13 kTokens,
14 pressure,
15 pushGrowth,
16 resetText,
17 ringsSvg,
18 textBar,
19 ttlMs,
20 turnsLeft,
21 turnsText,
22} from './model'
23import type { Pressure } from './model'
24
25const PANE = 'tide'
26const DEFAULT_COMPACT_AT = 92
27
28const usage = atom({ plugin: 'tokentide', key: 'usage' } as const, { window: 200_000, limits: [], at: 0 } as HaloUsage, { shape: 'usage-1' })
29const growth = atom({ plugin: 'tokentide', key: 'growth' } as const, [] as number[], { shape: 'growth-1' })
30const breakdown = atom({ plugin: 'tokentide', key: 'breakdown' } as const, { slices: [] } as HaloBreakdown, { shape: 'breakdown-1' })
31const cache = atom({ plugin: 'tokentide', key: 'cache' } as const, { lastAt: 0 } as HaloCache, { shape: 'cache-1' })
32const expanded = atom({ plugin: 'tokentide', key: 'expanded' } as const, false)
33const tab = atom({ plugin: 'tokentide', key: 'tab' } as const, 'now' as HaloTab)
34const now = atom({ plugin: 'tokentide', key: 'now' } as const, 0)
35const alerted = atom({ plugin: 'tokentide', key: 'alerted' } as const, 0)
36
37type View = {
38 percent: number
39 level: Pressure
40 used: string
41 window: string
42 cache: string
43 left: number | undefined
44 compactAt: number
45 slices: { name: string; tokens: number; color: string }[]
46}
47
48function limitsOf(list: readonly SessionRateLimit[]): HaloUsage['limits'] {
49 return list.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
50}
51
52async function record($: EngineInterface, context: SessionContextUsage, limits: readonly SessionRateLimit[]): Promise<void> {
53 const at = await $.clock.now()
54 await update($, usage, () => ({ tokens: context.tokens, window: context.window, percent: context.percent, limits: limitsOf(limits), at }))
55 if (context.percent !== undefined) {
56 const before = await read($, growth)
57 const next = pushGrowth(before, context.percent)
58 await update($, growth, () => next)
59 // A compact or clear brings the fill down: the alerts may fire again.
60 if (next.length === 1 && before.length > 1) await update($, alerted, () => 0)
61 }
62}
63
64/** Refreshes the per-category breakdown with the local estimate (no API calls). */
65async function refreshBreakdown($: EngineInterface): Promise<void> {
66 try {
67 const u = await $.session.usage({ breakdown: 'summary' })
68 const b = u.context.breakdown
69 if (!b) return
70 const used = b.categories.filter(c => c.kind === 'used' && c.tokens > 0).sort((a, z) => z.tokens - a.tokens)
71 const top = used.slice(0, 4).map(c => ({ name: c.name, tokens: c.tokens }))
72 const rest = used.slice(4).reduce((a, c) => a + c.tokens, 0)
73 if (rest > 0) top.push({ name: 'Other', tokens: rest })
74 const compactAt =
75 b.isAutoCompactEnabled && b.autoCompactThreshold && b.rawMaxTokens
76 ? Math.round((b.autoCompactThreshold / b.rawMaxTokens) * 100)
77 : undefined
78 await update($, breakdown, () => ({ slices: top, compactAt }))
79 } catch {
80 // The breakdown is a nicety; the halo still shows the fill without it.
81 }
82}
83
84async function view($: EngineInterface, options: Record<string, unknown>, isWorking: boolean): Promise<View | undefined> {
85 const u = await read($, usage)
86 if (u.percent === undefined || u.tokens === undefined) return undefined
87 const criticalAt = Number(options.criticalAt) || DEFAULT_COMPACT_AT
88 const b = await read($, breakdown)
89 const c = await read($, cache)
90 const compactAt = b.compactAt ?? DEFAULT_COMPACT_AT
91 const t = (await read($, now)) || (await $.clock.now())
92 return {
93 percent: u.percent,
94 level: pressure(u.percent, criticalAt),
95 used: kTokens(u.tokens),
96 window: kTokens(u.window),
97 cache: cacheText(c.lastAt, t, ttlMs(options.cacheTtl), isWorking),
98 left: turnsLeft(await read($, growth), u.percent, compactAt),
99 compactAt,
100 slices: b.slices.map((s, i) => ({ ...s, color: SLICE_COLORS[i % SLICE_COLORS.length] ?? COLORS.muted })),
101 }
102}
103
104async function compactWithConfirm($: EngineInterface): Promise<void> {
105 const u = await read($, usage)
106 const fill = u.tokens !== undefined ? `${kTokens(u.tokens)} of ${kTokens(u.window)} tokens` : 'the conversation'
107 let answer = 'Cancel'
108 try {
109 answer = await $.ui.ask(`Compact this session? Claude replaces older turns with a summary of ${fill}. Your files stay as they are.`, {
110 header: 'Context',
111 options: ['Cancel', 'Compact'],
112 })
113 } catch {
114 return
115 }
116 if (answer !== 'Compact') return
117 $.ui.toast('Compacting the conversation…')
118 try {
119 await $.session.compact()
120 } catch (err) {
121 $.ui.toast(`Compact did not run: ${String(err).slice(0, 120)}`)
122 }
123}
124
125export const register: Register = (on, options) => {
126 const opts = options as Record<string, unknown>
127 const warnAt = Number(opts.warnAt) || 80
128 const criticalAt = Number(opts.criticalAt) || DEFAULT_COMPACT_AT
129 const ttl = ttlMs(opts.cacheTtl)
130
131 on('session.start', async ($, e, next) => {
132 const started = await next(e)
133 await $.command.register({
134 name: 'tide',
135 description: 'TokenTide: open the Rings pane, or `/tide compact` to compact now',
136 argumentHint: '[compact]',
137 })
138 try {
139 const u = await $.session.usage()
140 await record($, u.context, u.rateLimits)
141 } catch {
142 // No figures before the first reply; the halo waits for one.
143 }
144 // The cache countdown ticks while there is something to count down.
145 $.clock.every(5000, () => {
146 void (async () => {
147 const c = await read($, cache)
148 const t = await $.clock.now()
149 if (c.lastAt && t - c.lastAt < ttl + 10_000) {
150 await update($, now, () => t)
151 const left = ttl - (t - c.lastAt)
152 if (ttl <= 5 * 60_000 && left <= 60_000 && left > 55_000)
153 $.ui.toast('Prompt cache cools in about a minute. Reply soon to keep reads cheap.', { timeoutMs: 6000 })
154 }
155 })()
156 })
157 return started
158 })
159
160 on('command.run', { command: 'tide' }, async ($, e) => {
161 if (e.args.trim() === 'compact') {
162 await compactWithConfirm($)
163 return { text: 'TokenTide: compact handled.' }
164 }
165 await $.ui.open({ id: PANE, title: 'Context' })
166 const u = await read($, usage)
167 return { text: u.percent === undefined ? 'Context: waiting for the first reply.' : `Context ${u.percent}% · ${kTokens(u.tokens ?? 0)} of ${kTokens(u.window)}` }
168 })
169
170 on('session.measure', async ($, e, next) => {
171 if (e.changed.includes('context') || e.changed.includes('rateLimits')) {
172 await record($, e.context, e.rateLimits)
173 const pct = e.context.percent
174 if (pct !== undefined) {
175 const was = await read($, alerted)
176 const u = await read($, usage)
177 const left = turnsLeft(await read($, growth), pct, (await read($, breakdown)).compactAt ?? DEFAULT_COMPACT_AT)
178 if (pct >= criticalAt && was < criticalAt) {
179 await update($, alerted, () => criticalAt)
180 $.ui.toast(`Context ${pct}% full: ${turnsText(left)}. Type /tide compact to compact now.`, { timeoutMs: 8000 })
181 } else if (pct >= warnAt && was < warnAt) {
182 await update($, alerted, () => warnAt)
183 const biggest = (await read($, breakdown)).slices[0]
184 const share = biggest && u.tokens ? ` ${biggest.name} take up ${Math.round((biggest.tokens / u.tokens) * 100)}% of it.` : ''
185 $.ui.toast(`Context ${pct}% full: ${turnsText(left)}.${share}`, { timeoutMs: 8000 })
186 }
187 }
188 }
189 return next(e)
190 })
191
192 on('turn.complete', async ($, e, next) => {
193 const done = await next(e)
194 if (e.agentId === undefined) {
195 const u = e.usage
196 const input = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
197 const hitRate = u && input > 0 ? Math.round((u.cache_read_input_tokens / input) * 100) : undefined
198 const t = await $.clock.now()
199 await update($, cache, c => ({ lastAt: t, hitRate: hitRate ?? c.hitRate }))
200 await update($, now, () => t)
201 void refreshBreakdown($)
202 }
203 return done
204 })
205
206 // The halo: one line above the prompt, a breakdown under it when opened.
207 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
208 const below = await next(e)
209 if (e.props.hasSurvey) return below
210 const v = await view($, opts, e.props.isWorking)
211 if (!v) return below
212 const isOpen = await read($, expanded)
213 const { Box, Text, Button } = $.ui.resolve(e)
214 const toggle = () => update($, expanded, x => !x)
215 const compact = () => compactWithConfirm($)
216
217 if (e.surface === 'terminal') {
218 const color = v.level === 'critical' ? 'error' : v.level === 'warm' ? 'warning' : 'subtle'
219 return (
220 <Box flexDirection="column">
221 <Box flexDirection="row" gap={1}>
222 <Text color={color}>{textBar(v.percent)}</Text>
223 <Text bold>{`${v.percent}%`}</Text>
224 {v.level !== 'calm' && <Text color={color}>{WORD[v.level]}</Text>}
225 <Text dimColor wrap="truncate">{`${v.used} of ${v.window} · cache ${v.cache} · ${turnsText(v.left)}`}</Text>
226 {v.level === 'critical' && <Button key="compact" label="Compact" onPress={compact} />}
227 </Box>
228 {below}
229 </Box>
230 )
231 }
232
233 const { Svg } = $.ui.resolve(e)
234 const color = COLORS[v.level]
235 const bar = barSvg(v.slices.map(s => ({ value: s.tokens, color: s.color })), 96, 6)
236 return (
237 <Box flexDirection="column" gap="xs">
238 <Box key="halo" flexDirection="row" alignItems="center" gap="sm">
239 <Svg source={ringsSvg([{ percent: v.percent, color }], 16, 3)} alt={`Context ${v.percent}% full`} width={16} height={16} />
240 <Text weight="semibold" tabular>{`${v.percent}%`}</Text>
241 {v.level !== 'calm' && (
242 <Text weight="medium" color={TEXT[v.level]}>
243 {WORD[v.level]}
244 </Text>
245 )}
246 <Text dimColor tabular>{`${v.used} of ${v.window}`}</Text>
247 <Text dimColor>·</Text>
248 <Text dimColor tabular>{`Cache ${v.cache}`}</Text>
249 <Box flexGrow={1} />
250 {v.level === 'critical' && <Button key="compact" variant="primary" label="Compact" onPress={compact} />}
251 {v.slices.length > 0 && <Svg source={bar} alt="What is in context, by category" width={96} height={6} />}
252 <Button key="toggle" plain dimColor label={isOpen ? 'Hide' : 'Details'} onPress={toggle} />
253 </Box>
254 {isOpen && (
255 <Box flexDirection="column" gap="xs" paddingY="xs">
256 {v.slices.map(s => (
257 <Box flexDirection="row" gap="sm" alignItems="center">
258 <Svg source={barSvg([{ value: 1, color: s.color }], 8, 8)} alt="" width={8} height={8} />
259 <Text>{s.name}</Text>
260 <Box flexGrow={1} />
261 <Text dimColor tabular>{kTokens(s.tokens)}</Text>
262 </Box>
263 ))}
264 <Box flexDirection="row" gap="sm" alignItems="center">
265 <Text dimColor>{`Auto-compact: ${turnsText(v.left)}`}</Text>
266 <Box flexGrow={1} />
267 <Button key="rings" label="Open Rings" onPress={() => $.ui.open({ id: PANE, title: 'Context' })} />
268 <Button key="compact-now" variant={v.level === 'critical' ? 'primary' : 'secondary'} label="Compact" onPress={compact} />
269 </Box>
270 </Box>
271 )}
272 {below}
273 </Box>
274 )
275 })
276
277 // The Rings pane: context, cache hits and plan windows, with Now and Turns tabs.
278 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
279 const { Box, Text, Button } = $.ui.resolve(e)
280 const v = await view($, opts, false)
281 if (!v) return <Text dimColor>Context figures arrive with Claude's first reply.</Text>
282 const u = await read($, usage)
283 const c = await read($, cache)
284 const series = await read($, growth)
285 const current = await read($, tab)
286 const t = (await read($, now)) || (await $.clock.now())
287 const five = u.limits.find(l => l.kind === 'five_hour')
288 const week = u.limits.find(l => l.kind === 'seven_day')
289 const rows = [
290 { name: 'Context', color: COLORS[v.level], percent: v.percent, detail: `${v.used} of ${v.window}` },
291 { name: 'Cache hits', color: COLORS.cache, percent: c.hitRate ?? 0, detail: c.hitRate === undefined ? 'no reply yet' : `${c.hitRate}% · ${v.cache}` },
292 five && { name: 'Plan · 5 hour', color: COLORS.fiveHour, percent: five.percentUsed, detail: `${five.percentUsed}% · ${resetText(five.resetsAt, t)}` },
293 week && { name: 'Plan · week', color: COLORS.week, percent: week.percentUsed, detail: `${week.percentUsed}% · ${resetText(week.resetsAt, t)}` },
294 ].filter(Boolean) as { name: string; color: string; percent: number; detail: string }[]
295 const tabs: { id: HaloTab; label: string }[] = [
296 { id: 'now', label: 'Now' },
297 { id: 'turns', label: 'Turns' },
298 ]
299 const tabRow = (
300 <Box flexDirection="row" gap="xs">
301 {tabs.map(x => (
302 <Button key={`tab-${x.id}`} label={x.label} variant={current === x.id ? 'primary' : 'secondary'} onPress={() => update($, tab, () => x.id)} />
303 ))}
304 </Box>
305 )
306
307 if (e.surface === 'terminal') {
308 return (
309 <Box flexDirection="column">
310 {tabRow}
311 {rows.map(r => (
312 <Text>{`${r.name.padEnd(14)} ${textBar(r.percent, 12)} ${r.detail}`}</Text>
313 ))}
314 <Text> </Text>
315 {current === 'now'
316 ? v.slices.map(s => <Text>{`${s.name.padEnd(18)} ${kTokens(s.tokens)}`}</Text>)
317 : [<Text>{`Fill by turn: ${series.join(' → ')}%`}</Text>, <Text dimColor>{turnsText(v.left)}</Text>]}
318 <Button key="compact" label="Compact now" onPress={() => compactWithConfirm($)} />
319 </Box>
320 )
321 }
322
323 const { Svg } = $.ui.resolve(e)
324 const width = Math.max(160, Math.min(320, e.props.bodyColumns * 8))
325 return (
326 <Box flexDirection="column" gap="md">
327 {tabRow}
328 <Box flexDirection="row" justifyContent="center">
329 <Svg
330 source={ringsSvg(rows.slice(0, 3).map(r => ({ percent: r.percent, color: r.color })), 180, 14)}
331 alt={rows.map(r => `${r.name} ${r.percent}%`).join(', ')}
332 width={180}
333 height={180}
334 />
335 </Box>
336 <Box flexDirection="column" alignItems="center">
337 <Text size="title" weight="semibold" tabular>{`${v.percent}%`}</Text>
338 <Text size="caption" weight="semibold" color={TEXT[v.level]}>
339 {WORD[v.level].toUpperCase()}
340 </Text>
341 </Box>
342 <Box flexDirection="column" gap="xs">
343 {rows.map(r => (
344 <Box flexDirection="row" gap="sm" alignItems="center">
345 <Svg source={barSvg([{ value: 1, color: r.color }], 10, 10)} alt="" width={10} height={10} />
346 <Text>{r.name}</Text>
347 <Box flexGrow={1} />
348 <Text dimColor tabular>{r.detail}</Text>
349 </Box>
350 ))}
351 </Box>
352 {current === 'now' ? (
353 <Box flexDirection="column" gap="xs">
354 <Text size="caption" weight="semibold" dimColor>
355 WHAT IS IN CONTEXT
356 </Text>
357 {v.slices.length === 0 && <Text dimColor>The breakdown appears after the next reply.</Text>}
358 {v.slices.length > 0 && <Svg source={barSvg(v.slices.map(s => ({ value: s.tokens, color: s.color })), width, 10)} alt="What is in context, by category" width={width} height={10} />}
359 {v.slices.map(s => (
360 <Box flexDirection="row" gap="sm" alignItems="center">
361 <Svg source={barSvg([{ value: 1, color: s.color }], 8, 8)} alt="" width={8} height={8} />
362 <Text>{s.name}</Text>
363 <Box flexGrow={1} />
364 <Text dimColor tabular>{kTokens(s.tokens)}</Text>
365 </Box>
366 ))}
367 </Box>
368 ) : (
369 <Box flexDirection="column" gap="xs">
370 <Text size="caption" weight="semibold" dimColor>
371 FILL BY TURN
372 </Text>
373 <Svg source={growthSvg(series, v.compactAt, COLORS[v.level], width, 100)} alt={`Fill by turn: ${series.join(', ')} percent`} width={width} height={100} />
374 <Text dimColor>{`The dashed line is auto-compact: ${turnsText(v.left)}.`}</Text>
375 </Box>
376 )}
377 <Button key="compact" variant="primary" label="Compact now" onPress={() => compactWithConfirm($)} />
378 </Box>
379 )
380 })
381}
382hooks/model.ts 151 lines1// Pure helpers: pressure levels, formatting, the turns-left estimate and the SVG pieces.
2
3export type Pressure = 'calm' | 'warm' | 'critical'
4
5export const COLORS = {
6 calm: '#0A84FF',
7 warm: '#FF9F0A',
8 critical: '#FF453A',
9 cache: '#40C8E0',
10 fiveHour: '#5E5CE6',
11 week: '#BF5AF2',
12 track: '#3A3A3C',
13 muted: '#636366',
14} as const
15
16/** Text colors that pass contrast on the desktop's dark and light grounds alike. */
17export const TEXT = { calm: '#409CFF', warm: '#E08600', critical: '#FF6961' } as const
18
19export const SLICE_COLORS = ['#40C8E0', '#BF5AF2', '#5E5CE6', '#8E8E93', '#64D2FF'] as const
20
21/** Calm below 60%, warm from 60%, critical from `criticalAt`. */
22export function pressure(percent: number, criticalAt: number): Pressure {
23 if (percent >= criticalAt) return 'critical'
24 if (percent >= Math.min(60, criticalAt)) return 'warm'
25 return 'calm'
26}
27
28export const WORD: Record<Pressure, string> = { calm: 'Calm', warm: 'Warm', critical: 'Critical' }
29
30export function kTokens(n: number): string {
31 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
32 if (n >= 1000) return `${Math.round(n / 1000)}K`
33 return String(Math.round(n))
34}
35
36/** Adds a reading to the per-turn series; a big drop (compact, clear) starts it over. */
37export function pushGrowth(series: readonly number[], percent: number): number[] {
38 const last = series[series.length - 1]
39 if (last !== undefined && percent < last - 15) return [percent]
40 if (last === percent) return [...series]
41 return [...series, percent].slice(-24)
42}
43
44/** Turns until auto-compact at the recent pace, or undefined when it cannot tell. */
45export function turnsLeft(series: readonly number[], percent: number, compactAt: number): number | undefined {
46 if (percent >= compactAt) return 0
47 const recent = series.slice(-7)
48 const steps: number[] = []
49 for (let i = 1; i < recent.length; i++) {
50 const d = (recent[i] ?? 0) - (recent[i - 1] ?? 0)
51 if (d > 0) steps.push(d)
52 }
53 if (steps.length < 2) return undefined
54 const avg = steps.reduce((a, b) => a + b, 0) / steps.length
55 if (avg <= 0.2) return undefined
56 return Math.max(1, Math.ceil((compactAt - percent) / avg))
57}
58
59export function turnsText(n: number | undefined): string {
60 if (n === undefined) return 'too few turns yet to time auto-compact'
61 if (n === 0) return 'auto-compact is due'
62 if (n <= 2) return `${n === 1 ? '1 turn' : '1–2 turns'} before auto-compact`
63 return `about ${n} turns before auto-compact`
64}
65
66/** Remaining prompt-cache life as m:ss, 'cold' once gone, '—' before the first reply. */
67export function cacheText(lastAt: number, now: number, ttlMs: number, isWorking: boolean): string {
68 if (!lastAt) return '—'
69 if (isWorking) return 'warm'
70 const left = ttlMs - (now - lastAt)
71 if (left <= 0) return 'cold'
72 const s = Math.ceil(left / 1000)
73 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
74}
75
76export function ttlMs(option: unknown): number {
77 return option === '1h' ? 60 * 60_000 : 5 * 60_000
78}
79
80function esc(s: string): string {
81 return s.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c] as string)
82}
83
84/** One ring (or several concentric ones) as an SVG document. */
85export function ringsSvg(rings: { percent: number; color: string }[], size: number, stroke: number): string {
86 const c = size / 2
87 const parts: string[] = []
88 rings.forEach((ring, i) => {
89 const r = c - stroke / 2 - i * (stroke + 3)
90 if (r <= 2) return
91 const len = 2 * Math.PI * r
92 const on = Math.max(0, Math.min(100, ring.percent)) / 100 * len
93 parts.push(
94 `<circle cx="${c}" cy="${c}" r="${r.toFixed(2)}" fill="none" stroke="${esc(ring.color)}" stroke-opacity="0.25" stroke-width="${stroke}"/>`,
95 on > 0
96 ? `<circle cx="${c}" cy="${c}" r="${r.toFixed(2)}" fill="none" stroke="${esc(ring.color)}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${on.toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`
97 : '',
98 )
99 })
100 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${parts.join('')}</svg>`
101}
102
103/** A segmented bar: one rounded run per slice, 2px apart. */
104export function barSvg(slices: { value: number; color: string }[], width: number, height: number): string {
105 const total = slices.reduce((a, s) => a + Math.max(0, s.value), 0)
106 if (total <= 0)
107 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="${width}" height="${height}" rx="${height / 2}" fill="${COLORS.track}"/></svg>`
108 const gap = slices.length > 1 ? 2 : 0
109 const room = width - gap * (slices.length - 1)
110 let x = 0
111 const rects = slices.map(s => {
112 const w = Math.max(1, (Math.max(0, s.value) / total) * room)
113 const rect = `<rect x="${x.toFixed(2)}" width="${w.toFixed(2)}" height="${height}" fill="${esc(s.color)}"/>`
114 x += w + gap
115 return rect
116 })
117 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><clipPath id="r"><rect width="${width}" height="${height}" rx="${height / 2}"/></clipPath><g clip-path="url(#r)">${rects.join('')}</g></svg>`
118}
119
120/** Bars for each turn's fill, the last in the pressure color, with a dashed auto-compact line. */
121export function growthSvg(series: readonly number[], compactAt: number, accent: string, width: number, height: number): string {
122 const n = Math.max(series.length, 1)
123 const gap = 4
124 const w = Math.max(2, (width - gap * (n - 1)) / n)
125 const y = (p: number) => height - (Math.min(100, p) / 100) * height
126 const bars = series
127 .map((p, i) => {
128 const barH = Math.max(2, height - y(p))
129 return `<rect x="${(i * (w + gap)).toFixed(2)}" y="${(height - barH).toFixed(2)}" width="${w.toFixed(2)}" height="${barH.toFixed(2)}" rx="2" fill="${i === series.length - 1 ? esc(accent) : COLORS.muted}"/>`
130 })
131 .join('')
132 const line = `<line x1="0" x2="${width}" y1="${y(compactAt).toFixed(2)}" y2="${y(compactAt).toFixed(2)}" stroke="#A1A1A6" stroke-width="1" stroke-dasharray="4 4"/>`
133 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${bars}${line}</svg>`
134}
135
136/** Text bar for the terminal: ▰▰▰▱▱ */
137export function textBar(percent: number, cells = 10): string {
138 const on = Math.round((Math.max(0, Math.min(100, percent)) / 100) * cells)
139 return '▰'.repeat(on) + '▱'.repeat(cells - on)
140}
141
142export function resetText(iso: string | undefined, now: number): string {
143 if (!iso) return ''
144 const at = Date.parse(iso)
145 if (Number.isNaN(at)) return ''
146 const min = Math.max(0, Math.round((at - now) / 60_000))
147 if (min < 60) return `resets in ${min}m`
148 if (min < 48 * 60) return `resets in ${Math.round(min / 60)}h`
149 return `resets in ${Math.round(min / 1440)}d`
150}
151types/index.d.ts 45 lines1export type HaloLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type HaloUsage = {
4 /** Input tokens of the last reply, cached and uncached together. */
5 tokens?: number
6 /** The model's context window in tokens. */
7 window: number
8 /** tokens over window, 0 to 100. */
9 percent?: number
10 limits: HaloLimit[]
11 at: number
12}
13
14export type HaloSlice = { name: string; tokens: number }
15
16export type HaloBreakdown = {
17 slices: HaloSlice[]
18 /** Where auto-compact runs, as a percentage of the window. */
19 compactAt?: number
20}
21
22export type HaloCache = {
23 /** When the last main-thread reply finished, in clock milliseconds; 0 before one. */
24 lastAt: number
25 /** Share of the last reply's input the prompt cache served, 0 to 100. */
26 hitRate?: number
27}
28
29export type HaloTab = 'now' | 'turns'
30
31declare module 'claude-code' {
32 interface PluginState {
33 'tokentide': {
34 usage: Shaped<HaloUsage>
35 growth: Shaped<number[]>
36 breakdown: Shaped<HaloBreakdown>
37 cache: Shaped<HaloCache>
38 expanded: boolean
39 tab: HaloTab
40 now: number
41 alerted: number
42 }
43 }
44}
45