SLOPSHOPPER

TokenTide

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

newpanebandcommandtoasttimer
v0.1.0MITupdated 2026-10-26hansraj316/hansraj-mods/tokentide
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokentide
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ [ Now ][ Turns ] │ ┃ Context ▰▰▰▰▰▰▱▱▱▱▱▱ 97K of 200K ⏺ Read(src/auth.ts) │ ┃ Cache hits ▰▰▰▰▰▰▰▰▰▰▰▱ 93% · 5:00 ⎿ Read 6 lines │ ┃ Plan · 5 hour ▰▰▰▰▱▱▱▱▱▱▱▱ 31% · ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Compact now ] ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tide │ ⎿ tokentide: Context 49% · 97K of 200K │ │ ▰▰▰▰▰▱▱▱▱▱ 49% 97K of 200K · cache 5:00 · too few turns yet to time auto-compact ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▰▰▰▰▰▱▱▱▱▱ 49% 97K of 200K · cache 5:00 · too few turns yet to time auto-compact ⟨Claude Code's own drawing⟩
Pane · Context
[ Now ][ Turns ] Context ▰▰▰▰▰▰▱▱▱▱▱▱ 97K of 200K Cache hits ▰▰▰▰▰▰▰▰▰▰▰▱ 93% · 5:00 Plan · 5 hour ▰▰▰▰▱▱▱▱▱▱▱▱ 31% · [ Compact now ]
README

TokenTide

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 ring above the prompt. A small ring, the percentage, tokens used out of the window, and a prompt-cache countdown. It stays quiet below 60% (Calm), adds the word Warm from 60%, and turns red with a Compact button at the critical line (92% by default). Details opens a breakdown of what is in context (messages, tools, memory files and so on) and an estimate of how many turns are left before auto-compact.
  • Rings pane (/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.
  • Heads-ups. One toast when context passes 80%, one at the critical line, and a nudge when the prompt cache has about a minute left.
  • Compact with a check. Every Compact button, and /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.

What the mod runs, reads and sends

  • Programs it runs: none.
  • What it reads: Claude Code's own usage figures for this session (context fill, the plan's rate-limit windows, the per-category estimate /context shows, and each reply's token counts for the cache hit rate). It reads no files.
  • What it stores: the session's readings (fill per turn, the last breakdown, when the last reply finished) in the session's own state. Nothing is kept across sessions.
  • What it sends out: nothing. It makes no network calls, and the breakdown uses the local estimate, so it adds no API requests.
  • What it changes: only when you press Compact (or type /tide compact) and confirm, it asks Claude Code to compact the conversation.

Settings

In /config, under TokenTide:

SettingDefaultWhat it does
Prompt cache lifetime5mSet 1h if your account uses the long prompt cache.
Heads-up at (%)80When the first toast shows.
Critical at (%)92When the ring turns red and shows Compact.

Install

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.

Source 3 files
hooks/register.tsx 382 lines
1import { 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}
382
hooks/model.ts 151 lines
1// 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[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}
151
types/index.d.ts 45 lines
1export 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