SLOPSHOPPER

coverage-band

Test coverage above the prompt after every test run, with the change since the last run and the modules below your threshold.

newpanebandspinnerguardcommand
v0.1.0MITupdated 2026-10-06santtisosa/coverage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coverage-band
› 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 › /coverage ⎿ coverage-band: No coverage yet: run your tests with coverage enabled. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

coverage-band

A Claude Code mod that shows your test coverage after every test run Claude makes: a gauge with a mark at your threshold, the change since the last run, and the modules that fall below it.

🧪 ━━━━━━━━┃━ 99.3% ↑8.0 · MyApp.WebApi 78.4%
  • The line is green up to your coverage (red when the total is below the threshold); the yellow ┃ marks the threshold.
  • ↑8.0 / ↓1.2: points gained or lost since the previous run in that folder.
  • Modules below the threshold follow in yellow, and a toast lists how many there are.
  • /coverage opens a pane with every module and its bar.

What it reads

It reacts to Bash commands that run tests (dotnet test, npm test, pnpm test, yarn test, jest, vitest, pytest), then picks up the reports that command wrote:

  • Cobertura XML (coverage.cobertura.xml, coverage.xml, cobertura-coverage.xml): .NET (coverlet), coverage.py, Jest. Several reports from one run (one per .NET test project) are merged line by line, so a line covered by any test project counts once, like ReportGenerator does.
  • lcov (lcov.info): Vitest, Jest, c8. Modules are folders.

Only reports written during that command count; node_modules, bin, obj, .git and .venv are skipped. A failing coverage read never affects the test command itself.

Install

Requires Claude Code 2.1.287 or later, on macOS or Linux (it uses find).

/plugin marketplace add santtisosa/coverage-band
/plugin install coverage-band@coverage-band

Settings (/config)

SettingDefault
Placementhinthint: at the end of the hint line under the prompt. band: a row above the prompt. status: a pinned notice under the prompt.
Threshold80Minimum line coverage, in percent.
Gauge width10Cells in the gauge.

Develop

claude plugin validate .
claude plugin test .

hooks/coverage.ts holds the parsers, the merge and the gauge; hooks/register.tsx wires them to Claude Code.

License

MIT

