A narrow bar above the prompt: prompt-cache time left, cache hit rate, context weather and fill, and a Compact button. Posts the compact summary into the chat.

Learn to play songs at the piano: a path from easy to hard, the chords and scales each song needs, and a practice player that plays along, steps through, or waits for you. Installable, works offline, in English and Russian.
Needs Node 24 (nvm use).
npm install
npm run dev # http://localhost:5173
npm run test # all tests
npm run build && npm run preview # the production build, with the service worker
.github/workflows/ci.yml). Vercel builds a preview of each pull request and deploys main to production (vercel.ts).hooks/register.tsx 255 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CompactBarInfo } from '../types'
5import {
6 BOXES,
7 DEFAULT_THRESHOLD,
8 FIVE_MIN,
9 ONE_HOUR,
10 cacheLeft,
11 fmtTokens,
12 hitRateOf,
13 meter,
14 meterColor,
15 splitSummary,
16 ttlSeen,
17 weatherOf,
18} from './logic'
19
20const INITIAL: CompactBarInfo = {
21 lastResponseAt: null,
22 ttlMs: ONE_HOUR,
23 ttlSource: 'guess',
24 hitRate: null,
25 contextPercent: null,
26 thresholdPercent: DEFAULT_THRESHOLD,
27}
28
29const info = atom({ plugin: 'compact-bar', key: 'info' } as const, INITIAL)
30const collapsed = atom({ plugin: 'compact-bar', key: 'collapsed' } as const, false)
31const tick = atom({ plugin: 'compact-bar', key: 'tick' } as const, 0)
32const isWorking = atom({ plugin: 'compact-bar', key: 'isWorking' } as const, false)
33const pending = atom({ plugin: 'compact-bar', key: 'pending' } as const, false)
34
35const STORE_COLLAPSED = 'collapsed'
36
37type $ = EngineInterface
38
39/** The auto-compact threshold as a percent of the window, or 83 when it cannot be read. */
40async function thresholdOf($: $): Promise<number> {
41 try {
42 const override = Number(await $.env.get('CLAUDE_AUTOCOMPACT_PCT_OVERRIDE'))
43 if (override > 0 && override <= 100) return override
44 const usage = await $.session.usage({ breakdown: 'summary' })
45 const b = usage.context.breakdown
46 if (b?.autoCompactThreshold && b.rawMaxTokens > 0) {
47 return Math.round((b.autoCompactThreshold / b.rawMaxTokens) * 100)
48 }
49 } catch {
50 // fall through to the default
51 }
52 return DEFAULT_THRESHOLD
53}
54
55async function setTtl($: $, ttl: '5m' | '1h') {
56 await update($, info, i => ({ ...i, ttlMs: ttl === '1h' ? ONE_HOUR : FIVE_MIN, ttlSource: 'hook' as const }))
57}
58
59/** Runs /compact now. If the engine refuses the direct call (the Desktop app), puts /compact in the prompt box. */
60async function runCompact($: $) {
61 try {
62 await $.command.run({ command: 'compact' })
63 } catch {
64 try {
65 await $.prompt.fill({ text: '/compact', mode: 'replace' })
66 $.ui.log('compact-bar: /compact is in the prompt box. Press Enter to compact.')
67 } catch {
68 $.ui.log('compact-bar: could not start /compact. Type /compact yourself.')
69 }
70 }
71}
72
73/** Compacts now when idle; while Claude works, queues it to run the moment the turn ends. */
74export async function compactNow($: $, working: boolean) {
75 if (working) {
76 await update($, pending, () => true)
77 return
78 }
79 await runCompact($)
80}
81
82export const register: Register = on => {
83 on('session.start', async ($, e, next) => {
84 const r = await next(e)
85 try {
86 const now = await $.clock.now()
87 await update($, tick, () => now)
88 await update($, collapsed, () => false)
89 if ((await $.store.get(STORE_COLLAPSED)) === true) await update($, collapsed, () => true)
90 const usage = await $.session.usage()
91 const threshold = await thresholdOf($)
92 // A subscription reports rate-limit windows; Claude Code caches for an hour there.
93 const guess = usage.rateLimits.length > 0 ? ONE_HOUR : FIVE_MIN
94 await update($, info, i => ({
95 ...i,
96 thresholdPercent: threshold,
97 contextPercent: usage.context.percent ?? i.contextPercent,
98 ttlMs: i.ttlSource === 'guess' ? guess : i.ttlMs,
99 }))
100 $.clock.every(60000, () => {
101 void $.clock.now().then(t => update($, tick, () => t))
102 })
103 } catch {
104 // the bar draws from its defaults
105 }
106 return r
107 })
108
109 on('classic.PreModelSwitch', async ($, e, next) => {
110 await setTtl($, e.cache_ttl)
111 return next(e)
112 }).catch(($, e, next) => next(e))
113 on('classic.PostModelSwitch', async ($, e, next) => {
114 await setTtl($, e.cache_ttl)
115 return next(e)
116 }).catch(($, e, next) => next(e))
117
118 on('turn.start', async ($, e, next) => {
119 await update($, isWorking, () => true)
120 return next(e)
121 })
122
123 on('turn.step', async function* ($, e, next) {
124 const r = yield* next(e)
125 if (e.agentId === undefined && r.usage) {
126 const u = r.usage
127 const now = await $.clock.now()
128 await update($, info, i => {
129 const seen = i.lastResponseAt === null || i.ttlSource === 'hook' ? null : ttlSeen(now - i.lastResponseAt, u)
130 return {
131 ...i,
132 lastResponseAt: now,
133 hitRate: hitRateOf(u),
134 ttlMs: seen ?? i.ttlMs,
135 ttlSource: seen ? 'seen' : i.ttlSource,
136 }
137 })
138 await update($, tick, () => now)
139 }
140 return r
141 })
142
143 on('turn.complete', async ($, e, next) => {
144 const r = await next(e)
145 if (e.agentId !== undefined) return r
146 await update($, isWorking, () => false)
147 if (await read($, pending)) {
148 await update($, pending, () => false)
149 void runCompact($)
150 return r
151 }
152 try {
153 const usage = await $.session.usage()
154 const threshold = await thresholdOf($)
155 await update($, info, i => ({
156 ...i,
157 contextPercent: usage.context.percent ?? i.contextPercent,
158 thresholdPercent: threshold,
159 }))
160 } catch {
161 // keep the last figures
162 }
163 return r
164 })
165
166 on('session.compact', async ($, e, next) => {
167 const r = await next(e)
168 if (e.agentId !== undefined || r.skip !== undefined || e.trigger === 'precompute') return r
169 await update($, info, i => ({ ...i, contextPercent: null }))
170 if (e.trigger === 'auto') return r
171 const summary = r.messages.find(m => m.text.trim().length > 0)?.text ?? ''
172 $.ui.log(
173 `Compacted: ${fmtTokens(r.tokensBefore)} → ${fmtTokens(r.tokensAfter)} tokens. Claude continues from this summary:`,
174 )
175 for (const piece of splitSummary(summary)) $.ui.log(piece)
176 return r
177 }).catch(($, e, next) => next(e))
178
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 const rest = await next(e)
181 if (e.props.hasSurvey) return rest
182 const { Box, Text, Button } = $.ui.resolve(e)
183 const bar = await read($, info)
184 const isCollapsed = await read($, collapsed)
185 await read($, tick)
186 const now = await $.clock.now()
187 const working = e.props.isWorking
188 const isPending = await read($, pending)
189 const compactLabel = isPending ? 'Compact queued' : 'Compact'
190
191 const left = cacheLeft(bar.lastResponseAt, bar.ttlMs, now)
192 const weather = weatherOf(bar.contextPercent, bar.thresholdPercent)
193 const fill = bar.contextPercent === null ? '–' : `${bar.contextPercent}%`
194 const time = left === null ? '–' : left.minutes === 0 ? 'expired' : `${left.minutes}m`
195 const timeColor = left === null ? undefined : left.minutes === 0 ? 'error' : meterColor(left.share)
196 const arrow = (
197 <Button
198 key="collapse"
199 plain
200 label={isCollapsed ? '▸' : '▾'}
201 onPress={async () => {
202 const value = !(await read($, collapsed))
203 await update($, collapsed, () => value)
204 await $.store.set(STORE_COLLAPSED, value)
205 }}
206 />
207 )
208
209 const narrow = e.props.bodyColumns < 72
210 let row
211 if (isCollapsed || narrow) {
212 row = (
213 <Box key="compact-bar" flexDirection="row" gap={1}>
214 <Text>{weather.emoji}</Text>
215 <Text color={timeColor}>{time}</Text>
216 <Text dimColor>·</Text>
217 <Text>{fill}</Text>
218 {narrow && !isCollapsed ? (
219 <Button key="compact" label={compactLabel} variant={weather.isStorm ? 'primary' : 'secondary'} onPress={() => compactNow($, working)} />
220 ) : null}
221 {arrow}
222 </Box>
223 )
224 } else {
225 const m = meter(left?.share ?? 0, BOXES)
226 row = (
227 <Box key="compact-bar" flexDirection="row" gap={1}>
228 <Text dimColor>cache</Text>
229 <Text color={timeColor} bold>
230 {time}
231 </Text>
232 <Text>
233 <Text color={timeColor}>{m.full}</Text>
234 <Text dimColor>{m.empty}</Text>
235 </Text>
236 <Text dimColor>· hit</Text>
237 <Text>{bar.hitRate === null ? '–' : `${bar.hitRate}%`}</Text>
238 <Text dimColor>·</Text>
239 <Text>{weather.emoji}</Text>
240 <Text bold={weather.isStorm}>{fill}</Text>
241 <Text dimColor>/ {bar.thresholdPercent}%</Text>
242 <Button key="compact" label={compactLabel} variant={weather.isStorm ? 'primary' : 'secondary'} onPress={() => compactNow($, working)} />
243 {arrow}
244 </Box>
245 )
246 }
247 return (
248 <Box flexDirection="column">
249 {rest}
250 {row}
251 </Box>
252 )
253 })
254}
255hooks/logic.ts 101 lines1// Pure parts of compact-bar: no engine, so the tests drive them directly.
2
3export const FIVE_MIN = 5 * 60 * 1000
4export const ONE_HOUR = 60 * 60 * 1000
5export const DEFAULT_THRESHOLD = 83
6export const BOXES = 20
7
8export type Weather = { emoji: string; isStorm: boolean }
9
10/** ☀️ up to half the threshold, 🌧️ up to 80% of it, ⛈️ above. */
11export function weatherOf(percent: number | null, threshold: number): Weather {
12 const p = percent ?? 0
13 if (p <= threshold * 0.5) return { emoji: '☀️', isStorm: false }
14 if (p <= threshold * 0.8) return { emoji: '🌧️', isStorm: false }
15 return { emoji: '⛈️', isStorm: true }
16}
17
18/** Whole minutes left, rounded up, and the share left 0..1. */
19export function cacheLeft(lastAt: number | null, ttlMs: number, now: number): { minutes: number; share: number } | null {
20 if (lastAt === null) return null
21 const left = Math.max(0, lastAt + ttlMs - now)
22 return { minutes: Math.ceil(left / 60000), share: left / ttlMs }
23}
24
25/** green, yellow in the last quarter, red in the last 10%. */
26export function meterColor(share: number): 'success' | 'warning' | 'error' {
27 if (share <= 0.1) return 'error'
28 if (share <= 0.25) return 'warning'
29 return 'success'
30}
31
32export function meter(share: number, boxes = BOXES): { full: string; empty: string } {
33 const n = Math.max(0, Math.min(boxes, Math.round(share * boxes)))
34 return { full: '■'.repeat(n), empty: '□'.repeat(boxes - n) }
35}
36
37export type Usage = {
38 input_tokens: number
39 cache_read_input_tokens: number
40 cache_creation_input_tokens: number
41}
42
43export function hitRateOf(u: Usage): number | null {
44 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
45 return total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : null
46}
47
48/**
49 * Learns the TTL from what a response shows: a cache read after more than five
50 * minutes of quiet means the hour lifetime; a full rewrite after 5–60 minutes
51 * with nothing read means five minutes.
52 */
53export function ttlSeen(gapMs: number, u: Usage): number | null {
54 if (gapMs > FIVE_MIN + 15000 && u.cache_read_input_tokens > 0) return ONE_HOUR
55 if (gapMs > FIVE_MIN + 15000 && gapMs < ONE_HOUR && u.cache_read_input_tokens === 0 && u.cache_creation_input_tokens > 1000) return FIVE_MIN
56 return null
57}
58
59export function fmtTokens(n: number | undefined): string {
60 if (n === undefined) return '?'
61 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
62 if (n >= 1000) return `${Math.round(n / 1000)}k`
63 return String(n)
64}
65
66/** Splits text at paragraph breaks into pieces of at most `max` characters; a paragraph longer than that is split at lines, then at spaces. */
67export function splitSummary(text: string, max = 1800): string[] {
68 const out: string[] = []
69 let cur = ''
70 const push = (piece: string) => {
71 if (cur && cur.length + 2 + piece.length > max) {
72 out.push(cur)
73 cur = ''
74 }
75 cur = cur ? `${cur}\n\n${piece}` : piece
76 }
77 for (const para of text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean)) {
78 if (para.length <= max) {
79 push(para)
80 continue
81 }
82 for (const part of hardSplit(para, max)) push(part)
83 }
84 if (cur) out.push(cur)
85 return out
86}
87
88function hardSplit(text: string, max: number): string[] {
89 const out: string[] = []
90 let rest = text
91 while (rest.length > max) {
92 let at = rest.lastIndexOf('\n', max)
93 if (at < max / 2) at = rest.lastIndexOf(' ', max)
94 if (at < max / 2) at = max
95 out.push(rest.slice(0, at).trimEnd())
96 rest = rest.slice(at).trimStart()
97 }
98 if (rest) out.push(rest)
99 return out
100}
101types/index.d.ts 21 lines1export type CompactBarInfo = {
2 /** When the last main-loop response arrived, in $.clock.now() ms; null before one. */
3 lastResponseAt: number | null
4 /** The prompt cache's lifetime in ms: 5 minutes or 1 hour. */
5 ttlMs: number
6 /** Where ttlMs came from: 'hook' (the engine said), 'seen' (observed), 'guess'. */
7 ttlSource: 'hook' | 'seen' | 'guess'
8 /** Share of the last response's input served from the cache, 0..100; null before one. */
9 hitRate: number | null
10 /** Context fill against the model's window, 0..100; null before a response. */
11 contextPercent: number | null
12 /** The auto-compact threshold as a percent of the window. */
13 thresholdPercent: number
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'compact-bar': { info: CompactBarInfo; collapsed: boolean; tick: number; isWorking: boolean; pending: boolean }
19 }
20}
21