SLOPSHOPPER

context-meter

Context-window meter for every running Claude Code session, with Compact now, auto-compact and options

newpanebandcommandprocesstimer
v0.2.0MITupdated 2026-10-07sp1e/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
│ ┃ context-meter ✕ › fix the failing auth test and add an audit log call │ ┃ [Compact] visas från 50% │ ┃ ⏺ Read(src/auth.ts) │ ┃ Inga aktiva sessioner just nu. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /context-meter │ ⎿ context-meter: Context-panelen dold. │ │ Context – + × [ Compact now ] ☐ Auto-compact 50% ⚙ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context – + × [ Compact now ] ☐ Auto-compact 50% ⚙ Options
Pane · context-meter
[Compact] visas från 50% Inga aktiva sessioner just nu.
README

context-meter

A Claude Code mod (function-hook plugin) that shows how full the context window is, for this session and for every other session that is actively working.

The UI text is in Swedish.

What it draws

A band above the prompt, per session:

  • Context 15% +: this session's fill, colored green, yellow or red. + opens the comparison pane, − closes it.
  • × hides the band in this session. /context-meter brings it back.
  • Compact now asks the session to write its own /compact summary, then compacts with it.
  • ☐ Auto-compact 50% does the same by itself once the session reaches the threshold. Per session.
  • ⚙ Options opens the settings, shared by all sessions.

The comparison pane lists every session whose transcript was written recently, sorted by fill, with a bar, tokens / window and folder. A [Compact] button appears on a row at or above the threshold. Rows marked ≈ are sessions that do not run the mod. Their fill is estimated from the transcript.

Options

OptionDefault
Auto-compact at50 %
Auto-compact on in new sessionsNo
[Compact] in the pane from50 %
Yellow from / red above50 % / 75 %
Show sessions active within10 min
Show sessions without the mod (≈)Yes
Open the comparison pane at startYes

How compaction works

A session can only compact itself, so a request goes through a file:

  1. The button writes ~/.claude/context-meter/requests/<session id>.json.
  2. The target session picks it up on its next tick (every 15 s) and submits a prompt starting with Pausa dev här och skriv en bra /compact.
  3. When that turn completes, its answer becomes the instructions for $.session.compact, retried up to five times while the session is busy.

Files it writes

All under ~/.claude/context-meter/:

  • <session id>.json: one heartbeat per session, every 15 s.
  • requests/: compaction requests and their state.
  • prefs.json: the options.

Install

Clone it anywhere and point Claude Code at the folder. For every session, set this in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:\\path\\to\\context-meter" } }

A session loads it at start. The estimated rows need python on PATH (scripts/scan.py).

.claude-plugin/types/ holds the Claude Code type definitions. Claude Code generates them locally and they are not committed.

claude plugin validate .

License

MIT, see LICENSE.

