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

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%
┃ marks the threshold.↑8.0 / ↓1.2: points gained or lost since the previous run in that folder./coverage opens a pane with every module and its bar.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:
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.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.
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
/config)| Setting | Default | |
|---|---|---|
| Placement | hint | hint: at the end of the hint line under the prompt. band: a row above the prompt. status: a pinned notice under the prompt. |
| Threshold | 80 | Minimum line coverage, in percent. |
| Gauge width | 10 | Cells in the gauge. |
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.
MIT
hooks/register.tsx 200 lines1import { 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}
200hooks/coverage.ts 138 lines1import 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] }
138types/index.d.ts 12 lines1export 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