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

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.
/compact. The ring drops to the compacted size right away, before the next reply reports the real one.On the terminal the rings are drawn as ○ ◔ ◑ ◕ ●.
At the prompt of a terminal session:
/plugin install context-rings --marketplace nvsravank/context-rings
Each is a row in the mod's settings in /config.
| Setting | Default | Choices |
|---|---|---|
| Session context window | 1M | 100K to 1M |
| Session ring turns yellow at | 50% | 30% to 90% |
| Session ring turns red at | 80% | 50% to 95% |
| Subagent limit | 200K | 100K to 1M |
| Subagent dot appears (yellow) at | 75% | 30% to 90% |
| Subagent dot turns red (and toasts) at | 90% | 50% to 95% |
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:
| What | Why |
|---|---|
~/.claude/sessions/*.json | Which 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.json | Session titles and subagent descriptions |
What it runs (read-only, no shell except the first):
| Command | Why |
|---|---|
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.
MIT
hooks/register.tsx 306 lines1import { 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}
306hooks/parse.ts 218 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
218types/index.d.ts 20 lines1export 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