Source 2 files
hooks/register.tsx 644 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CompactRequest, CompactState, ContextBeat, MeterPrefs } from '../types'
5
6const PANE = 'context-meter'
7const BEAT_MS = 15_000
8// A session whose heartbeat is older than this has quit or crashed.
9const STALE_MS = 90_000
10// The scan, the activity filter (prefs.activeMinutes), reruns this often.
11const SCAN_MS = 60_000
12
13const DEFAULT_PREFS: MeterPrefs = {
14  isHidden: false,
15  autoAt: 50,
16  buttonAt: 50,
17  yellowAt: 50,
18  redAt: 75,
19  activeMinutes: 10,
20  showEstimated: true,
21  autoInNewSessions: false,
22}
23
24// What the options' pickers offer; a stored value outside them is added.
25const COMPACT_STEPS = [30, 40, 50, 60, 70, 80, 90]
26const YELLOW_STEPS = [30, 40, 50, 60, 70, 80]
27const RED_STEPS = [50, 60, 70, 75, 80, 90, 95]
28const MINUTE_STEPS = [5, 10, 30, 60, 240]
29
30const SUMMARY_MARKER = 'Pausa dev här och skriv en bra /compact'
31const SUMMARY_PROMPT =
32  `${SUMMARY_MARKER} för den här sessionen: vad som är gjort, vad som återstår, ` +
33  'fattade beslut, viktiga filsökvägar och nästa steg. ' +
34  'Svara ENDAST med texten som ska stå efter /compact, inget annat.'
35
36const STATE_TEXT: Record<CompactState, string> = {
37  requested: 'compact begärd',
38  summarizing: 'skriver sammanfattning',
39  compacting: 'kompakterar',
40  done: 'kompakterad',
41  failed: 'compact misslyckades',
42}
43
44const beats = atom({ plugin: 'context-meter', key: 'beats' } as const, [])
45const scanned = atom({ plugin: 'context-meter', key: 'scanned' } as const, [])
46const selfId = atom({ plugin: 'context-meter', key: 'selfId' } as const, '')
47const requests = atom({ plugin: 'context-meter', key: 'requests' } as const, {})
48const isAwaitingSummary = atom({ plugin: 'context-meter', key: 'isAwaitingSummary' } as const, false)
49const isPaneOpen = atom({ plugin: 'context-meter', key: 'isPaneOpen' } as const, false)
50// Both per session: the band's close mark, and the auto-compact box.
51const isBandHidden = atom({ plugin: 'context-meter', key: 'isBandHidden' } as const, false)
52const isAutoCompact = atom({ plugin: 'context-meter', key: 'isAutoCompact' } as const, false)
53const isOptionsOpen = atom({ plugin: 'context-meter', key: 'isOptionsOpen' } as const, false)
54const prefs = atom({ plugin: 'context-meter', key: 'prefs' } as const, DEFAULT_PREFS)
55// After a failed or finished compaction, auto-compact waits this long.
56const AUTO_PAUSE_MS = 10 * 60_000
57
58// Green below yellowAt, yellow up to and including redAt, red above.
59function colorFor(percent: number, p: MeterPrefs): string | undefined {
60  if (percent < p.yellowAt) return 'green'
61  if (percent <= p.redAt) return 'yellow'
62  return 'red'
63}
64
65const numberOr = (value: unknown, fallback: number) =>
66  typeof value === 'number' && Number.isFinite(value) ? value : fallback
67
68const booleanOr = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
69
70// A hand-edited or older prefs.json falls back field by field.
71function toPrefs(raw: unknown): MeterPrefs {
72  const p = (raw !== null && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
73  const d = DEFAULT_PREFS
74  return {
75    isHidden: booleanOr(p.isHidden, d.isHidden),
76    autoAt: numberOr(p.autoAt, d.autoAt),
77    buttonAt: numberOr(p.buttonAt, d.buttonAt),
78    yellowAt: numberOr(p.yellowAt, d.yellowAt),
79    redAt: numberOr(p.redAt, d.redAt),
80    activeMinutes: numberOr(p.activeMinutes, d.activeMinutes),
81    showEstimated: booleanOr(p.showEstimated, d.showEstimated),
82    autoInNewSessions: booleanOr(p.autoInNewSessions, d.autoInNewSessions),
83  }
84}
85
86// Yellow stays below red: the field just picked wins, the other gives way.
87function ordered(p: MeterPrefs, isRedPicked: boolean): MeterPrefs {
88  if (p.yellowAt < p.redAt) return p
89  return isRedPicked ? { ...p, yellowAt: p.redAt - 5 } : { ...p, redAt: p.yellowAt + 5 }
90}
91
92function percentOptions(steps: readonly number[], current: number, unit = '%') {
93  return [...new Set([...steps, current])]
94    .sort((a, b) => a - b)
95    .map(n => ({ value: String(n), label: `${n}${unit}` }))
96}
97
98const YES_NO = [
99  { value: 'yes', label: 'Ja' },
100  { value: 'no', label: 'Nej' },
101]
102const yesNo = (isOn: boolean) => (isOn ? 'yes' : 'no')
103
104const kilo = (n: number) =>
105  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
106
107const folderName = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
108
109const isBusy = (r: CompactRequest | undefined) =>
110  r !== undefined && (r.state === 'requested' || r.state === 'summarizing' || r.state === 'compacting')
111
112async function homeDir($: EngineInterface): Promise<string> {
113  return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
114}
115
116async function heartbeatDir($: EngineInterface): Promise<string> {
117  return `${await homeDir($)}/.claude/context-meter`
118}
119
120// Module variables start over on a reload; the label is cheap to rebuild.
121let label = ''
122
123async function labelFor($: EngineInterface, cwd: string): Promise<string> {
124  if (label) return label
125  const messages = await $.session.messages()
126  const first = messages.find(m => m.role === 'user' && m.text.trim() !== '')
127  const prompt = first?.text.replace(/\s+/g, ' ').trim().slice(0, 48) ?? ''
128  if (prompt) label = prompt
129  return prompt || folderName(cwd)
130}
131
132async function beat($: EngineInterface, isEnded = false): Promise<void> {
133  const [id, cwd, model, usage, now] = await Promise.all([
134    $.session.id(),
135    $.session.cwd(),
136    $.session.model(),
137    $.session.usage(),
138    $.clock.now(),
139  ])
140  const entry: ContextBeat = {
141    id,
142    label: await labelFor($, cwd),
143    cwd,
144    model,
145    tokens: usage.context.tokens ?? 0,
146    window: usage.context.window,
147    percent: usage.context.percent ?? 0,
148    updatedAt: now,
149    isEnded,
150  }
151  await $.fs.write(`${await heartbeatDir($)}/${id}.json`, JSON.stringify(entry))
152}
153
154async function readJsonDir<T>($: EngineInterface, dir: string, maxAgeMs: number): Promise<Array<[string, T]>> {
155  const now = await $.clock.now()
156  const files = (await $.fs.list(dir).catch(() => [])).filter(
157    f => f.kind === 'file' && f.name.endsWith('.json') && now - f.mtimeMs < maxAgeMs,
158  )
159  const parsed = await Promise.all(
160    files.map(f =>
161      $.fs
162        .read(`${dir}/${f.name}`)
163        .then((text): [string, T] => [f.name.slice(0, -'.json'.length), JSON.parse(text) as T])
164        .catch(() => undefined),
165    ),
166  )
167  return parsed.filter((p): p is [string, T] => p !== undefined)
168}
169
170async function refresh($: EngineInterface): Promise<void> {
171  const dir = await heartbeatDir($)
172  const now = await $.clock.now()
173  const live = (await readJsonDir<ContextBeat>($, dir, STALE_MS))
174    .map(([, b]) => b)
175    .filter(b => !b.isEnded && now - b.updatedAt < STALE_MS)
176  await update($, beats, () => live)
177  const asked = await readJsonDir<CompactRequest>($, `${dir}/requests`, 24 * 3_600_000)
178  await update($, requests, () => Object.fromEntries(asked))
179}
180
181type ScanRow = { id: string; label: string; cwd: string; model: string; tokens: number; updatedAt: number }
182
183// A transcript does not say the model's window: take this session's own,
184// and 1M once a session is past 200k.
185async function scan($: EngineInterface): Promise<void> {
186  const home = await homeDir($)
187  const activeSeconds = (await read($, prefs)).activeMinutes * 60
188  const [run, usage] = await Promise.all([
189    $.process.run(
190      ['python', `${$.plugin.root}/scripts/scan.py`, `${home}/.claude/projects`, String(activeSeconds)],
191      { timeoutMs: 60_000 },
192    ),
193    $.session.usage(),
194  ])
195  if (run.exitCode !== 0) return
196  const rows = JSON.parse(run.stdout) as ScanRow[]
197  const estimated = rows.map((r): ContextBeat => {
198    const window = r.tokens > 200_000 ? Math.max(1_000_000, usage.context.window) : usage.context.window
199    return {
200      ...r,
201      window,
202      percent: Math.round((r.tokens / window) * 100),
203      isEnded: false,
204      isEstimated: true,
205    }
206  })
207  await update($, scanned, () => estimated)
208}
209
210// The scan is the activity filter: a session shows only while its transcript
211// was written within prefs.activeMinutes (this session always). Heartbeats are
212// exact and win; the transcript's title (the name the app shows) labels both.
213function merge(exact: ContextBeat[], estimated: ContextBeat[], self: string, showEstimated = true): ContextBeat[] {
214  const active = new Map(estimated.map(b => [b.id, b]))
215  const exactIds = new Set(exact.map(b => b.id))
216  const estimatedOnly = showEstimated
217    ? estimated.filter(b => !exactIds.has(b.id)).map(b => ({ ...b, label: b.label || folderName(b.cwd) }))
218    : []
219  return [
220    ...exact
221      .filter(b => b.id === self || active.has(b.id))
222      .map(b => ({ ...b, label: active.get(b.id)?.label || b.label })),
223    ...estimatedOnly,
224  ].sort((a, b) => b.percent - a.percent)
225}
226
227async function writeRequest($: EngineInterface, id: string, state: CompactState, note?: string): Promise<void> {
228  const request: CompactRequest = { state, at: await $.clock.now(), ...(note ? { note } : {}) }
229  await $.fs.write(`${await heartbeatDir($)}/requests/${id}.json`, JSON.stringify(request))
230  await update($, requests, all => ({ ...all, [id]: request }))
231}
232
233// Step 1, in the target session: a requested compaction queues the summary
234// prompt, which the engine runs once the session is idle.
235async function takeRequest($: EngineInterface): Promise<void> {
236  const id = await $.session.id()
237  const request = (await read($, requests))[id]
238  if (request?.state !== 'requested' || (await read($, isAwaitingSummary))) return
239  await update($, isAwaitingSummary, () => true)
240  await writeRequest($, id, 'summarizing')
241  void $.prompt.submit({ text: SUMMARY_PROMPT })
242}
243
244// A session may answer as a pasteable block: ```\n/compact text\n```. Only the
245// text is the instruction.
246function toInstructions(answer: string): string {
247  return answer
248    .trim()
249    .replace(/^```[a-z]*\s*\n?/i, '')
250    .replace(/\n?```\s*$/, '')
251    .trim()
252    .replace(/^\/compact\s+/, '')
253    .trim()
254}
255
256const errorText = (error: unknown) =>
257  (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 160)
258
259// Step 2: compact with the summary as instructions. Compaction is refused while
260// a turn runs, so it is retried a few times; the last try goes through the
261// /compact command instead, and a failure keeps the engine's own words.
262async function compactWith($: EngineInterface, instructions: string, attempt: number): Promise<void> {
263  const id = await $.session.id()
264  try {
265    const result = await $.session.compact({ instructions })
266    const isSkipped = 'skip' in result && result.skip !== undefined
267    await writeRequest($, id, isSkipped ? 'failed' : 'done', isSkipped ? 'avbruten av en hook' : undefined)
268    await beat($).catch(() => undefined)
269  } catch (error) {
270    if (attempt < 5) {
271      await writeRequest($, id, 'compacting', `försök ${attempt} avvisat: ${errorText(error)}`)
272      $.clock.after(10_000, () => void compactWith($, instructions, attempt + 1))
273      return
274    }
275    try {
276      await $.command.run({ command: 'compact', args: instructions })
277      await writeRequest($, id, 'done', 'via /compact-kommandot')
278      await beat($).catch(() => undefined)
279    } catch (fallback) {
280      // Last resort: paste `/compact <text>` as if typed; it runs once idle.
281      try {
282        await $.prompt.submit({ text: `/compact ${instructions}`, asUser: true })
283        await writeRequest($, id, 'compacting', 'inklistrat som /compact, körs när sessionen är ledig')
284      } catch (pasted) {
285        const notes = [errorText(error), `/compact: ${errorText(fallback)}`, `inklistring: ${errorText(pasted)}`]
286        await writeRequest($, id, 'failed', notes.join(' | '))
287      }
288    }
289  }
290}
291
292// Read on every tick, so a change made in one session reaches the others.
293async function loadPrefs($: EngineInterface): Promise<MeterPrefs> {
294  const text = await $.fs.read(`${await heartbeatDir($)}/prefs.json`).catch(() => '')
295  let raw: unknown = {}
296  try {
297    raw = JSON.parse(text)
298  } catch {
299    // Missing or broken: the defaults stand.
300  }
301  const loaded = toPrefs(raw)
302  await update($, prefs, () => loaded)
303  return loaded
304}
305
306async function savePrefs($: EngineInterface, patch: Partial<MeterPrefs>): Promise<void> {
307  // Re-read first: another session may have changed another option meanwhile.
308  const current = await loadPrefs($).catch(() => read($, prefs))
309  const next = ordered({ ...current, ...patch }, patch.redAt !== undefined)
310  await update($, prefs, () => next)
311  await $.fs.write(`${await heartbeatDir($)}/prefs.json`, JSON.stringify(next, null, 2)).catch(() => undefined)
312}
313
314// The last choice is also where the next session starts.
315async function setPane($: EngineInterface, isOpen: boolean): Promise<void> {
316  if (isOpen) {
317    await $.ui.open({ id: PANE, title: 'Context' })
318  } else {
319    await $.ui.close({ id: PANE })
320  }
321  await update($, isPaneOpen, () => isOpen)
322  await savePrefs($, { isHidden: !isOpen })
323}
324
325// Compact now: request this session's compaction and take it at once.
326async function compactSelf($: EngineInterface): Promise<void> {
327  const id = await $.session.id()
328  if (isBusy((await read($, requests))[id])) return
329  await writeRequest($, id, 'requested')
330  await takeRequest($)
331}
332
333// Auto-compact: a session past prefs.autoAt asks for its own compaction, once;
334// a failure or a fresh compaction pauses it for AUTO_PAUSE_MS.
335async function autoCompact($: EngineInterface): Promise<void> {
336  if (!(await read($, isAutoCompact))) return
337  const [id, usage, now, p] = await Promise.all([$.session.id(), $.session.usage(), $.clock.now(), read($, prefs)])
338  if ((usage.context.percent ?? 0) < p.autoAt) return
339  const request = (await read($, requests))[id]
340  if (isBusy(request)) return
341  if (request && (request.state === 'failed' || request.state === 'done') && now - request.at < AUTO_PAUSE_MS) return
342  await writeRequest($, id, 'requested', 'auto-compact')
343}
344
345async function tick($: EngineInterface): Promise<void> {
346  await loadPrefs($).catch(() => undefined)
347  await beat($).catch(() => undefined)
348  await refresh($).catch(() => undefined)
349  await autoCompact($).catch(() => undefined)
350  await takeRequest($).catch(() => undefined)
351}
352
353export const register: Register = on => {
354  on('session.start', async ($, e, next) => {
355    await update($, selfId, () => '')
356    await $.command.register({
357      name: 'context-meter',
358      description: 'Visa Context-raden igen, eller visa/dölj jämförelsepanelen',
359    })
360    void $.session.id().then(id => update($, selfId, () => id))
361    void tick($)
362    $.clock.every(BEAT_MS, () => void tick($))
363    void scan($).catch(() => undefined)
364    $.clock.every(SCAN_MS, () => void scan($).catch(() => undefined))
365    void loadPrefs($).then(async loaded => {
366      await update($, isAutoCompact, () => loaded.autoInNewSessions)
367      if (loaded.isHidden) return
368      await $.ui.open({ id: PANE, title: 'Context' })
369      await update($, isPaneOpen, () => true)
370    })
371
372    return next(e)
373  })
374
375  on('command.run', { command: 'context-meter' }, async $ => {
376    await tick($)
377    // Brings a closed band back; otherwise toggles the comparison pane.
378    if (await read($, isBandHidden)) {
379      await update($, isBandHidden, () => false)
380      return { text: 'Context-raden visas igen.' }
381    }
382    const isOpen = !(await read($, isPaneOpen))
383    await setPane($, isOpen)
384
385    return { text: isOpen ? 'Context-panelen visas.' : 'Context-panelen dold.' }
386  })
387
388  // The pane's own close mark counts as hiding it.
389  on('ui.close', async ($, e, next) => {
390    const result = await next(e)
391    if (e.id === PANE) {
392      await update($, isPaneOpen, () => false).catch(() => undefined)
393      if (e.origin.kind === 'person') await savePrefs($, { isHidden: true }).catch(() => undefined)
394    }
395
396    return result
397  })
398
399  // The band above the prompt: this session's fill, + for the comparison pane,
400  // a close mark, and the compaction controls.
401  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402    if (e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
403    const table = $.ui.resolve(e)
404    // The band is drawn on the terminal and desktop only, both with a Select.
405    if (!('Select' in table)) return next(e)
406    const { Box, Button, Select, Text } = table
407    const self = await read($, selfId)
408    const own = merge(await read($, beats), await read($, scanned), self).find(b => b.id === self)
409    const isOpen = await read($, isPaneOpen)
410    const isAuto = await read($, isAutoCompact)
411    const isOptions = await read($, isOptionsOpen)
412    const p = await read($, prefs)
413    const request = (await read($, requests))[self]
414
415    return (
416      <Box flexDirection="column">
417        <Box flexDirection="row" justifyContent="space-between">
418          <Box flexDirection="row" gap={1}>
419            <Text dimColor>
420              Context {own ? <Text color={colorFor(own.percent, p)}>{own.percent}%</Text> : '–'}
421            </Text>
422            <Button
423              key="context-meter-compare"
424              plain
425              label={isOpen ? '−' : '+'}
426              onPress={() => setPane($, !isOpen)}
427            />
428          </Box>
429          <Button
430            key="context-meter-close"
431            role="dismiss"
432            plain
433            label="×"
434            onPress={() => update($, isBandHidden, () => true)}
435          />
436        </Box>
437        <Box flexDirection="row" gap={2}>
438          <Button
439            key="context-meter-compact"
440            label={request && isBusy(request) ? STATE_TEXT[request.state] : 'Compact now'}
441            onPress={() => compactSelf($)}
442          />
443          <Button
444            key="context-meter-auto"
445            plain
446            label={`${isAuto ? '☑' : '☐'} Auto-compact ${p.autoAt}%`}
447            onPress={() => update($, isAutoCompact, was => !was)}
448          />
449          <Button
450            key="context-meter-options"
451            plain
452            dimColor={!isOptions}
453            label="⚙ Options"
454            onPress={() => update($, isOptionsOpen, was => !was)}
455          />
456        </Box>
457        {request && !isBusy(request) && (
458          <Text dimColor wrap="truncate-end">
459            {STATE_TEXT[request.state]}
460            {request.note ? ` – ${request.note}` : ''}
461          </Text>
462        )}
463        {isOptions && (
464          <Box flexDirection="column" marginTop={1}>
465            <Text dimColor>Options – gäller alla sessioner (bockrutan gäller bara den här)</Text>
466            <Select
467              key="context-meter-opt-auto-at"
468              label="Auto-compact vid "
469              options={percentOptions(COMPACT_STEPS, p.autoAt)}
470              value={String(p.autoAt)}
471              onSelect={v => void savePrefs($, { autoAt: Number(v) })}
472            />
473            <Select
474              key="context-meter-opt-auto-new"
475              label="Auto-compact påslaget i nya sessioner "
476              options={YES_NO}
477              value={yesNo(p.autoInNewSessions)}
478              onSelect={v => void savePrefs($, { autoInNewSessions: v === 'yes' })}
479            />
480            <Select
481              key="context-meter-opt-button-at"
482              label="[Compact]-knapp i panelen från "
483              options={percentOptions(COMPACT_STEPS, p.buttonAt)}
484              value={String(p.buttonAt)}
485              onSelect={v => void savePrefs($, { buttonAt: Number(v) })}
486            />
487            <Select
488              key="context-meter-opt-yellow"
489              label="Gul från "
490              options={percentOptions(YELLOW_STEPS, p.yellowAt)}
491              value={String(p.yellowAt)}
492              onSelect={v => void savePrefs($, { yellowAt: Number(v) })}
493            />
494            <Select
495              key="context-meter-opt-red"
496              label="Röd över "
497              options={percentOptions(RED_STEPS, p.redAt)}
498              value={String(p.redAt)}
499              onSelect={v => void savePrefs($, { redAt: Number(v) })}
500            />
501            <Select
502              key="context-meter-opt-active"
503              label="Visa sessioner aktiva senaste "
504              options={percentOptions(MINUTE_STEPS, p.activeMinutes, ' min')}
505              value={String(p.activeMinutes)}
506              onSelect={v => void savePrefs($, { activeMinutes: Number(v) }).then(() => scan($))}
507            />
508            <Select
509              key="context-meter-opt-estimated"
510              label="Visa sessioner utan moddet (≈) "
511              options={YES_NO}
512              value={yesNo(p.showEstimated)}
513              onSelect={v => void savePrefs($, { showEstimated: v === 'yes' })}
514            />
515            <Select
516              key="context-meter-opt-pane"
517              label="Öppna jämförelsepanelen vid start "
518              options={YES_NO}
519              value={yesNo(!p.isHidden)}
520              onSelect={v => void savePrefs($, { isHidden: v === 'no' })}
521            />
522            <Box flexDirection="row" gap={2}>
523              <Button
524                key="context-meter-opt-reset"
525                label="Återställ standard"
526                onPress={() => savePrefs($, { ...DEFAULT_PREFS, isHidden: p.isHidden })}
527              />
528              <Button
529                key="context-meter-opt-done"
530                label="Klar"
531                onPress={() => update($, isOptionsOpen, () => false)}
532              />
533            </Box>
534          </Box>
535        )}
536      </Box>
537    )
538  })
539
540  on('turn.complete', async ($, e, next) => {
541    const result = await next(e)
542    if (e.agentId === undefined && (await read($, isAwaitingSummary))) {
543      const messages = await $.session.messages()
544      const lastPrompt = [...messages].reverse().find(m => m.role === 'user' && m.text.trim() !== '')
545      if (lastPrompt?.text.includes(SUMMARY_MARKER)) {
546        await update($, isAwaitingSummary, () => false)
547        const id = await $.session.id()
548        const summary = toInstructions(e.answer)
549        if (e.isAborted || summary === '') {
550          await writeRequest($, id, 'failed', e.isAborted ? 'sammanfattningen avbröts' : 'tom sammanfattning')
551        } else {
552          await writeRequest($, id, 'compacting')
553          $.clock.after(2_000, () => void compactWith($, summary, 1))
554        }
555      }
556    }
557    await tick($)
558
559    return result
560  })
561
562  // However the compaction ran (call, command or pasted /compact), a request
563  // waiting on it is done once it stands.
564  on('session.compact', async ($, e, next) => {
565    const result = await next(e)
566    const isSkipped = 'skip' in result && result.skip !== undefined
567    const id = await $.session.id()
568    const request = (await read($, requests))[id]
569    if (!isSkipped && request?.state === 'compacting') {
570      await writeRequest($, id, 'done').catch(() => undefined)
571      $.clock.after(2_000, () => void beat($).catch(() => undefined))
572    }
573
574    return result
575  })
576
577  on('session.end', async ($, e, next) => {
578    await beat($, true).catch(() => undefined)
579
580    return next(e)
581  })
582
583  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
584    const { Box, Button, Text } = $.ui.resolve(e)
585    const self = await read($, selfId)
586    const p = await read($, prefs)
587    const list = merge(await read($, beats), await read($, scanned), self, p.showEstimated)
588    const top = list[0]
589    const asked = await read($, requests)
590    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 40
591    // Narrower than the pane: a desktop draws wider glyphs than it reports.
592    const barWidth = Math.max(10, Math.min(60, columns - 12))
593
594    return (
595      <Box flexDirection="column">
596        <Box marginBottom={1}>
597          <Text dimColor wrap="truncate-end">
598            [Compact] visas från {p.buttonAt}%
599            {top ? ` · högst nu ${top.percent}%` : ''}
600          </Text>
601        </Box>
602        {list.length === 0 && <Text dimColor>Inga aktiva sessioner just nu.</Text>}
603        {list.map(b => {
604          const filled = Math.round((b.percent / 100) * barWidth)
605          const request = asked[b.id]
606          const canCompact = !b.isEstimated && b.percent >= p.buttonAt && !isBusy(request)
607          return (
608            <Box key={b.id} flexDirection="column" marginBottom={1}>
609              <Text bold={b.id === self} dimColor={b.isEstimated} wrap="truncate-end">
610                {b.id === self ? '● ' : '  '}
611                {b.label}
612              </Text>
613              <Text color={colorFor(b.percent, p)} wrap="truncate-end">
614                {'█'.repeat(filled)}
615                <Text dimColor>{'░'.repeat(barWidth - filled)}</Text>
616              </Text>
617              <Text dimColor wrap="truncate-end">
618                {b.isEstimated ? '≈' : ''}
619                {b.percent}% · {kilo(b.tokens)} / {kilo(b.window)} · {folderName(b.cwd)}
620              </Text>
621              {request && (
622                <Text dimColor wrap="truncate-end">
623                  {STATE_TEXT[request.state]}
624                  {request.note ? ` – ${request.note}` : ''}
625                </Text>
626              )}
627              {canCompact && (
628                <Button
629                  key={`compact-${b.id}`}
630                  label="Compact"
631                  onPress={() => writeRequest($, b.id, 'requested')}
632                />
633              )}
634              {b.isEstimated && b.percent >= p.buttonAt && (
635                <Text dimColor>starta om sessionen för compact</Text>
636              )}
637            </Box>
638          )
639        })}
640      </Box>
641    )
642  })
643}
644
types/index.d.ts 60 lines
1/** One session's context fill: from its own heartbeat, or estimated from its transcript. */
2export type ContextBeat = {
3  id: string
4  label: string
5  cwd: string
6  model: string
7  tokens: number
8  window: number
9  percent: number
10  updatedAt: number
11  isEnded: boolean
12  isEstimated?: boolean
13}
14
15/** Where a requested compaction of one session stands. */
16export type CompactState = 'requested' | 'summarizing' | 'compacting' | 'done' | 'failed'
17
18/** A compaction the pane asked for, kept in requests/<session id>.json. */
19export type CompactRequest = {
20  state: CompactState
21  at: number
22  note?: string
23}
24
25/** The options, shared by every session, in prefs.json. */
26export type MeterPrefs = {
27  /** The comparison pane starts closed. */
28  isHidden: boolean
29  /** Auto-compact fires at this fill. */
30  autoAt: number
31  /** The pane's per-session [Compact] shows from this fill. */
32  buttonAt: number
33  /** Below this fill green; up to and including redAt yellow; above, red. */
34  yellowAt: number
35  redAt: number
36  /** Sessions whose transcript was written within this window are listed. */
37  activeMinutes: number
38  /** List sessions that do not run the mod, estimated from their transcript. */
39  showEstimated: boolean
40  /** A new session starts with its auto-compact box ticked. */
41  autoInNewSessions: boolean
42}
43
44declare module 'claude-code' {
45  interface PluginState {
46    'context-meter': {
47      beats: ContextBeat[]
48      scanned: ContextBeat[]
49      selfId: string
50      requests: Record<string, CompactRequest>
51      isAwaitingSummary: boolean
52      isPaneOpen: boolean
53      isBandHidden: boolean
54      isAutoCompact: boolean
55      isOptionsOpen: boolean
56      prefs: MeterPrefs
57    }
58  }
59}
60