Leiste über dem Eingabefeld: Prompt-Cache-Restzeit, Cache-Hit-Rate, Kontext-Wetter und Compact-Knopf

Schmale Leiste über dem Eingabefeld von Claude Code, von links nach rechts:
~83 %, solange sie nicht lesbar ist)./compact eingereiht und startet, sobald Claude fertig ist.Nach /compact oder dem Knopf steht im Chat eine Zeile mit den Tokens vorher und nachher und darunter die Zusammenfassung, mit der Claude weitermacht. Das sind Log-Zeilen, die das Modell nicht liest; lange Zusammenfassungen werden an Absätzen geteilt.
Im Terminal, direkt aus GitHub:
claude plugin marketplace add lechef312/claude-cache-leiste
claude plugin install cache-leiste@simons-mods --scope user
Oder in einer Claude-Code-Sitzung: /plugin install cache-leiste --marketplace lechef312/claude-cache-leiste.
Aus einem lokalen Ordner geht es genauso, mit dem Pfad statt lechef312/claude-cache-leiste. Danach Claude Code neu starten.
Diese zwei Zeilen ins Setup-Skript der Cloud-Umgebung, dann lädt jede neue Cloud-Sitzung den Mod:
claude plugin marketplace add lechef312/claude-cache-leiste
claude plugin install cache-leiste@simons-mods --scope user
claude plugin validate .
claude plugin test .hooks/register.tsx 386 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, UiPressArgument } from 'claude-code'
3
4import type { CacheTtl, LastResponse } from '../types'
5import {
6 BAR_CELLS,
7 FALLBACK_THRESHOLD,
8 cacheView,
9 formatTokens,
10 hitRate,
11 redrawOffsets,
12 splitParagraphs,
13 ttlFromTranscript,
14 weather,
15} from './logic'
16
17const last = atom({ plugin: 'cache-leiste', key: 'last' } as const, null)
18const ttl = atom({ plugin: 'cache-leiste', key: 'ttl' } as const, '5m')
19const isTtlRead = atom({ plugin: 'cache-leiste', key: 'isTtlRead' } as const, false)
20const context = atom({ plugin: 'cache-leiste', key: 'context' } as const, {
21 percent: null,
22 threshold: FALLBACK_THRESHOLD,
23 isThresholdRead: false,
24})
25const now = atom({ plugin: 'cache-leiste', key: 'now' } as const, 0)
26const isCollapsed = atom({ plugin: 'cache-leiste', key: 'isCollapsed' } as const, false)
27
28/** Wie viel vom Ende des Protokolls gelesen wird, um die Cache-Laufzeit zu finden. */
29const TRANSCRIPT_TAIL_BYTES = '2000000'
30
31// Modulvariablen: nach einem Neuladen leer, alles Gezeichnete liegt in $.state.
32let timers: Timer[] = []
33let isBusy = false
34/** Die letzte bekannte Kontextgröße; nach einem Compact bleibt sie bis zur nächsten Antwort stehen. */
35let contextTokens: number | undefined
36let pendingCompact: { before?: number; after?: number; fallback?: string; isLogged: boolean } | undefined
37let compactSummary: string | undefined
38/**
39 * Läuft gerade ein Compact, den der Knopf ausgelöst hat? Dessen Ereignisse
40 * laufen unter dem Aufruf des Mods und erreichen dessen Hooks nicht; der Knopf
41 * schreibt die Zusammenfassung dann selbst.
42 */
43let isOwnCompact = false
44
45async function setNow($: EngineInterface) {
46 const t = await $.clock.now()
47 await update($, now, () => t)
48}
49
50/** Zeichnet ab der letzten Antwort jede Minute neu, dazu beim Wechsel auf Gelb und Rot. */
51async function startTicking($: EngineInterface) {
52 for (const timer of timers) timer.cancel()
53 timers = []
54 await setNow($)
55
56 const response = await read($, last)
57 if (response === null) return
58 const elapsed = (await $.clock.now()) - response.at
59
60 for (const offset of redrawOffsets(await read($, ttl))) {
61 if (offset <= elapsed) continue
62 timers.push($.clock.after(offset - elapsed, () => void setNow($)))
63 }
64}
65
66async function setTtl($: EngineInterface, value: CacheTtl) {
67 const before = await read($, ttl)
68 await update($, ttl, () => value)
69 await update($, isTtlRead, () => true)
70 await $.store.set('ttl', value)
71 if (before !== value) await startTicking($)
72}
73
74async function readTtlFromTranscript($: EngineInterface, path: string) {
75 let text: string | undefined
76 try {
77 const tail = await $.process.run(['tail', '-c', TRANSCRIPT_TAIL_BYTES, path], { timeoutMs: 5000 })
78 if (tail.exitCode === 0) text = tail.stdout
79 } catch {
80 // Kein tail auf diesem Rechner: dann die Datei selbst, solange sie unter 4 MiB ist.
81 }
82 if (text === undefined) {
83 try {
84 text = await $.fs.read(path)
85 } catch {
86 return
87 }
88 }
89 const found = ttlFromTranscript(text)
90 if (found !== undefined) await setTtl($, found)
91}
92
93async function readThreshold($: EngineInterface) {
94 try {
95 const usage = await $.session.usage({ breakdown: 'summary' })
96 const breakdown = usage.context.breakdown
97 const tokens = breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
98 if (tokens === undefined || usage.context.window <= 0) return
99 const threshold = Math.round((tokens / usage.context.window) * 100)
100 await update($, context, fill => ({ ...fill, threshold, isThresholdRead: true }))
101 } catch {
102 // Schwelle nicht lesbar: es bleibt bei 83 %.
103 }
104}
105
106async function setContext($: EngineInterface, percent: number | undefined, tokens: number | undefined) {
107 if (tokens !== undefined) contextTokens = tokens
108 await update($, context, fill => ({ ...fill, percent: percent ?? null }))
109}
110
111/** Nach einem Compact ist der alte Cache nichts mehr wert, bis die nächste Antwort kommt. */
112async function resetAfterCompact($: EngineInterface) {
113 await update($, last, () => null)
114 await update($, context, fill => ({ ...fill, percent: null }))
115 await startTicking($)
116}
117
118function writeSummary($: EngineInterface, before: number | undefined, after: number | undefined, summary: string | undefined) {
119 const from = before === undefined ? '?' : formatTokens(before)
120 const to = after === undefined ? '?' : formatTokens(after)
121 $.ui.log(`Compact: ${from} → ${to} Tokens. Damit macht Claude weiter:`)
122 if (summary === undefined || summary.trim() === '') {
123 $.ui.log('(keine Zusammenfassung erhalten)')
124 return
125 }
126 for (const chunk of splitParagraphs(summary)) $.ui.log(chunk)
127}
128
129/** Die Nachricht, mit der das Gespräch nach dem Compact beginnt: die Zusammenfassung. */
130function summaryOf(messages: readonly { role: string; text: string }[]): string | undefined {
131 return messages.find(message => message.role === 'user' && message.text.trim() !== '')?.text
132}
133
134/** Die Größe nach dem Compact, geschätzt, solange noch keine Antwort sie gemeldet hat. */
135async function estimateTokens($: EngineInterface): Promise<number | undefined> {
136 try {
137 const usage = await $.session.usage({ breakdown: 'summary' })
138 return usage.context.tokens ?? usage.context.breakdown?.totalTokens
139 } catch {
140 return undefined
141 }
142}
143
144function logCompact($: EngineInterface) {
145 if (pendingCompact === undefined || pendingCompact.isLogged) return
146 pendingCompact.isLogged = true
147 const { before, after, fallback } = pendingCompact
148 const summary = compactSummary ?? fallback
149 compactSummary = undefined
150 writeSummary($, before, after, summary)
151}
152
153async function compactNow($: EngineInterface) {
154 const before = contextTokens
155 const result = await $.session.compact()
156 if (result.skip !== undefined) {
157 $.ui.toast(`Compact übersprungen: ${result.skip}`)
158 return
159 }
160 await resetAfterCompact($)
161 writeSummary($, result.tokensBefore ?? before, result.tokensAfter, summaryOf(result.messages))
162}
163
164async function compactQueued($: EngineInterface) {
165 $.ui.toast('/compact ist eingereiht und startet, sobald Claude fertig ist.')
166 try {
167 const ran = await $.command.run({ command: 'compact' })
168 if (ran.text !== undefined && /error|fehler|failed|not enough/i.test(ran.text)) {
169 $.ui.toast(`/compact: ${ran.text}`)
170 return
171 }
172 const before = contextTokens
173 await resetAfterCompact($)
174 writeSummary($, before, await estimateTokens($), summaryOf(await $.session.messages()))
175 } catch {
176 $.ui.toast('/compact ließ sich nicht einreihen.')
177 }
178}
179
180async function compact($: EngineInterface, press: UiPressArgument, isWorkingAtDraw: boolean) {
181 if (isOwnCompact) {
182 $.ui.toast('Compact läuft schon.')
183 return
184 }
185 isOwnCompact = true
186 try {
187 // In der Desktop-App und während einer Runde geht der direkte Aufruf nicht: einreihen.
188 if (press.surface !== 'terminal' || isBusy || isWorkingAtDraw) {
189 await compactQueued($)
190 return
191 }
192 try {
193 await compactNow($)
194 } catch {
195 await compactQueued($)
196 }
197 } finally {
198 isOwnCompact = false
199 }
200}
201
202export const register: Register = on => {
203 on('session.start', async ($, e, next) => {
204 const started = await next(e)
205 const savedTtl = await $.store.get('ttl')
206 if (savedTtl === '5m' || savedTtl === '1h') await update($, ttl, () => savedTtl)
207 const savedCollapsed = await $.store.get('isCollapsed')
208 if (typeof savedCollapsed === 'boolean') await update($, isCollapsed, () => savedCollapsed)
209
210 const usage = await $.session.usage()
211 await setContext($, usage.context.percent, usage.context.tokens)
212 await readThreshold($)
213 await startTicking($)
214
215 return started
216 })
217
218 // Bei einer fortgesetzten Session: wann die letzte Antwort kam und mit welcher Laufzeit.
219 on('classic.SessionStart', async ($, e, next) => {
220 const result = await next(e)
221 if (e.seconds_since_last_response !== undefined && (await read($, last)) === null) {
222 const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
223 await update($, last, () => ({ at, hitRate: null }))
224 }
225 if (e.source === 'resume' || e.source === 'fork') await readTtlFromTranscript($, e.transcript_path)
226 await startTicking($)
227
228 return result
229 }).catch(($, e, next) => next(e))
230
231 on('turn.start', ($, e, next) => {
232 isBusy = true
233 return next(e)
234 })
235
236 // Jede Antwort des Hauptgesprächs frischt den Cache auf und bringt die Hit-Rate mit.
237 on('turn.step', async function* ($, e, next) {
238 const result = yield* next(e)
239 if (e.agentId === undefined && result.usage !== null) {
240 const response: LastResponse = { at: await $.clock.now(), hitRate: hitRate(result.usage) }
241 await update($, last, () => response)
242 await startTicking($)
243 }
244 return result
245 })
246
247 on('turn.complete', async ($, e, next) => {
248 const result = await next(e)
249 if (e.agentId === undefined) {
250 isBusy = false
251 if (!(await read($, context)).isThresholdRead) await readThreshold($)
252 }
253 return result
254 })
255
256 // Am Ende jeder Runde steht die letzte Antwort im Protokoll: daraus die echte Laufzeit.
257 on('classic.Stop', async ($, e, next) => {
258 const result = await next(e)
259 if (e.agent_id === undefined) await readTtlFromTranscript($, e.transcript_path)
260 return result
261 }).catch(($, e, next) => next(e))
262
263 on('classic.PostModelSwitch', async ($, e, next) => {
264 const result = await next(e)
265 await setTtl($, e.cache_ttl)
266 await update($, context, fill => ({ ...fill, isThresholdRead: false }))
267 await readThreshold($)
268 return result
269 }).catch(($, e, next) => next(e))
270
271 on('session.measure', async ($, e, next) => {
272 if (e.changed.includes('context')) await setContext($, e.context.percent, e.context.tokens)
273 return next(e)
274 })
275
276 on('classic.PostCompact', async ($, e, next) => {
277 const result = await next(e)
278 if (isOwnCompact) return result
279 compactSummary = e.compact_summary
280 if (pendingCompact !== undefined) logCompact($)
281 return result
282 }).catch(($, e, next) => next(e))
283
284 // /compact, selbst getippt (der Knopf schreibt seine Zusammenfassung selbst, siehe compact).
285 on('session.compact', async ($, e, next) => {
286 const before = contextTokens
287 const result = await next(e)
288 if (isOwnCompact || e.agentId !== undefined || result.skip !== undefined) return result
289
290 await resetAfterCompact($)
291 if (e.trigger !== 'auto' && e.trigger !== 'precompute') {
292 pendingCompact = { before: result.tokensBefore ?? before, after: result.tokensAfter, fallback: summaryOf(result.messages), isLogged: false }
293 // Die Zusammenfassung aus PostCompact abwarten, wenn sie nicht schon da ist.
294 if (compactSummary !== undefined) logCompact($)
295 else $.clock.after(1500, () => logCompact($))
296 }
297 return result
298 }).catch(($, e, next) => next(e))
299
300 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
301 if (e.props.hasSurvey) return next(e)
302
303 const { Box, Text, Button } = $.ui.resolve(e)
304 const response = await read($, last)
305 const cacheTtl = await read($, ttl)
306 const ttlKnown = await read($, isTtlRead)
307 const fill = await read($, context)
308 const t = await read($, now)
309 const collapsed = await read($, isCollapsed)
310 const isWorkingAtDraw = e.props.isWorking
311
312 const view = response === null ? undefined : cacheView(response.at, cacheTtl, Math.max(t, response.at))
313 const sky = fill.percent === null ? '☀️' : weather(fill.percent, fill.threshold)
314 const isStorm = sky === '⛈️'
315 const thresholdText = `${fill.isThresholdRead ? '' : '~'}${fill.threshold} %`
316 const percentText = fill.percent === null ? '–' : `${fill.percent} %`
317 const minutesText =
318 view === undefined ? '–' : view.minutes === 0 ? 'abgelaufen' : `${view.minutes} Min`
319 const minutesColor = view === undefined ? undefined : view.color
320
321 const toggle = (
322 <Button
323 key="toggle"
324 plain
325 dimColor
326 label={collapsed ? '▸' : '◂'}
327 onPress={async () => {
328 const value = await update($, isCollapsed, c => !c)
329 await $.store.set('isCollapsed', value)
330 }}
331 />
332 )
333
334 if (collapsed || e.props.bodyColumns < 60) {
335 return (
336 <Box flexDirection="row">
337 <Text>{sky} </Text>
338 <Text color={minutesColor} dimColor={view === undefined}>
339 {minutesText}
340 </Text>
341 <Text dimColor> · </Text>
342 <Text>{percentText} </Text>
343 {toggle}
344 </Box>
345 )
346 }
347
348 const bar =
349 view === undefined ? (
350 <Text dimColor>{'░'.repeat(BAR_CELLS)}</Text>
351 ) : (
352 <Text>
353 <Text color={view.color}>{'█'.repeat(view.filled)}</Text>
354 <Text dimColor>{'░'.repeat(BAR_CELLS - view.filled)}</Text>
355 </Text>
356 )
357 const hit = response?.hitRate ?? null
358
359 return (
360 <Box flexDirection="row">
361 <Text dimColor>Cache{ttlKnown ? ` ${cacheTtl === '1h' ? '1 h' : '5 Min'}` : ''}: </Text>
362 <Text color={minutesColor} dimColor={view === undefined}>
363 {minutesText}{' '}
364 </Text>
365 {bar}
366 <Text dimColor> · Hit </Text>
367 <Text>{hit === null ? '–' : `${hit} %`}</Text>
368 <Text dimColor> · </Text>
369 <Text>{sky} </Text>
370 <Text dimColor>Kontext </Text>
371 <Text color={isStorm ? 'error' : undefined}>{percentText}</Text>
372 <Text dimColor> / Compact bei {thresholdText} </Text>
373 <Button
374 key="compact"
375 label="Compact"
376 variant={isStorm ? 'primary' : undefined}
377 dimColor={isStorm ? undefined : true}
378 onPress={press => compact($, press, isWorkingAtDraw)}
379 />
380 <Text> </Text>
381 {toggle}
382 </Box>
383 )
384 })
385}
386hooks/logic.ts 166 lines1import type { CacheTtl } from '../types'
2
3export const BAR_CELLS = 20
4export const FALLBACK_THRESHOLD = 83
5/** Die Zeilen, die das Terminal von einer Log-Zeile zeichnet, sind 2000 Zeichen; etwas Luft bleibt. */
6export const LOG_CHUNK = 1800
7
8/** Eine Sekunde Spielraum, damit ein Tick kurz vor der Minutengrenze nicht eine Minute zu viel zeigt. */
9const SLACK_MS = 1000
10
11export const ttlMs = (ttl: CacheTtl): number => (ttl === '1h' ? 3_600_000 : 300_000)
12
13export type CacheView = {
14 /** Ganze Minuten, aufgerundet; 0 heißt abgelaufen. */
15 minutes: number
16 /** Gefüllte Kästchen von BAR_CELLS. */
17 filled: number
18 color: 'success' | 'warning' | 'error'
19}
20
21export const cacheView = (at: number, ttl: CacheTtl, now: number): CacheView => {
22 const total = ttlMs(ttl)
23 const left = Math.max(0, total - (now - at) - SLACK_MS)
24 const fraction = left / total
25 const minutes = Math.ceil(left / 60_000)
26 const filled = Math.min(BAR_CELLS, Math.ceil(fraction * BAR_CELLS))
27 const color = fraction > 0.25 ? 'success' : fraction > 0.1 ? 'warning' : 'error'
28
29 return { minutes, filled, color }
30}
31
32/**
33 * Die Zeitpunkte nach der Antwort, zu denen die Leiste neu gezeichnet wird:
34 * jede volle Minute, dazu der Wechsel auf Gelb (75 %) und auf Rot (90 %).
35 */
36export const redrawOffsets = (ttl: CacheTtl): number[] => {
37 const total = ttlMs(ttl)
38 const offsets = new Set<number>()
39
40 for (let ms = 60_000; ms <= total; ms += 60_000) offsets.add(ms)
41 offsets.add(Math.round(total * 0.75) + SLACK_MS)
42 offsets.add(Math.round(total * 0.9) + SLACK_MS)
43
44 return [...offsets].sort((a, b) => a - b)
45}
46
47export type Weather = '☀️' | '🌧️' | '⛈️'
48
49export const weather = (percent: number, threshold: number): Weather => {
50 const ratio = percent / threshold
51
52 return ratio <= 0.5 ? '☀️' : ratio <= 0.8 ? '🌧️' : '⛈️'
53}
54
55export type Usage = {
56 input_tokens: number
57 cache_read_input_tokens: number
58 cache_creation_input_tokens: number
59}
60
61/** Anteil der Input-Tokens, die aus dem Cache kamen, in ganzen Prozent. */
62export const hitRate = (usage: Usage): number | null => {
63 const read = usage.cache_read_input_tokens ?? 0
64 const total = (usage.input_tokens ?? 0) + read + (usage.cache_creation_input_tokens ?? 0)
65
66 return total > 0 ? Math.round((read / total) * 100) : null
67}
68
69type TranscriptRow = {
70 type?: string
71 isSidechain?: boolean
72 message?: {
73 usage?: {
74 cache_creation?: {
75 ephemeral_1h_input_tokens?: number
76 ephemeral_5m_input_tokens?: number
77 } | null
78 }
79 }
80}
81
82/**
83 * Liest aus dem Session-Protokoll, mit welcher Laufzeit die letzte Antwort
84 * des Hauptgesprächs in den Cache geschrieben hat; undefined, wenn keine
85 * Antwort etwas geschrieben hat.
86 */
87export const ttlFromTranscript = (text: string): CacheTtl | undefined => {
88 const lines = text.split('\n')
89
90 for (let i = lines.length - 1; i >= 0; i -= 1) {
91 const line = lines[i]?.trim()
92 if (!line || !line.includes('"cache_creation"')) continue
93
94 let row: TranscriptRow
95 try {
96 row = JSON.parse(line) as TranscriptRow
97 } catch {
98 continue
99 }
100 if (row.type !== 'assistant' || row.isSidechain === true) continue
101
102 const creation = row.message?.usage?.cache_creation
103 if (!creation) continue
104 if ((creation.ephemeral_5m_input_tokens ?? 0) > 0) return '5m'
105 if ((creation.ephemeral_1h_input_tokens ?? 0) > 0) return '1h'
106 }
107
108 return undefined
109}
110
111/** Tausender mit Punkt, ohne Intl (die Umgebung des Mods hat keins garantiert). */
112export const formatTokens = (n: number): string =>
113 String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
114
115const hardSplit = (text: string, max: number): string[] => {
116 const parts: string[] = []
117 let rest = text
118
119 while (rest.length > max) {
120 const space = rest.lastIndexOf(' ', max)
121 const cut = space > max / 2 ? space : max
122 parts.push(rest.slice(0, cut).trimEnd())
123 rest = rest.slice(cut).trimStart()
124 }
125 if (rest.length > 0) parts.push(rest)
126
127 return parts
128}
129
130/**
131 * Teilt einen langen Text an Absätzen in Stücke von höchstens `max` Zeichen;
132 * ein zu langer Absatz wird an Zeilen, eine zu lange Zeile an Wörtern geteilt.
133 */
134export const splitParagraphs = (text: string, max: number = LOG_CHUNK): string[] => {
135 // Jedes Stück mit dem Trenner, der im Text davor stand.
136 const pieces: { text: string; sep: string }[] = []
137
138 for (const paragraph of text.trim().split(/\n\s*\n/)) {
139 if (paragraph.length <= max) {
140 pieces.push({ text: paragraph, sep: '\n\n' })
141 continue
142 }
143 paragraph.split('\n').forEach((line, i) => {
144 const sep = i === 0 ? '\n\n' : '\n'
145 hardSplit(line, max).forEach((part, j) => pieces.push({ text: part, sep: j === 0 ? sep : ' ' }))
146 })
147 }
148
149 const chunks: string[] = []
150 let current = ''
151
152 for (const piece of pieces) {
153 if (current === '') {
154 current = piece.text
155 } else if (current.length + piece.sep.length + piece.text.length > max) {
156 chunks.push(current)
157 current = piece.text
158 } else {
159 current = current + piece.sep + piece.text
160 }
161 }
162 if (current.trim() !== '') chunks.push(current)
163
164 return chunks
165}
166types/index.d.ts 25 lines1export type CacheTtl = '5m' | '1h'
2
3/** Die letzte Antwort des Hauptgesprächs: wann sie kam und wie viel aus dem Cache. */
4export type LastResponse = { at: number; hitRate: number | null }
5
6/** Kontextfüllung und Auto-Compact-Schwelle, beides in Prozent des Kontextfensters. */
7export type ContextFill = {
8 percent: number | null
9 threshold: number
10 isThresholdRead: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'cache-leiste': {
16 last: LastResponse | null
17 ttl: CacheTtl
18 isTtlRead: boolean
19 context: ContextFill
20 now: number
21 isCollapsed: boolean
22 }
23 }
24}
25