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

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.
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 summary, then compacts with it.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.
| Option | Default |
|---|---|
| Auto-compact at | 50 % |
| Auto-compact on in new sessions | No |
[Compact] in the pane from | 50 % |
| Yellow from / red above | 50 % / 75 % |
| Show sessions active within | 10 min |
| Show sessions without the mod (≈) | Yes |
| Open the comparison pane at start | Yes |
A session can only compact itself, so a request goes through a file:
~/.claude/context-meter/requests/<session id>.json.Pausa dev här och skriv en bra /compact.$.session.compact, retried up to five times while the session is busy.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.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 .
MIT, see LICENSE.
hooks/register.tsx 644 lines1import { 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}
644types/index.d.ts 60 lines1/** 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