SLOPSHOPPER

Context Rings

A ring per active session above the prompt, filled by how much of its context window is used; newest activity first, hover for the name

newbandtoastprocesstimer
v0.1.0MITupdated 2026-10-15nvsravank/context-rings
A shopper browsing a rack in a slop shop
README

Context Rings

A Claude Code mod that shows how full each of your sessions' context windows is, as a row of rings above the prompt. The session you were active in most recently comes first. Point at a ring to see the session's name, folder, tokens used and whether it is working, idle or closed.

  • One ring per session. A session shows while its process is running, and stays (faded) for an hour after it closes, matching the prompt cache.
  • Fill and color. A ring fills against your context window (1M by default) and turns yellow, then red, at the percentages you choose (50% and 80% by default). A working session's ring pulses; this session's has a dot.
  • After /compact. The ring drops to the compacted size right away, before the next reply reports the real one.
  • Subagents. A subagent appears as a small dot after its session's ring only once it reaches the yellow line of the subagent limit (75% of 200K by default), and only while it is active. When it reaches red (90%) you get a single toast, shared across all your open sessions.
  • Folders. The chip at the start of the row reads All; click it to step through each folder's sessions and back.

On the terminal the rings are drawn as ○ ◔ ◑ ◕ ●.

Install

At the prompt of a terminal session:

/plugin install context-rings --marketplace nvsravank/context-rings

Settings

Each is a row in the mod's settings in /config.

SettingDefaultChoices
Session context window1M100K to 1M
Session ring turns yellow at50%30% to 90%
Session ring turns red at80%50% to 95%
Subagent limit200K100K to 1M
Subagent dot appears (yellow) at75%30% to 90%
Subagent dot turns red (and toasts) at90%50% to 95%

What it runs and changes

It sends nothing: no network calls. It does not read the conversation of the session it runs in through Claude Code's API. It reads Claude Code's own files about your sessions, on your machine, to find each session's name and context size.

What it reads, every 5 seconds and after each turn:

