SLOPSHOPPER

context-bar

Live context window bar above the prompt, broken down by category like /context, with usage limits, session cost and a one-key handoff

newbandtoasttimer
v0.1.0MITupdated 2026-10-14iagodemacedo/context-bar
A shopper browsing a rack in a slop shop
README

context-bar

A Claude Code mod that draws a live card above the prompt: how full the context window is, what fills it, how much of your usage limits is left, and what the session has cost. It works in the terminal and in the desktop app's Code tab.

What it shows

  • Context: tokens used out of the window, the percent, and where auto-compact starts. Below that, a bar split by category (system prompt, tools, MCP tools, memory files, messages, free space, compaction buffer). Hover a segment to see its name and size.
  • Usage: your 5-hour and weekly limits, each with a meter, the percent used and the time until it resets, plus any other window your account reports. The last row is the session's cost estimated at API prices, the same figure /cost shows.
  • Handoff button (h): writes a handoff document of the conversation, clears it, and starts a fresh conversation from that document. If any step fails, nothing is cleared and a toast says why.

Install

Run this in a terminal:

claude plugin install context-bar --marketplace iagodemacedo/context-bar

It installs at the user scope, so the card appears in every new session, in the terminal and in the desktop app's Code tab. Start a new session after installing.

From inside a terminal session you can also type /plugin install context-bar --marketplace iagodemacedo/context-bar, answer y to add the marketplace, and press Enter to keep the user scope. The /plugin command is not available in the desktop app's Code tab, so install from a terminal once and the desktop picks it up.

Update and remove

claude plugin update context-bar
claude plugin uninstall context-bar

Notes

  • Built and tested on macOS with Claude Code 2.1.300. The handoff document is saved under /tmp.
  • Usage limits appear when your account reports them (Pro and Max plans). Otherwise the row says there is no reading yet.
  • Where an organization manages Claude Code, the hook that refreshes right after a compaction does not run. The card still refreshes every 15 seconds.

Develop

git clone https://github.com/iagodemacedo/context-bar && cd context-bar
claude --plugin-dir .

Saving a file reloads the mod in that session. Before a commit, run claude plugin validate . and claude plugin test ..

License

MIT