Source 3 files
hooks/register.tsx 200 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Coverage, Snapshot } from '../types'
5import { appendHistory, formatDelta, gauge, isTestCommand, parseCobertura, parseLcov, REPORT_NAMES, withHistory, workingDirectory } from './coverage'
6
7const PANE = 'coverage'
8const CLOCK_SKEW_MS = 2_000
9const FIND_TIMEOUT_MS = 15_000
10const MODULES_IN_BAND = 3
11const NAME_COLUMN = 40
12const BAR_CELLS = 20
13const PRUNED = ['node_modules', '.git', 'bin', 'obj', '.venv']
14
15const last = atom({ plugin: 'coverage-band', key: 'last' } as const, null as Snapshot | null)
16
17const findReports = async ($: EngineInterface, dir: string, since: number): Promise<string[]> => {
18  // ponytail: one `find` call; walk with $.fs.list if Windows support is ever needed
19  const prune = PRUNED.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name]))
20  const names = REPORT_NAMES.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name]))
21  const found = await $.process.run(
22    ['find', dir, '(', ...prune, ')', '-prune', '-o', '-type', 'f', '(', ...names, ')', '-print'],
23    { timeoutMs: FIND_TIMEOUT_MS })
24  const paths = found.stdout.split('\n').filter(path => path.length > 0)
25  const stats = await Promise.all(paths.map(async path => ({ path, modified: (await $.fs.stat(path)).mtimeMs })))
26
27  return stats
28    .filter(file => file.modified >= since)
29    .sort((a, b) => b.modified - a.modified)
30    .map(file => file.path)
31}
32
33const readCoverage = async ($: EngineInterface, reports: readonly string[], dir: string): Promise<Coverage> => {
34  const lcov = reports.find(path => path.endsWith('lcov.info'))
35
36  if(lcov !== undefined) {
37    return parseLcov(await $.fs.read(lcov), dir)
38  }
39
40  return parseCobertura(await Promise.all(reports.map(path => $.fs.read(path))))
41}
42
43type Display = { isStatus: boolean; threshold: number; gaugeWidth: number }
44
45const record = async ($: EngineInterface, command: string, startedAt: number, display: Display) => {
46  const dir = workingDirectory(command, await $.session.cwd())
47  const reports = await findReports($, dir, startedAt - CLOCK_SKEW_MS)
48
49  if(reports.length === 0) {
50    return
51  }
52
53  const coverage = await readCoverage($, reports, dir)
54  const key = `history:${dir}`
55  const history = appendHistory(((await $.store.get(key)) as number[] | undefined) ?? [], coverage.percent)
56  await $.store.set(key, history)
57  const snapshot = { ...coverage, history, source: dir }
58  await update($, last, () => snapshot)
59
60  if(display.isStatus) {
61    $.ui.status(formatLine(snapshot, display))
62  }
63
64  const below = coverage.modules.filter(module => module.percent < display.threshold)
65
66  if(below.length > 0) {
67    $.ui.toast(`Coverage: ${below.length} module(s) below ${display.threshold}%`)
68  }
69}
70
71const belowThreshold = (snapshot: Snapshot, threshold: number) =>
72  snapshot.modules.filter(module => module.percent < threshold).slice(0, MODULES_IN_BAND)
73
74type Elements = ReturnType<EngineInterface['ui']['resolve']>
75
76const coverageTree = (snapshot: Snapshot, display: Display, { Box, Text }: Elements) => {
77  const threshold = display.threshold
78  const color = snapshot.percent >= threshold ? 'green' : 'red'
79  const delta = formatDelta(snapshot.history)
80  const parts = gauge(snapshot.percent, threshold, display.gaugeWidth)
81
82  return (
83    <Box gap={1}>
84      <Text>🧪</Text>
85      <Box>
86        {parts.map((part, index) => (
87          <Text key={`${index}`} color={part.kind === 'covered' ? color : part.kind === 'threshold' ? 'yellow' : undefined} dimColor={part.kind === 'missing'} bold={part.kind === 'threshold'}>
88            {part.text}
89          </Text>
90        ))}
91      </Box>
92      <Text color={color} bold>{`${snapshot.percent}%`}</Text>
93      {delta === undefined ? null : <Text color={delta.startsWith('↑') ? 'green' : 'red'}>{delta}</Text>}
94      {belowThreshold(snapshot, threshold).map(module => (
95        <Text key={module.name} color="yellow">{`${module.name} ${module.percent}%`}</Text>
96      ))}
97    </Box>
98  )
99}
100
101const formatLine = (snapshot: Snapshot, display: Display): string => {
102  const delta = formatDelta(snapshot.history)
103  const below = belowThreshold(snapshot, display.threshold).map(module => `${module.name} ${module.percent}%`)
104  const total = `🧪 ${gauge(snapshot.percent, display.threshold, display.gaugeWidth).map(part => part.text).join('')} ${snapshot.percent}%`
105
106  return [total, delta, ...below].filter(part => part !== undefined).join(' · ')
107}
108
109export const register: Register = (on, options) => {
110  const threshold = Number(options.threshold ?? 80)
111  const placement = String(options.placement ?? 'hint')
112  const isStatus = placement === 'status'
113  const gaugeWidth = Number(options.gaugeWidth ?? 10)
114  const display = { isStatus, threshold, gaugeWidth }
115  const colorOf = (percent: number) => (percent >= threshold ? 'green' : 'red')
116
117  on('session.start', async ($, e, next) => {
118    await $.command.register({ name: PANE, description: 'Coverage per module from the last test run' })
119    const snapshot = await update($, last, withHistory)
120    $.ui.status(isStatus && snapshot !== null ? formatLine(snapshot, display) : undefined)
121
122    return next(e)
123  })
124
125  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
126    const startedAt = await $.clock.now()
127    const ran = await next(e)
128    const output = ran.result as { interrupted?: boolean; backgroundTaskId?: string } | undefined
129    const isFinishedTestRun = ran.deny === undefined && isTestCommand(e.command)
130      && output?.interrupted !== true && output?.backgroundTaskId === undefined
131
132    if(isFinishedTestRun) {
133      await record($, e.command, startedAt, display)
134    }
135
136    return ran
137  }).catch(($, e, next) => next(e))
138
139  on('command.run', { command: PANE }, async ($, e) => {
140    if((await read($, last)) === null) {
141      return { text: 'No coverage yet: run your tests with coverage enabled.' }
142    }
143
144    await $.ui.open({ id: PANE, title: 'Coverage', focus: true, closeOnEscape: true })
145
146    return { text: 'Coverage opened.' }
147  })
148
149  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
150    const line = await next(e)
151    const snapshot = await read($, last)
152
153    if(placement !== 'hint' || snapshot === null) {
154      return line
155    }
156
157    const elements = $.ui.resolve(e)
158
159    return <elements.Box gap={2}>{line}{coverageTree(snapshot, display, elements)}</elements.Box>
160  })
161
162  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
163    const snapshot = await read($, last)
164
165    if(placement !== 'band' || e.props.hasSurvey || snapshot === null) {
166      return next(e)
167    }
168
169    return coverageTree(snapshot, display, $.ui.resolve(e))
170  })
171
172  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
173    const snapshot = await read($, last)
174
175    if(snapshot === null) {
176      return next(e)
177    }
178
179    const { Box, Text } = $.ui.resolve(e)
180
181    return (
182      <Box flexDirection="column">
183        <Text dimColor>{`${snapshot.source} · threshold ${threshold}%`}</Text>
184        {snapshot.modules.map(module => {
185          const filled = Math.round((module.percent / 100) * BAR_CELLS)
186
187          return (
188            <Box key={module.name}>
189              <Box width={NAME_COLUMN}><Text wrap="truncate-start">{module.name}</Text></Box>
190              <Text color={colorOf(module.percent)}>{'█'.repeat(filled)}</Text>
191              <Text dimColor>{'░'.repeat(BAR_CELLS - filled)}</Text>
192              <Text color={colorOf(module.percent)}>{` ${module.percent}%`}</Text>
193            </Box>
194          )
195        })}
196      </Box>
197    )
198  })
199}
200
hooks/coverage.ts 138 lines
1import type { Coverage, Snapshot } from '../types'
2
3const TEST_COMMAND = /\b(dotnet\s+test|(npm|pnpm)\s+(run\s+)?test|yarn\s+test|jest|vitest|pytest)\b/
4const LEADING_CD = /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/
5const PACKAGE = /<package\b([^>]*)>([\s\S]*?)<\/package>/g
6const CLASS = /<class\b([^>]*)>([\s\S]*?)<\/class>/g
7const LINE = /<line\b([^>]*)\/?>/g
8const PERCENT = 100
9
10export const REPORT_NAMES = ['coverage.cobertura.xml', 'coverage.xml', 'cobertura-coverage.xml', 'lcov.info']
11
12export const isTestCommand = (command: string): boolean => TEST_COMMAND.test(command)
13
14export const workingDirectory = (command: string, sessionCwd: string): string => {
15  const target = LEADING_CD.exec(command)?.[1]?.replace(/^["']|["']$/g, '')
16
17  if(target === undefined) {
18    return sessionCwd
19  }
20
21  return target.startsWith('/') ? target : `${sessionCwd}/${target}`
22}
23
24const attribute = (attributes: string, name: string): string | undefined =>
25  new RegExp(`\\b${name}="([^"]*)"`).exec(attributes)?.[1]
26
27const percentOf = (covered: number, total: number): number =>
28  total === 0 ? PERCENT : Math.round((covered / total) * PERCENT * 10) / 10
29
30const summarize = (lines: Map<string, Map<string, boolean>>): Coverage => {
31  let covered = 0
32  let total = 0
33  const modules = [...lines].map(([name, moduleLines]) => {
34    const moduleCovered = [...moduleLines.values()].filter(Boolean).length
35    covered += moduleCovered
36    total += moduleLines.size
37
38    return { name, percent: percentOf(moduleCovered, moduleLines.size) }
39  })
40
41  return { percent: percentOf(covered, total), modules: modules.sort((a, b) => a.percent - b.percent) }
42}
43
44const recordLine = (lines: Map<string, Map<string, boolean>>, module: string, key: string, isCovered: boolean) => {
45  const moduleLines = lines.get(module) ?? new Map<string, boolean>()
46  moduleLines.set(key, (moduleLines.get(key) ?? false) || isCovered)
47  lines.set(module, moduleLines)
48}
49
50export const parseCobertura = (reports: readonly string[]): Coverage => {
51  const lines = new Map<string, Map<string, boolean>>()
52
53  for(const report of reports) {
54    const source = /<source>([^<]*)<\/source>/.exec(report)?.[1]?.trim() ?? ''
55
56    for(const [, packageAttributes = '', packageBody = ''] of report.matchAll(PACKAGE)) {
57      const module = attribute(packageAttributes, 'name') || '.'
58
59      for(const [, classAttributes = '', classBody = ''] of packageBody.matchAll(CLASS)) {
60        const file = `${source}${attribute(classAttributes, 'filename') ?? ''}`
61
62        for(const [, lineAttributes = ''] of classBody.matchAll(LINE)) {
63          const hits = Number(attribute(lineAttributes, 'hits') ?? 0)
64          recordLine(lines, module, `${file}:${attribute(lineAttributes, 'number')}`, hits > 0)
65        }
66      }
67    }
68  }
69
70  return summarize(lines)
71}
72
73export const parseLcov = (report: string, root: string): Coverage => {
74  const lines = new Map<string, Map<string, boolean>>()
75
76  for(const record of report.split('end_of_record')) {
77    const file = /^SF:(.*)$/m.exec(record)?.[1]?.trim()
78
79    if(file === undefined) {
80      continue
81    }
82
83    const module = file.replace(`${root}/`, '').replace(/\/[^/]*$/, '') || '.'
84
85    for(const [, number = '', hits = '0'] of record.matchAll(/^DA:(\d+),(\d+)/gm)) {
86      recordLine(lines, module, `${file}:${number}`, Number(hits) > 0)
87    }
88  }
89
90  return summarize(lines)
91}
92
93export const HISTORY_LENGTH = 5
94const GAUGE_LINE = '━'
95const GAUGE_MARK = '┃'
96
97export type GaugePart = { text: string; kind: 'covered' | 'missing' | 'threshold' }
98
99export const appendHistory = (history: readonly number[], percent: number): number[] =>
100  [...history, percent].slice(-HISTORY_LENGTH)
101
102export const gauge = (percent: number, threshold: number, width: number): GaugePart[] => {
103  const cells = Math.max(2, Math.round(width))
104  const mark = Math.min(cells - 1, Math.round((threshold / PERCENT) * cells))
105  const covered = Math.round((Math.min(PERCENT, Math.max(0, percent)) / PERCENT) * cells)
106  const kinds = Array.from({ length: cells }, (_, cell): GaugePart['kind'] => {
107    if(cell === mark) {
108      return 'threshold'
109    }
110
111    return cell < covered ? 'covered' : 'missing'
112  })
113
114  return kinds.reduce<GaugePart[]>((parts, kind) => {
115    const char = kind === 'threshold' ? GAUGE_MARK : GAUGE_LINE
116    const previous = parts.at(-1)
117
118    return previous?.kind === kind
119      ? [...parts.slice(0, -1), { kind, text: previous.text + char }]
120      : [...parts, { kind, text: char }]
121  }, [])
122}
123
124export const formatDelta = (history: readonly number[]): string | undefined => {
125  const [previous, current] = history.slice(-2)
126
127  if(previous === undefined || current === undefined || previous === current) {
128    return undefined
129  }
130
131  const delta = Math.round((current - previous) * 10) / 10
132
133  return delta > 0 ? `↑${delta.toFixed(1)}` : `↓${(-delta).toFixed(1)}`
134}
135
136export const withHistory = (saved: Snapshot | null): Snapshot | null =>
137  saved === null || Array.isArray(saved.history) ? saved : { ...saved, history: [saved.percent] }
138
types/index.d.ts 12 lines
1export type ModuleCoverage = { name: string; percent: number }
2
3export type Coverage = { percent: number; modules: ModuleCoverage[] }
4
5export type Snapshot = Coverage & { history: number[]; source: string }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'coverage-band': { last: Snapshot | null }
10  }
11}
12