WhatWhy
~/.claude/sessions/*.jsonWhich sessions are running, their names, folders and whether they are working
~/.claude/projects/**/*.jsonl (the last 1 MB of each changed in the last hour)The context size the last reply or compaction reported
~/.claude/projects/**/custom-title.json, subagents/*.meta.jsonSession titles and subagent descriptions

What it runs (read-only, no shell except the first):

CommandWhy
sh -c 'printf %s "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'Once, to find Claude Code's folder
ps -o pid= -p <pids>Which session processes are still running
find ~/.claude/projects … -exec stat -f '%m %N'Which transcripts changed, and when
tail -c 1000000 <transcripts>The end of each changed transcript

What it writes: only its own store in Claude Code's plugin data, holding recent session names (so a closed session keeps its title) and which subagents were already toasted. Its hooks pass every event through unchanged.

Uninstall with /plugin uninstall context-rings; nothing is left behind but that store.

License

MIT

Source 3 files
hooks/register.tsx 306 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ring, Sub } from '../types'
5import {
6  K,
7  baseName,
8  fmt,
9  glyph,
10  lastContextTokens,
11  level,
12  parsePct,
13  parseSize,
14  pctOf,
15  parseStat,
16  projectDir,
17  ringLabel,
18  ringsSvg,
19  splitTail,
20} from './parse'
21import type { Scale, TranscriptFile } from './parse'
22
23type Engine = EngineInterface
24
25// How often every session's files are read again.
26const POLL_MS = 5000
27// A closed session stays (dimmed) this long after its last activity: the prompt cache's hour.
28const RECENT_MS = 60 * 60 * 1000
29// A subagent whose transcript has not moved in this long is taken as finished.
30const SUB_ACTIVE_MS = 10 * 60 * 1000
31// How much of each transcript's end is read for its last response's usage.
32const TAIL_BYTES = 1000000
33// Files per tail call, so one call's output stays under process.run's 4 MiB.
34const TAIL_BATCH = 3
35const FOLDER_KEY = 'folder'
36
37const c = { ok: 'success', warn: 'warning', bad: 'error', chip: 'suggestion' } as const
38
39// --- preferences: a change reloads the module, so register sets them again ---
40let main: Scale = { limit: 1000 * K, yellow: 50, red: 80 }
41let sub: Scale = { limit: 200 * K, yellow: 75, red: 90 }
42
43const rings = atom({ plugin: 'context-rings', key: 'rings' } as const, [])
44const folder = atom({ plugin: 'context-rings', key: 'folder' } as const, '')
45
46type Live = { pid: number; sessionId: string; cwd: string; name?: string; status?: string; updatedAt: number }
47type Known = { name?: string; cwd?: string }
48
49let configDir = ''
50const tokensByPath = new Map<string, { mtime: number; tokens: number; cwd?: string }>()
51const subNames = new Map<string, string>()
52
53async function findConfigDir($: Engine): Promise<string> {
54  if (configDir) return configDir
55  const { stdout } = await $.process.run(['/bin/sh', '-c', 'printf %s "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'])
56  configDir = stdout.trim()
57  return configDir
58}
59
60async function readLive($: Engine, dir: string): Promise<Live[]> {
61  const entries = await $.fs.list(`${dir}/sessions`).catch(() => [])
62  const out: Live[] = []
63  for (const entry of entries) {
64    if (entry.kind !== 'file' || !/^\d+\.json$/.test(entry.name)) continue
65    try {
66      const j = JSON.parse(await $.fs.read(`${dir}/sessions/${entry.name}`))
67      if (typeof j.sessionId !== 'string' || typeof j.pid !== 'number') continue
68      out.push({
69        pid: j.pid,
70        sessionId: j.sessionId,
71        cwd: String(j.cwd ?? ''),
72        name: typeof j.name === 'string' ? j.name : undefined,
73        status: typeof j.status === 'string' ? j.status : undefined,
74        updatedAt: Number(j.statusUpdatedAt ?? j.updatedAt ?? 0),
75      })
76    } catch {
77      // A file being rewritten right now: next poll.
78    }
79  }
80  if (out.length === 0) return out
81  // A registry file can outlive a crashed process: keep only pids still running.
82  const ps = await $.process.run(['/bin/ps', '-o', 'pid=', '-p', out.map(l => l.pid).join(',')])
83  const alive = new Set(ps.stdout.split('\n').map(s => Number(s.trim())).filter(Boolean))
84  return out.filter(l => alive.has(l.pid))
85}
86
87async function statTranscripts($: Engine, dir: string, live: readonly Live[]): Promise<TranscriptFile[]> {
88  const names = live.flatMap(l => ['-o', '-name', `${l.sessionId}.jsonl`])
89  const minutes = String(Math.ceil(RECENT_MS / 60000))
90  const { stdout } = await $.process.run([
91    '/usr/bin/find', `${dir}/projects`, '-maxdepth', '4', '-name', '*.jsonl',
92    '(', '-mmin', `-${minutes}`, ...names, ')',
93    '-exec', '/usr/bin/stat', '-f', '%m %N', '{}', '+',
94  ])
95  return parseStat(stdout)
96}
97
98async function readTokens($: Engine, files: readonly TranscriptFile[]) {
99  const stale = files.filter(f => tokensByPath.get(f.path)?.mtime !== f.mtime)
100  for (let i = 0; i < stale.length; i += TAIL_BATCH) {
101    const batch = stale.slice(i, i + TAIL_BATCH)
102    const paths = batch.map(f => f.path)
103    const { stdout } = await $.process.run(['/usr/bin/tail', '-c', String(TAIL_BYTES), ...paths])
104    const texts = splitTail(stdout, paths)
105    for (const f of batch) {
106      const text = texts.get(f.path) ?? ''
107      const before = tokensByPath.get(f.path)
108      const cwd = /"cwd":"([^"]+)"/.exec(text)?.[1]
109      tokensByPath.set(f.path, { mtime: f.mtime, tokens: lastContextTokens(text) ?? before?.tokens ?? 0, cwd: cwd ?? before?.cwd })
110    }
111  }
112}
113
114async function subName($: Engine, path: string): Promise<string> {
115  const known = subNames.get(path)
116  if (known) return known
117  let name = 'subagent'
118  try {
119    const meta = JSON.parse(await $.fs.read(path.replace(/\.jsonl$/, '.meta.json')))
120    name = String(meta.description ?? meta.agentType ?? name)
121  } catch {
122    // No meta file: keep the plain name.
123  }
124  subNames.set(path, name)
125  return name
126}
127
128async function savedTitle($: Engine, mainPath: string, sessionId: string): Promise<string | undefined> {
129  try {
130    const j = JSON.parse(await $.fs.read(mainPath.replace(/\.jsonl$/, '') + '/custom-title.json'))
131    return typeof j.customTitle === 'string' ? j.customTitle : undefined
132  } catch {
133    return undefined
134  }
135}
136
137async function alertSubs($: Engine, subs: readonly { ring: Ring; sub: Sub; mtime: number }[], now: number) {
138  // A toast when a subagent's dot turns red.
139  const due = subs.filter(s => now - s.mtime < SUB_ACTIVE_MS && pctOf(s.sub.tokens, sub) >= sub.red)
140  if (due.length === 0) return
141  // Every open session runs this: the shared store says which subagents were already toasted.
142  const alerted = ((await $.store.get('alerted')) ?? {}) as Record<string, number>
143  let isChanged = false
144  for (const { ring, sub: s } of due) {
145    if (alerted[s.id]) continue
146    alerted[s.id] = now
147    isChanged = true
148    $.ui.toast(`⚠ ${ring.name} › ${s.name}: ${fmt(s.tokens)} of its ${fmt(sub.limit)} limit`, { timeoutMs: 8000 })
149  }
150  if (!isChanged) return
151  for (const [id, at] of Object.entries(alerted)) if (now - at > 24 * RECENT_MS) delete alerted[id]
152  await $.store.set('alerted', alerted)
153}
154
155async function refresh($: Engine) {
156  const dir = await findConfigDir($)
157  const now = await $.clock.now()
158  const currentId = await $.session.id()
159  const live = await readLive($, dir)
160  const files = await statTranscripts($, dir, live)
161  await readTokens($, files)
162
163  const known = ((await $.store.get('known')) ?? {}) as Record<string, Known>
164  const liveById = new Map(live.map(l => [l.sessionId, l]))
165  const mains = new Map(files.filter(f => f.kind === 'main').map(f => [f.sessionId, f]))
166  const ids = new Set([...liveById.keys(), ...mains.keys()])
167
168  const out: Ring[] = []
169  for (const id of ids) {
170    const l = liveById.get(id)
171    const f = mains.get(id)
172    const seen = f ? tokensByPath.get(f.path) : undefined
173    const lastAt = Math.max(f?.mtime ?? 0, l?.updatedAt ?? 0)
174    if (!l && now - lastAt > RECENT_MS) continue
175    const cwd = l?.cwd || seen?.cwd || known[id]?.cwd || ''
176    const name =
177      l?.name || (f ? await savedTitle($, f.path, id) : undefined) || known[id]?.name || `${baseName(cwd) || 'session'} · ${id.slice(0, 8)}`
178    known[id] = { name, cwd }
179    out.push({ id, name, cwd, tokens: seen?.tokens ?? 0, isLive: !!l, isBusy: l?.status === 'busy', isCurrent: id === currentId, lastAt, subs: [] })
180  }
181
182  const byId = new Map(out.map(r => [r.id, r]))
183  const subRows: { ring: Ring; sub: Sub; mtime: number }[] = []
184  for (const f of files) {
185    if (f.kind !== 'sub') continue
186    const ring = byId.get(f.sessionId)
187    const tokens = tokensByPath.get(f.path)?.tokens ?? 0
188    // A subagent shows once its dot would be yellow, and only while active.
189    if (!ring || pctOf(tokens, sub) < sub.yellow || now - f.mtime > SUB_ACTIVE_MS) continue
190    const row = { id: f.agentId, name: await subName($, f.path), tokens }
191    ring.subs.push(row)
192    subRows.push({ ring, sub: row, mtime: f.mtime })
193  }
194
195  out.sort((a, b) => b.lastAt - a.lastAt)
196  await alertSubs($, subRows, now)
197
198  // Keep names of sessions seen lately, so a closed one still has its title.
199  const keep = Object.fromEntries(Object.entries(known).filter(([id]) => ids.has(id)).slice(-200))
200  await $.store.set('known', keep)
201
202  const prev = await read($, rings)
203  if (JSON.stringify(prev) !== JSON.stringify(out)) await update($, rings, () => out)
204}
205
206let pollTimer: { cancel: () => void } | null = null
207function schedule($: Engine) {
208  if (pollTimer) pollTimer.cancel()
209  pollTimer = $.clock.after(POLL_MS, () => {
210    void refresh($)
211      .catch(() => undefined)
212      .then(() => schedule($))
213  })
214}
215
216function folders(all: readonly Ring[]): string[] {
217  return [...new Set(all.map(r => r.cwd).filter(Boolean))]
218}
219
220let lastCycleAt = 0
221/** The chip cycles All → each folder → All. */
222async function cycleFolder($: Engine) {
223  const now = await $.clock.now()
224  if (now - lastCycleAt < 400) return
225  lastCycleAt = now
226  const list = folders(await read($, rings))
227  await update($, folder, cur => {
228    const i = list.indexOf(cur)
229    return i + 1 < list.length ? list[i + 1]! : ''
230  })
231}
232
233export const register: Register = (on, options) => {
234  main = { limit: parseSize(options.contextWindow, 1000 * K), yellow: parsePct(options.mainYellow, 50), red: parsePct(options.mainRed, 80) }
235  sub = { limit: parseSize(options.subagentLimit, 200 * K), yellow: parsePct(options.subagentYellow, 75), red: parsePct(options.subagentRed, 90) }
236
237  on('session.start', async ($, e, next) => {
238    void refresh($).catch(() => undefined)
239    schedule($)
240    return next(e)
241  })
242
243  on('turn.complete', async ($, e, next) => {
244    void refresh($).catch(() => undefined)
245    return next(e)
246  })
247
248  // Presses arrive by onPress, ui.press or both (desktop); either cycles the chip once.
249  on('ui.press', { component: 'AbovePrompt' }, async ($, e, next) => {
250    if (e.element !== FOLDER_KEY) return next(e)
251    await cycleFolder($)
252    return { element: e.element }
253  })
254
255  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
256    const all = await read($, rings)
257    if (e.props.hasSurvey || all.length === 0) return next(e)
258    const picked = await read($, folder)
259    const shown = picked ? all.filter(r => r.cwd === picked) : all
260    const now = await $.clock.now()
261    const ui = $.ui.resolve(e)
262    const { Box, Text, Button } = ui
263    const chip = <Button key={FOLDER_KEY} label={picked ? baseName(picked) : 'All'} onPress={() => void cycleFolder($)} />
264
265    // Desktop: one interactive SVG; pointing at a ring reveals its label inside it (CSS :hover).
266    if (e.surface !== 'terminal' && 'Svg' in ui) {
267      const { Svg } = ui
268      const svg = ringsSvg(shown, main, sub, now)
269      return (
270        <Box flexDirection="row" alignItems="center">
271          {chip}
272          <Box marginLeft={1}>
273            <Svg source={svg.source} width={svg.width} height={svg.height} isInteractive alt={`${shown.length} sessions' context`} />
274          </Box>
275        </Box>
276      )
277    }
278
279    // Terminal: glyphs; each is a hover group that reveals its label at the row's end.
280    const scope = (r: Ring) => `ring-${r.id.slice(0, 20)}`
281    return (
282      <Box flexDirection="row" width={e.props.bodyColumns}>
283        {chip}
284        {shown.map(r => {
285          const pct = pctOf(r.tokens, main)
286          return (
287            <Box key={r.id} marginLeft={1} hover={{ scope: scope(r) }}>
288              <Text color={c[level(pct, main)]} dimColor={!r.isLive} bold={r.isCurrent}>
289                {glyph(pct)}
290                {r.subs.map(s => (level(pctOf(s.tokens, sub), sub) === 'bad' ? '•' : '·')).join('')}
291              </Text>
292            </Box>
293          )
294        })}
295        {shown.map(r => (
296          <Box key={`label-${r.id}`} marginLeft={2} flexShrink={1} display="none" hover={{ scope: scope(r), display: 'flex' }}>
297            <Text dimColor wrap="truncate-end">
298              {ringLabel(r, main, now)}
299            </Text>
300          </Box>
301        ))}
302      </Box>
303    )
304  })
305}
306
hooks/parse.ts 218 lines
1// Pure helpers: no `$` here, so the validator only has to follow register.tsx.
2
3import type { Ring } from '../types'
4
5export const K = 1000
6
7/** "100K" → 100000, "1M" → 1000000; anything else → fallback. */
8export function parseSize(text: unknown, fallback: number): number {
9  if (typeof text !== 'string') return fallback
10  const m = /^(\d+)([KM])$/.exec(text)
11  if (!m) return fallback
12  return Number(m[1]) * (m[2] === 'M' ? 1000 * K : K)
13}
14
15/** 340000 → "340K", 1000000 → "1M". */
16export function fmt(tokens: number): string {
17  if (tokens >= 1000 * K) return `${+(tokens / (1000 * K)).toFixed(2)}M`
18  return `${Math.round(tokens / K)}K`
19}
20
21export function baseName(path: string): string {
22  const parts = path.split('/').filter(Boolean)
23  return parts[parts.length - 1] ?? path
24}
25
26/** The folder name Claude Code files a project's transcripts under. */
27export function projectDir(cwd: string): string {
28  return cwd.replace(/[^A-Za-z0-9]/g, '-')
29}
30
31export type TranscriptFile =
32  | { kind: 'main'; path: string; mtime: number; sessionId: string }
33  | { kind: 'sub'; path: string; mtime: number; sessionId: string; agentId: string }
34
35/** Lines of `stat -f '%m %N'`: seconds, a space, the path. */
36export function parseStat(stdout: string): TranscriptFile[] {
37  const out: TranscriptFile[] = []
38  for (const line of stdout.split('\n')) {
39    const i = line.indexOf(' ')
40    if (i <= 0) continue
41    const mtime = Number(line.slice(0, i)) * 1000
42    const path = line.slice(i + 1)
43    const sub = /\/([0-9a-f-]{36})\/subagents\/agent-([A-Za-z0-9_-]+)\.jsonl$/.exec(path)
44    if (sub) {
45      out.push({ kind: 'sub', path, mtime, sessionId: sub[1]!, agentId: sub[2]! })
46      continue
47    }
48    const main = /\/([0-9a-f-]{36})\.jsonl$/.exec(path)
49    if (main) out.push({ kind: 'main', path, mtime, sessionId: main[1]! })
50  }
51  return out
52}
53
54/**
55 * Splits the output of `tail -c N a b c` into each file's text. With more than
56 * one file tail heads each with `==> path <==`; with one it writes none.
57 */
58export function splitTail(stdout: string, paths: readonly string[]): Map<string, string> {
59  const map = new Map<string, string>()
60  if (paths.length === 1) {
61    map.set(paths[0]!, stdout)
62    return map
63  }
64  const re = /^==> (.*) <==$/gm
65  const heads: { path: string; start: number; end: number }[] = []
66  let m: RegExpExecArray | null
67  while ((m = re.exec(stdout))) heads.push({ path: m[1]!, start: m.index, end: re.lastIndex })
68  heads.forEach((h, i) => {
69    const stop = i + 1 < heads.length ? heads[i + 1]!.start : stdout.length
70    map.set(h.path, stdout.slice(h.end, stop))
71  })
72  return map
73}
74
75/**
76 * The context the last assistant response was answered over: uncached input,
77 * cache writes and cache reads together. A compaction after the last response
78 * counts as its `postTokens`, until the next response reports the real figure.
79 * Undefined when the tail holds neither.
80 */
81export function lastContextTokens(text: string): number | undefined {
82  const lines = text.split('\n')
83  for (let i = lines.length - 1; i >= 0; i--) {
84    const line = lines[i]!
85    const isCompact = line.includes('"compact_boundary"')
86    if (!isCompact && (!line.includes('"usage"') || !line.includes('"assistant"'))) continue
87    try {
88      const row = JSON.parse(line)
89      if (isCompact && row?.subtype === 'compact_boundary') {
90        const post = row?.compactMetadata?.postTokens
91        if (typeof post === 'number') return post
92        continue
93      }
94      const u = row?.message?.usage
95      if (!u || row?.message?.role !== 'assistant') continue
96      return (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
97    } catch {
98      // The tail's first line is usually cut mid-row.
99    }
100  }
101  return undefined
102}
103
104/** Five fill steps for the terminal, where there is no SVG. */
105export function glyph(pct: number): string {
106  if (pct < 12.5) return '○'
107  if (pct < 37.5) return '◔'
108  if (pct < 62.5) return '◑'
109  if (pct < 87.5) return '◕'
110  return '●'
111}
112
113/** A limit and the percentages of it where yellow and red begin. */
114export type Scale = { limit: number; yellow: number; red: number }
115
116export function pctOf(tokens: number, scale: Scale): number {
117  return Math.min(100, (tokens / scale.limit) * 100)
118}
119
120export function level(pct: number, scale: Scale): 'ok' | 'warn' | 'bad' {
121  return pct < scale.yellow ? 'ok' : pct < scale.red ? 'warn' : 'bad'
122}
123
124/** "65%" → 65; anything else → fallback. */
125export function parsePct(text: unknown, fallback: number): number {
126  const m = typeof text === 'string' ? /^(\d+)%$/.exec(text) : null
127  return m ? Number(m[1]) : fallback
128}
129
130export function ago(ms: number): string {
131  const s = Math.max(0, Math.round(ms / 1000))
132  if (s < 60) return `${s}s ago`
133  if (s < 3600) return `${Math.round(s / 60)}m ago`
134  return `${Math.round(s / 3600)}h ago`
135}
136
137const HEX = { ok: '#3fb950', warn: '#d29922', bad: '#f85149' } as const
138const RING = 24
139const DOT = 8
140
141/** What hovering a ring reveals: name, folder, fill and state. */
142export function ringLabel(r: Ring, main: Scale, now: number): string {
143  const pct = pctOf(r.tokens, main)
144  const state = r.isLive ? (r.isBusy ? 'working' : 'idle') : `closed ${ago(now - r.lastAt)}`
145  const subs = r.subs.map(s => `${s.name} ${fmt(s.tokens)}`).join(', ')
146  return `${r.name} · ${baseName(r.cwd)} · ${fmt(r.tokens)} of ${fmt(main.limit)} (${Math.round(pct)}%) · ${state}${r.isCurrent ? ' · this session' : ''}${subs ? ` · subagents: ${subs}` : ''}`
147}
148
149/** One session's ring, with its subagents' dots after it. */
150export function ringSvg(r: Ring, main: Scale, sub: Scale): { source: string; width: number; height: number } {
151  const pct = pctOf(r.tokens, main)
152  const color = HEX[level(pct, main)]
153  const c = RING / 2
154  const rad = 9
155  const len = 2 * Math.PI * rad
156  const parts = [
157    `<g opacity="${r.isLive ? 1 : 0.4}">`,
158    `<circle cx="${c}" cy="${c}" r="${rad}" fill="none" stroke="#8b949e" stroke-opacity="0.3" stroke-width="3"/>`,
159    `<circle cx="${c}" cy="${c}" r="${rad}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round"` +
160      ` stroke-dasharray="${((len * pct) / 100).toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`,
161    r.isCurrent ? `<circle cx="${c}" cy="${c}" r="2.5" fill="#8b949e"/>` : '',
162    r.isBusy
163      ? `<circle cx="${c}" cy="${c}" r="${rad}" fill="none" stroke="${color}" stroke-width="1" opacity="0.6">` +
164        `<animate attributeName="r" values="${rad};${rad + 2.5};${rad}" dur="1.6s" repeatCount="indefinite"/></circle>`
165      : '',
166    `</g>`,
167  ]
168  let x = RING
169  for (const s of r.subs) {
170    parts.push(`<circle cx="${x + DOT / 2}" cy="${c}" r="3.5" fill="${HEX[level(pctOf(s.tokens, sub), sub)]}"/>`)
171    x += DOT
172  }
173  return {
174    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${x}" height="${RING}" viewBox="0 0 ${x} ${RING}">${parts.join('')}</svg>`,
175    width: x,
176    height: RING,
177  }
178}
179
180function esc(text: string): string {
181  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
182}
183
184// Room for the hover label after the rings, in CSS pixels.
185const LABEL_W = 640
186
187/**
188 * Every ring in one interactive SVG. Pointing at a ring reveals its label,
189 * drawn in the same SVG after the last ring, by CSS :hover inside the frame.
190 */
191export function ringsSvg(rings: readonly Ring[], main: Scale, sub: Scale, now: number): { source: string; width: number; height: number } {
192  let x = 0
193  const groups: string[] = []
194  const pieces = rings.map(r => ringSvg(r, main, sub))
195  pieces.forEach(p => (x += p.width + 6))
196  const labelX = x + 6
197  x = 0
198  rings.forEach((r, i) => {
199    const p = pieces[i]!
200    const inner = p.source.replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '')
201    groups.push(
202      `<g class="r"><rect x="${x}" y="0" width="${p.width + 6}" height="${RING}" fill="transparent"/>` +
203        `<g transform="translate(${x} 0)">${inner}</g>` +
204        `<text class="l" x="${labelX}" y="${RING / 2 + 4}">${esc(ringLabel(r, main, now))}</text></g>`,
205    )
206    x += p.width + 6
207  })
208  const width = labelX + LABEL_W
209  const style =
210    `<style>.l{visibility:hidden;font:12px -apple-system,system-ui,sans-serif;fill:#8b949e}` +
211    `.r:hover .l{visibility:visible}.r{cursor:default}</style>`
212  return {
213    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${RING}" viewBox="0 0 ${width} ${RING}">${style}${groups.join('')}</svg>`,
214    width,
215    height: RING,
216  }
217}
218
types/index.d.ts 20 lines
1export type Sub = { id: string; name: string; tokens: number }
2
3export type Ring = {
4  id: string
5  name: string
6  cwd: string
7  tokens: number
8  isLive: boolean
9  isBusy: boolean
10  isCurrent: boolean
11  lastAt: number
12  subs: Sub[]
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'context-rings': { rings: Ring[]; folder: string }
18  }
19}
20