SLOPSHOPPER

compact-bar

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.

newbandtimer
★ 2v0.1.0no licenseupdated 2026-10-12tt8747430-png/piano-trainer/.claude/mods/compact-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · compact-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 ⟨Claude Code's own drawing⟩ cache – □□□□□□□□□□□□□□□□□□□□ · hit – · 🌧️ 49% / 83% [ Compact ] ▾ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ cache – □□□□□□□□□□□□□□□□□□□□ · hit – · 🌧️ 49% / 83% [ Compact ] ▾
README

Piano Trainer

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.

Run it

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

Work on it

  • How code is organised and written: CLAUDE.md, docs/CODE_STYLE.md.
  • The words we use: docs/UBIQUITOUS_LANGUAGE.md.
  • Decisions and why: docs/adr; the visual world in ADR 0007.
  • Every push and pull request runs typecheck, lint, tests with coverage and the build (.github/workflows/ci.yml). Vercel builds a preview of each pull request and deploys main to production (vercel.ts).
  • How to add a song, a progression, a pattern or a path step: docs/CONTENT.md.
Source 3 files
hooks/register.tsx 255 lines
1import { 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}
255
hooks/logic.ts 101 lines
1// 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}
101
types/index.d.ts 21 lines
1export 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