Source 2 files
hooks/register.tsx 516 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  ContextCategory,
4  EngineInterface,
5  Register,
6  SessionContextBreakdown,
7  SessionRateLimit,
8} from 'claude-code'
9
10import type { ContextSegment, ContextSnapshot, HandoffState, UsageLimit } from '../types'
11
12const snapshot = atom({ plugin: 'context-bar', key: 'snapshot' } as const, null)
13
14// Display names and colors per /context row. Unknown rows still show, lowercased,
15// with a fallback color, so a new category in a later build is never dropped.
16const LOOKS: Record<string, { label: string; hex: string }> = {
17  'System prompt': { label: 'system prompt', hex: '#6B9BD1' },
18  'System tools': { label: 'tools', hex: '#5EC4C9' },
19  'MCP tools': { label: 'mcp tools', hex: '#A78BFA' },
20  'Custom agents': { label: 'agents', hex: '#86C06C' },
21  'Memory files': { label: 'memory files', hex: '#E8C56A' },
22  Skills: { label: 'skills', hex: '#F4A6C0' },
23  Messages: { label: 'messages', hex: '#E0704F' },
24}
25const FALLBACK_HEX = ['#7FB3D5', '#C39BD3', '#F0B27A', '#76D7C4', '#F7DC6F']
26const TRACK_HEX = '#8B93A6'
27
28// Refresh cadence: events mark the snapshot stale, a 1s tick recomputes it.
29// The breakdown is a local estimate ('summary'), so a forced pass every 15s is cheap.
30const TICK_MS = 1000
31const FORCE_EVERY_TICKS = 15
32
33// Handoff: the button writes a handoff document with the mod's own skill
34// (skills/handoff), clears the conversation, then starts the fresh one from that
35// document. The run lives in module variables as well as $.state, since a
36// /clear starts a new session.
37const HANDOFF_COMMAND = 'context-bar:handoff'
38const HANDOFF_TIMEOUT_MS = 15 * 60_000
39const IDLE_HANDOFF: HandoffState = { phase: 'idle', path: null, startedAt: 0 }
40const handoff = atom({ plugin: 'context-bar', key: 'handoff' } as const, IDLE_HANDOFF)
41// Files a temp-dir handoff could be saved as, when the reply names another path.
42const TMP_MARKDOWN = /(?:\/private)?\/(?:tmp|var\/folders)\/[^\s`'"<>()[\]]+\.md/g
43
44let isTurnRunning = false
45let turnStartedAt = 0
46let lastTurn: { startedAt: number; answer: string; isOk: boolean } | null = null
47let isDriving = false
48
49let isDirty = true
50let burstTicks = 0
51let isFlushing = false
52let lastKey = ''
53
54type BarKind = ContextSegment['kind']
55const isInWindow = (c: ContextCategory): c is ContextCategory & { kind: BarKind } =>
56  c.kind !== 'deferred'
57
58// Account-wide windows first (they count every model), then any other window the
59// engine reports, so a per-model or spend window is shown rather than dropped.
60const LIMIT_ORDER = ['five_hour', 'seven_day']
61const limitLabel = (kind: string) =>
62  kind.replace('five_hour', '5h').replace('seven_day', 'weekly').replace('spend_limit', 'spend').replace(/_/g, ' ')
63
64function toLimits(limits: readonly SessionRateLimit[], now: number): UsageLimit[] {
65  const rank = (kind: string) => {
66    const i = LIMIT_ORDER.indexOf(kind)
67    return i === -1 ? LIMIT_ORDER.length : i
68  }
69  return [...limits]
70    .sort((a, b) => rank(a.kind) - rank(b.kind))
71    .map(l => {
72      const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
73      // Rounded to the minute so the snapshot, and the redraw, changes at most once a minute.
74      const resetsInMin = Number.isNaN(at) ? null : Math.max(0, Math.round((at - now) / 60_000))
75      return { kind: l.kind, label: limitLabel(l.kind), percent: l.percentUsed, resetsInMin }
76    })
77}
78
79function toSnapshot(b: SessionContextBreakdown, limits: UsageLimit[], costUsd: number | null): ContextSnapshot {
80  let unknown = 0
81  const segments = b.categories.filter(isInWindow).map((c): ContextSegment => {
82    const look = LOOKS[c.name]
83    if (c.kind === 'free') return { label: 'free', tokens: c.tokens, kind: c.kind, hex: TRACK_HEX, theme: c.color }
84    if (c.kind === 'buffer') return { label: 'buffer', tokens: c.tokens, kind: c.kind, hex: TRACK_HEX, theme: c.color }
85    const hex = look?.hex ?? FALLBACK_HEX[unknown++ % FALLBACK_HEX.length] ?? TRACK_HEX
86    return { label: look?.label ?? c.name.toLowerCase(), tokens: c.tokens, kind: c.kind, hex, theme: c.color }
87  })
88  const compactAt = b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : null
89
90  return { used: b.totalTokens, max: b.rawMaxTokens, compactAt, segments, limits, costUsd }
91}
92
93async function flush($: EngineInterface): Promise<void> {
94  if (isFlushing) {
95    isDirty = true
96    return
97  }
98  isFlushing = true
99  try {
100    const [{ context, rateLimits, cost }, now] = await Promise.all([
101      $.session.usage({ breakdown: 'summary' }),
102      $.clock.now(),
103    ])
104    if (!context.breakdown) {
105      // No session bound yet: try again on the next tick.
106      isDirty = true
107      return
108    }
109    const snap = toSnapshot(context.breakdown, toLimits(rateLimits, now), cost?.usd ?? null)
110    const key = JSON.stringify(snap)
111    if (key === lastKey) return
112    lastKey = key
113    await update($, snapshot, () => snap)
114  } catch {
115    // Left for the next forced pass rather than retried every tick.
116  } finally {
117    isFlushing = false
118  }
119}
120
121const handoffArgs = (path: string) =>
122  `Next session: continue this work where it left off. Save the handoff document at exactly ${path}, then reply with that path.`
123
124async function findHandoff($: EngineInterface, expected: string, answer: string, since: number) {
125  for (const path of [expected, ...(answer.match(TMP_MARKDOWN) ?? [])]) {
126    const stat = await $.fs.stat(path).catch(() => undefined)
127    // Written during this run (a minute of slack for clock skew), not an old one.
128    if (stat?.kind === 'file' && stat.mtimeMs >= since - 60_000) return path
129  }
130  return null
131}
132
133// The press only records the request; the next tick (within a second) runs it,
134// outside the press's own dispatch.
135async function requestHandoff($: EngineInterface): Promise<void> {
136  await update($, handoff, h => (h.phase === 'idle' ? { ...IDLE_HANDOFF, phase: 'requested' as const } : h))
137}
138
139// One step of the handoff run per call; the ticks call it until it is idle.
140// Nothing is cleared unless the document was saved and read back non-empty.
141async function driveHandoff($: EngineInterface): Promise<void> {
142  if (isDriving) return
143  isDriving = true
144  const stop = async (why: string) => {
145    await update($, handoff, () => IDLE_HANDOFF)
146    $.ui.toast(`Handoff stopped: ${why}`)
147  }
148  try {
149    const h = await read($, handoff)
150    if (h.phase === 'idle') return
151    const now = await $.clock.now()
152
153    if (h.phase === 'requested') {
154      // Pressed mid-turn: start once that turn has ended.
155      if (isTurnRunning) return
156      const commands = await $.command.list()
157      if (!commands.some(c => c.name === HANDOFF_COMMAND)) return await stop(`/${HANDOFF_COMMAND} is not available`)
158      const path = `/tmp/claude-handoff-${now}.md`
159      lastTurn = null
160      await update($, handoff, () => ({ phase: 'writing' as const, path, startedAt: now }))
161      $.command
162        .run({ command: HANDOFF_COMMAND, args: handoffArgs(path) })
163        .catch(() => stop('the handoff command did not run, nothing was cleared'))
164      return
165    }
166
167    if (h.phase === 'writing' && h.path !== null) {
168      if (now - h.startedAt > HANDOFF_TIMEOUT_MS) return await stop('no handoff after 15 minutes, nothing was cleared')
169      // Wait for the turn the skill started, not one that ran before it.
170      if (lastTurn === null || lastTurn.startedAt < h.startedAt) return
171      if (!lastTurn.isOk) return await stop('the handoff turn did not finish, nothing was cleared')
172      const path = await findHandoff($, h.path, lastTurn.answer, h.startedAt)
173      if (path === null) return await stop('no handoff file was saved, nothing was cleared')
174      const doc = await $.fs.read(path)
175      if (doc.trim() === '') return await stop('the handoff file is empty, nothing was cleared')
176
177      await update($, handoff, () => ({ ...h, phase: 'clearing' as const }))
178      await $.command.run({ command: 'clear' })
179      // The fresh conversation's state starts over: redraw the bar from scratch.
180      lastKey = ''
181      isDirty = true
182      // The document joins the fresh conversation as a row the model reads and
183      // the transcript does not show as typed; the prompt itself stays one line.
184      // Refused, the document rides in the prompt instead.
185      const stored = await $.session
186        .append({ message: { type: 'user', content: [{ type: 'text', text: `Handoff document:\n\n${doc}` }] } })
187        .then(
188          r => r.deny === undefined,
189          () => false,
190        )
191      const lead = `Continue the work from the handoff document (saved at ${path})`
192      await $.prompt.submit({ text: stored ? `${lead}, above.` : `${lead}:\n\n${doc}` })
193      await update($, handoff, () => IDLE_HANDOFF)
194      $.ui.toast('Handoff sent to a fresh conversation')
195    }
196  } catch {
197    await stop('an error came up').catch(() => undefined)
198  } finally {
199    isDriving = false
200  }
201}
202
203function fmt(n: number): string {
204  const trim = (s: string) => s.replace(/\.0$/, '')
205  if (n < 1000) return String(Math.round(n))
206  if (n < 10_000) return `${trim((n / 1000).toFixed(1))}k`
207  if (n < 999_500) return `${Math.round(n / 1000)}k`
208  return `${trim((n / 1_000_000).toFixed(1))}M`
209}
210
211function fmtUsd(usd: number): string {
212  if (usd > 0 && usd < 0.01) return 'under $0.01'
213  const [whole = '0', cents = '00'] = usd.toFixed(2).split('.')
214  return `$${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${cents}`
215}
216
217// What a segment's hover label says.
218function segmentLabel(s: ContextSegment, max: number): string {
219  if (s.kind === 'free') return `free ${fmt(s.tokens)}`
220  if (s.kind === 'buffer') return `compaction buffer ${fmt(s.tokens)}`
221  return `${s.label} ${fmt(s.tokens)} · ${pct(s.tokens, max)}%`
222}
223
224function fmtReset(min: number): string {
225  if (min < 1) return 'under 1m'
226  if (min < 60) return `${min}m`
227  if (min < 1440) return `${Math.floor(min / 60)}h ${min % 60}m`
228  return `${Math.floor(min / 1440)}d ${Math.floor((min % 1440) / 60)}h`
229}
230
231const levelHex = (percent: number) => (percent >= 80 ? '#E0704F' : percent >= 50 ? '#E8C56A' : '#86C06C')
232
233const METER_CELLS = 10
234const meterCells = (percent: number) =>
235  percent > 0 ? Math.min(METER_CELLS, Math.max(1, Math.round((percent / 100) * METER_CELLS))) : 0
236
237function meterSvg(percent: number): string {
238  const w = Math.min(100, Math.max(0, percent))
239  return (
240    `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="12" viewBox="0 0 100 6" preserveAspectRatio="none">` +
241    `<defs><clipPath id="m"><rect width="100" height="6" rx="3" ry="3"/></clipPath></defs>` +
242    `<g clip-path="url(#m)"><rect width="100" height="6" fill="${TRACK_HEX}" fill-opacity="0.22"/>` +
243    `<rect width="${w.toFixed(1)}" height="6" fill="${levelHex(percent)}"/></g></svg>`
244  )
245}
246
247// Any row holding tokens shows at least 1%, so a small row never reads as empty.
248const pct = (tokens: number, max: number) =>
249  tokens > 0 && max > 0 ? Math.max(1, Math.round((tokens / max) * 100)) : 0
250
251// Splits `width` cells across the rows by share, every non-empty row getting at
252// least one cell, the rounding leftovers going to the largest remainders.
253function allocate(segs: ContextSegment[], width: number): number[] {
254  const total = segs.reduce((sum, s) => sum + s.tokens, 0)
255  if (total <= 0 || width <= 0) return segs.map(() => 0)
256  const raw = segs.map(s => (s.tokens / total) * width)
257  const cells = raw.map((r, i) => ((segs[i]?.tokens ?? 0) > 0 ? Math.max(1, Math.floor(r)) : 0))
258  let left = width - cells.reduce((a, b) => a + b, 0)
259  const order = raw
260    .map((r, i) => ({ i, rem: r - Math.floor(r) }))
261    .filter(({ i }) => (segs[i]?.tokens ?? 0) > 0)
262    .sort((a, b) => b.rem - a.rem)
263  for (let k = 0; left > 0 && order.length > 0; k++) {
264    const slot = order[k % order.length]
265    if (slot) cells[slot.i] = (cells[slot.i] ?? 0) + 1
266    left -= 1
267  }
268  while (left < 0) {
269    const widest = cells.indexOf(Math.max(...cells))
270    cells[widest] = (cells[widest] ?? 1) - 1
271    left += 1
272  }
273  return cells
274}
275
276// Desktop fill for one bar segment: a solid strip stretched to the segment's
277// box. A plain image, so the pointer over it is the enclosing Box's hover.
278function segmentSvg(fill: string, opacity: number): string {
279  return (
280    `<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="20" viewBox="0 0 100 10" preserveAspectRatio="none">` +
281    `<rect width="100" height="10" fill="${fill}" fill-opacity="${opacity}"/></svg>`
282  )
283}
284
285export const register: Register = on => {
286  on('session.start', async ($, e, next) => {
287    const ran = await next(e)
288    isDirty = true
289    // A reload loses the run's module variables: start it over rather than hang.
290    await update($, handoff, () => IDLE_HANDOFF)
291    let ticks = 0
292    $.clock.every(TICK_MS, () => {
293      void driveHandoff($)
294      ticks += 1
295      if (burstTicks > 0) burstTicks -= 1
296      if (isDirty || burstTicks > 0 || ticks % FORCE_EVERY_TICKS === 0) {
297        isDirty = false
298        void flush($)
299      }
300    })
301
302    return ran
303  })
304
305  // Mid-turn growth. A model request starts once the prompt or the last tool
306  // results are in, and ends with the response: mark stale on both edges. These
307  // hooks only flip a flag, so they never gate the prompt, a tool or a compaction.
308  on('turn.step', async function* ($, e, next) {
309    if (!e.agentId) isDirty = true
310    const ran = yield* next(e)
311    if (!e.agentId) isDirty = true
312    return ran
313  })
314
315  // Turns, for the handoff run: whether one is running, and the main loop's last answer.
316  on('turn.start', async ($, e, next) => {
317    isTurnRunning = true
318    turnStartedAt = await $.clock.now()
319    return next(e)
320  }).catch(($, e, next) => next(e))
321
322  on('turn.complete', ($, e, next) => {
323    if (!e.agentId) {
324      isTurnRunning = false
325      lastTurn = { startedAt: turnStartedAt, answer: e.answer, isOk: e.reason === 'answer' && !e.isAborted }
326      isDirty = true
327    }
328    return next(e)
329  })
330
331  // A compaction runs for a while: refresh on every tick for the next 60s.
332  on('session.compact', ($, e, next) => {
333    burstTicks = 60
334    return next(e)
335  }).catch(($, e, next) => next(e))
336
337  on('session.end', ($, e, next) => {
338    if (e.reason === 'clear') {
339      burstTicks = 10
340      lastKey = ''
341    }
342    return next(e)
343  })
344
345  // After every main-thread turn the engine measures; refresh right away.
346  on('session.measure', async ($, e, next) => {
347    await flush($)
348    return next(e)
349  })
350
351  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
352    if (e.props.hasSurvey) return next(e)
353    const snap = await read($, snapshot)
354    if (snap === null || snap.max <= 0) return next(e)
355
356    const { Box, Text, Button } = $.ui.resolve(e)
357    const run = await read($, handoff)
358    // Card border (2) and padding (2) come off the band's width.
359    const inner = Math.max(20, e.props.bodyColumns - 4)
360    const compact = snap.compactAt === null ? 'auto-compact off' : `compacts at ${pct(snap.compactAt, snap.max)}%`
361
362    const header = (
363      <Box justifyContent="space-between">
364        <Text bold>context</Text>
365        <Text>
366          <Text bold>{fmt(snap.used)}</Text>
367          <Text dimColor>
368            {' '}/ {fmt(snap.max)} · {pct(snap.used, snap.max)}% · {compact}
369          </Text>
370        </Text>
371      </Box>
372    )
373
374    // The bar: one keyed Box per segment, sized in cells, each its own hover
375    // scope. Its label is plain text with no background, so it sits on the blank
376    // row under the bar (the usage section's top margin), where it covers no
377    // other text; hidden until the pointer is over the segment, shifted left
378    // where it would run past the bar's end.
379    const cells = allocate(snap.segments, inner)
380    const barOf = (fill: (s: ContextSegment, n: number, label: string) => JSX.Element) => {
381      let start = 0
382      return (
383        <Box>
384          {snap.segments.map((s, i) => {
385            const n = cells[i] ?? 0
386            const at = start
387            start += n
388            if (n <= 0) return null
389            const label = segmentLabel(s, snap.max)
390            const left = Math.max(-at, Math.min(0, inner - (at + label.length)))
391            return (
392              <Box key={`segment-${i}`} width={n}>
393                {fill(s, n, label)}
394                <Box position="absolute" top={1} left={left} display="none" hover={{ display: 'flex' }}>
395                  <Text>{label}</Text>
396                </Box>
397              </Box>
398            )
399          })}
400        </Box>
401      )
402    }
403
404    let bar
405    let meters
406    if (e.surface === 'desktop') {
407      const { Svg } = $.ui.resolve(e)
408      bar = barOf((s, _n, label) =>
409        s.kind === 'used' ? (
410          <Svg source={segmentSvg(s.hex, 1)} alt={label} height={10} />
411        ) : (
412          <Svg source={segmentSvg(TRACK_HEX, s.kind === 'buffer' ? 0.45 : 0.22)} alt={label} height={10} />
413        ),
414      )
415      meters = snap.limits.map(l => (
416        <Svg source={meterSvg(l.percent)} alt={`${l.label} limit ${l.percent}% used`} width={140} height={8} />
417      ))
418    } else {
419      bar = barOf((s, n) =>
420        s.kind === 'used' ? (
421          <Text color={s.hex}>{'█'.repeat(n)}</Text>
422        ) : (
423          <Text color={s.theme}>{(s.kind === 'buffer' ? '▒' : '░').repeat(n)}</Text>
424        ),
425      )
426      meters = snap.limits.map(l => {
427        const n = meterCells(l.percent)
428        return (
429          <Text>
430            <Text color={levelHex(l.percent)}>{'█'.repeat(n)}</Text>
431            <Text dimColor>{'░'.repeat(METER_CELLS - n)}</Text>
432          </Text>
433        )
434      })
435    }
436
437    // Usage as a small table: one row per window, then the session's spend.
438    const HEAD_W = 7
439    const limitW = Math.max(...snap.limits.map(l => l.label.length), 'session'.length) + 2
440    const heading = <Text bold>usage</Text>
441    const limitRows = snap.limits.map((l, i) => (
442      <Box alignItems="center">
443        <Box width={HEAD_W}>{i === 0 && heading}</Box>
444        <Box width={limitW}>
445          <Text dimColor>{l.label}</Text>
446        </Box>
447        {meters[i]}
448        <Box width={8} justifyContent="flex-end">
449          <Text bold>{l.percent}%</Text>
450        </Box>
451        {l.resetsInMin !== null && (
452          <Box marginLeft={3}>
453            <Text dimColor>resets in {fmtReset(l.resetsInMin)}</Text>
454          </Box>
455        )}
456      </Box>
457    ))
458    const noLimits = (
459      <Box>
460        <Box width={HEAD_W}>{heading}</Box>
461        <Box width={limitW}>
462          <Text dimColor>limits</Text>
463        </Box>
464        <Text dimColor>no reading yet</Text>
465      </Box>
466    )
467    const costRow = (
468      <Box>
469        <Box width={HEAD_W}>{null}</Box>
470        <Box width={limitW}>
471          <Text dimColor>session</Text>
472        </Box>
473        {snap.costUsd === null ? (
474          <Text dimColor>cost unavailable</Text>
475        ) : (
476          <Text>
477            <Text bold>{fmtUsd(snap.costUsd)}</Text>
478            <Text dimColor> estimated at API prices</Text>
479          </Text>
480        )}
481      </Box>
482    )
483    const usageTable = (
484      <Box flexDirection="column">
485        {snap.limits.length === 0 ? noLimits : limitRows}
486        {costRow}
487      </Box>
488    )
489    const status: Record<Exclude<HandoffState['phase'], 'idle'>, string> = {
490      requested: e.props.isWorking ? 'handoff queued, starts after this turn' : 'starting handoff…',
491      writing: 'writing handoff…',
492      clearing: 'clearing context…',
493    }
494    const handoffControl =
495      run.phase === 'idle' ? (
496        <Button key="handoff" label="handoff" hotkey="h" onPress={() => requestHandoff($)} />
497      ) : (
498        <Text dimColor>{status[run.phase]}</Text>
499      )
500    const usage = (
501      <Box marginTop={1} justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" columnGap={2}>
502        {usageTable}
503        {handoffControl}
504      </Box>
505    )
506
507    return (
508      <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} width={e.props.bodyColumns}>
509        {header}
510        {bar}
511        {usage}
512      </Box>
513    )
514  })
515}
516
types/index.d.ts 43 lines
1export type ContextSegment = {
2  label: string
3  tokens: number
4  kind: 'used' | 'free' | 'buffer'
5  hex: string
6  theme: string
7}
8
9export type UsageLimit = {
10  kind: string
11  label: string
12  percent: number
13  // Minutes until the window resets, rounded, as of the last refresh; null when unknown.
14  resetsInMin: number | null
15}
16
17export type ContextSnapshot = {
18  used: number
19  max: number
20  compactAt: number | null
21  segments: ContextSegment[]
22  limits: UsageLimit[]
23  // The session's spend as /cost totals it (API list prices unless the organization set its own); null when the host keeps no ledger.
24  costUsd: number | null
25}
26
27// The handoff button's run: requested (waits for a running turn to end), the
28// skill writing the document, then the conversation clearing.
29export type HandoffPhase = 'idle' | 'requested' | 'writing' | 'clearing'
30
31export type HandoffState = {
32  phase: HandoffPhase
33  // Where the skill was asked to save the document.
34  path: string | null
35  startedAt: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    'context-bar': { snapshot: ContextSnapshot | null; handoff: HandoffState }
41  }
42}
43