Fighting-game combo meter for Claude Code: every clean tool call is a hit, every error breaks the chain. Ranks D to SSS, special moves, and an all-time high…

Claude Combo turns your Claude Code session into an arcade fight. Every clean tool call is a hit, every error breaks the chain, and you climb from D to SSS while a pixel-art counter, a status line and chiptune effects keep score. It's a free, open-source Claude Code mod (a plugin of function hooks): a streak counter, combo tracker and status line in one.
▀▄▀ ▀█▀ ▀█▀ ▐ B ▌ BLAZING ×2 1,240 pts
▄▀▄ █ ▄▄█ ████████████░░░░░░░░ 5 to A
✦ RED→GREEN TATSUMAKI pnpm test +250
Why? A long agent session is hours of tool calls. Claude Combo turns "did that run clean?" into a number you can see, so a failing test breaks your streak and a green one feels good.
From the marketplace (recommended):
claude plugin marketplace add sarthak2511/claude-combo
claude plugin install combo-meter@sarthak-mods
From a local clone, for one session:
git clone https://github.com/sarthak2511/claude-combo
claude --plugin-dir ./claude-combo
Then run a few tool calls. The band appears above the prompt after your first hit. Type /combo for the fight card.
Each tool call is a move. Points are multiplied by your combo: ×2 from 10 hits, up to ×5.
| Tool call | Move | Points |
|---|---|---|
| Read | JAB | 10 |
| Grep / Glob | SCOUT | 10 |
| Edit | SLASH | 25 |
| Write | UPPERCUT | 30 |
| Bash | HADOUKEN | 20 |
| Bash running tests | RISING TEST | 50 |
git commit | SEAL | 60 |
| Agent / subagent | ASSIST | 40 |
| MCP tool | SUMMON | 20 |
| Special | How to land it | Bonus |
|---|---|---|
| RED→GREEN TATSUMAKI | Read → Edit → passing test | +150 |
| SEEK & STRIKE | Grep → Read → Edit | +80 |
| TRIPLE SLASH | Three edits in a row | +100 |
| COMEBACK! | Tests pass after they failed | +200 |
| FLAWLESS VICTORY | git commit on a 20+ combo | +300 |
| PERFECT TURN | 8+ hits in one turn, zero errors | +10 per hit |
D DECENT (3) · C CLEAN (6) · B BLAZING (10) · A ATOMIC (16) · S SHIPPING (25) · SS SENIOR STAFF (40) · SSS SMOKIN' SHIP STYLE (60)
A tool call that errors: a failing command, a failing test, an edit that didn't apply. A call you deny is a parry. It doesn't count as a hit, but it doesn't break the chain either.
⚡ x23 A · 4,180 pts.wav files are generated by tools/make-sfx.py./combo shows the session's fight card. /combo reset-best clears your all-time record.Change them in /config:
| Option | Default | |
|---|---|---|
sound | true | Chiptune effects (macOS) |
style | big | big pixel counter, or compact one-line text band |
Claude Combo only hooks session.start, turn.start, tool.call, turn.complete, session.end, command.run (/combo) and ui.render (the band). It reads each tool call's name and its file path or command head to name the move. It makes no network requests, never reads file contents, and stores only your best combo and score through $.store. Run claude plugin validate . to see the full list of calls it makes.
Does it slow Claude Code down? No. Each hook does a few pure-function updates, and the sound is fire-and-forget.
Does it change what Claude does? No. It observes tool calls and always passes them through unchanged.
Does it work in the desktop app and VS Code? Yes, with a text band instead of the pixel counter.
Can I change the ranks, points or moves? They live in hooks/combo.ts, a pure file with no engine dependencies.
claude plugin validate .
claude plugin test .
python3 tools/make-sfx.py # regenerate sfx/*.wav
Issues and PRs are welcome. New moves and specials especially.
See the community catalogue awesome-claude-code-mods for status lines, dashboards, guardrails and games.
MIT. Made by Sarthak Bhatore.
hooks/register.tsx 292 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ComboBest, ComboRun } from '../types'
5import {
6 EMPTY_BEST,
7 EMPTY_RUN,
8 WARMUP_HEAT,
9 award,
10 bar,
11 clip,
12 formatScore,
13 kindOf,
14 land,
15 mergeBest,
16 multiplierOf,
17 nextRankOf,
18 parry,
19 perfectBonus,
20 progressOf,
21 rankOf,
22 shouldToastBreak,
23 smash,
24 startTurn,
25 targetOf,
26} from './combo'
27import { TRACK_COLOR, bigText, heatBar, hex } from './pixel'
28
29const run = atom({ plugin: 'combo-meter', key: 'run' } as const, EMPTY_RUN)
30const best = atom({ plugin: 'combo-meter', key: 'best' } as const, EMPTY_BEST)
31
32const STORE_BEST = 'best'
33const COMMAND = 'combo'
34const METER_MAX = 28
35const METER_MIN = 10
36/** Below this many columns the big counter does not fit beside the meter. */
37const BIG_MIN_COLUMNS = 56
38
39const SFX = {
40 rankup: 'sfx/rankup.wav',
41 special: 'sfx/special.wav',
42 break: 'sfx/break.wav',
43 record: 'sfx/record.wav',
44 perfect: 'sfx/perfect.wav',
45} as const
46
47type Sfx = keyof typeof SFX
48
49function statusOf(current: ComboRun): string | undefined {
50 if (current.combo === 0) {
51 return current.broken === null ? undefined : `✖ combo x${current.broken.combo} broken`
52 }
53 const rank = rankOf(current.combo)
54 const label = rank === null ? '' : ` ${rank.letter}`
55 return `⚡ x${current.combo}${label} · ${formatScore(current.score)} pts`
56}
57
58function readBest(stored: unknown): ComboBest {
59 if (typeof stored !== 'object' || stored === null) return EMPTY_BEST
60 const held: Readonly<Record<string, unknown>> = { ...stored }
61 return {
62 combo: typeof held.combo === 'number' ? held.combo : 0,
63 score: typeof held.score === 'number' ? held.score : 0,
64 at: typeof held.at === 'string' ? held.at : null,
65 }
66}
67
68// Effects are a flourish: a clip that cannot play (no player on this
69// system) must never cost the hook that triggered it.
70function play($: EngineInterface, sfx: Sfx, isOn: boolean): void {
71 if (!isOn) return
72 $.audio.play({ asset: SFX[sfx] }).catch(() => undefined)
73}
74
75async function saveBest($: EngineInterface): Promise<void> {
76 const current = await read($, run)
77 const at = new Date(await $.clock.now()).toISOString()
78 const merged = await update($, best, held => mergeBest(held, current, at))
79 await $.store.set(STORE_BEST, merged)
80}
81
82export const register: Register = (on, options) => {
83 const isSoundOn = options.sound !== false
84 const isCompact = options.style === 'compact'
85
86 on('session.start', async ($, e, next) => {
87 const kept = readBest(await $.store.get(STORE_BEST))
88 await update($, best, () => kept)
89 await $.command.register({
90 name: COMMAND,
91 description: 'Combo Meter: this session’s fight card (`/combo reset-best` clears the all-time record)',
92 })
93
94 return next(e)
95 })
96
97 on('turn.start', async ($, e, next) => {
98 await update($, run, startTurn)
99
100 return next(e)
101 })
102
103 on('tool.call', async ($, e, next) => {
104 const input: Readonly<Record<string, unknown>> = e
105 const tool = String(e.tool)
106 const command = typeof input.command === 'string' ? input.command : undefined
107 const kind = kindOf(tool, command)
108 const target = targetOf(input)
109
110 const ran = await next(e)
111
112 if (ran.deny !== undefined) {
113 await update($, run, parry)
114 return ran
115 }
116
117 if (ran.isError === true) {
118 const before = await read($, run)
119 const by = clip(target || tool, 32)
120 const after = await update($, run, held => smash(held, kind, by))
121 if (shouldToastBreak(before.combo)) {
122 $.ui.toast(`💥 COMBO BROKEN x${before.combo} — ${clip(target || tool, 40)}`)
123 play($, 'break', isSoundOn)
124 }
125 $.ui.status(statusOf(after))
126 return ran
127 }
128
129 const record = await read($, best)
130 const after = await update($, run, held => land(held, record, kind, target))
131 const hit = after.last
132 if (hit !== null) {
133 const isNewRecord = record.combo > 0 && hit.combo === record.combo + 1
134 if (hit.special !== null) $.ui.toast(`✨ ${hit.special} +${formatScore(hit.points)}`)
135 if (hit.rankUp !== null) $.ui.toast(`▲ RANK ${hit.rankUp} x${hit.combo}`)
136 if (isNewRecord) $.ui.toast(`🏆 NEW RECORD x${hit.combo} beats x${record.combo}`)
137 // One clip per hit, the biggest moment wins.
138 if (isNewRecord) play($, 'record', isSoundOn)
139 else if (hit.special !== null) play($, 'special', isSoundOn)
140 else if (hit.rankUp !== null) play($, 'rankup', isSoundOn)
141 }
142 $.ui.status(statusOf(after))
143
144 return ran
145 })
146
147 on('turn.complete', async ($, e, next) => {
148 const isMainTurn = e.agentId === undefined && !e.isAborted
149 if (isMainTurn) {
150 const current = await read($, run)
151 const bonus = perfectBonus(current)
152 if (bonus > 0) {
153 const after = await update($, run, held => award(held, bonus))
154 $.ui.toast(`🥋 PERFECT TURN ${current.turnHits} hits, no misses +${formatScore(bonus)}`)
155 $.ui.status(statusOf(after))
156 play($, 'perfect', isSoundOn)
157 }
158 await saveBest($)
159 }
160
161 return next(e)
162 })
163
164 on('session.end', async ($, e, next) => {
165 await saveBest($)
166
167 return next(e)
168 })
169
170 on('command.run', { command: COMMAND }, async ($, e) => {
171 if (e.args.trim() === 'reset-best') {
172 await update($, best, () => EMPTY_BEST)
173 await $.store.delete(STORE_BEST)
174 return { text: 'Combo Meter: all-time record cleared.' }
175 }
176
177 const current = await read($, run)
178 const record = await read($, best)
179 const rank = rankOf(current.maxCombo)
180 const lines = [
181 '🥊 COMBO METER — fight card',
182 '',
183 ` Current combo x${current.combo}`,
184 ` Best this fight x${current.maxCombo}${rank === null ? '' : ` (${rank.letter} ${rank.name})`}`,
185 ` Score ${formatScore(current.score)}`,
186 ` Hits / breaks ${current.hits} / ${current.breaks}`,
187 ` Specials landed ${current.specials}`,
188 ` Parried ${current.parries}`,
189 '',
190 ` All-time x${Math.max(record.combo, current.maxCombo)} · ${formatScore(Math.max(record.score, current.score))} pts`,
191 ]
192
193 return { text: lines.join('\n') }
194 })
195
196 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
197 if (e.props.hasSurvey) return next(e)
198
199 const current = await read($, run)
200 const record = await read($, best)
201 const { Box, Text } = $.ui.resolve(e)
202 const columns = e.props.bodyColumns
203
204 if (current.combo === 0) {
205 if (current.broken === null) return next(e)
206 return (
207 <Box flexDirection="row">
208 <Text color="#f87171" bold>
209 {`✖ K.O. x${current.broken.combo} chain`}
210 </Text>
211 <Text dimColor wrap="truncate-end">
212 {` broken by ${current.broken.by} · best x${Math.max(record.combo, current.maxCombo)} · next hit starts a new round`}
213 </Text>
214 </Box>
215 )
216 }
217
218 const rank = rankOf(current.combo)
219 const upcoming = nextRankOf(current.combo)
220 const hit = current.last
221 const heat = rank?.heat ?? WARMUP_HEAT
222 const accent = hex(heat[1])
223 const meterColumns = Math.max(METER_MIN, Math.min(METER_MAX, Math.floor(columns / 3)))
224 const progress = progressOf(current.combo)
225
226 const title = (
227 <Box flexDirection="row">
228 <Text color="#0b0b0f" backgroundColor={accent} bold>
229 {` ${rank?.letter ?? '·'} `}
230 </Text>
231 <Text color={accent} bold>
232 {` ${rank?.name ?? 'WARMING UP'}`}
233 </Text>
234 <Text color="#facc15" bold>{` ×${multiplierOf(current.combo)}`}</Text>
235 <Text dimColor>{` ${formatScore(current.score)} pts`}</Text>
236 {current.isRecord && <Text color="#facc15">{' 🏆 NEW BEST'}</Text>}
237 </Box>
238 )
239 const toNext = (
240 <Text dimColor>
241 {upcoming === null ? ' MAX RANK' : ` ${upcoming.at - current.combo} to ${upcoming.letter}`}
242 </Text>
243 )
244 const lastHit = hit !== null && (
245 <Box flexDirection="row">
246 <Text color={hit.special === null ? accent : '#facc15'} bold>
247 {hit.special === null ? hit.move : `✦ ${hit.special}`}
248 </Text>
249 <Text dimColor wrap="truncate-end">
250 {`${hit.target === '' ? '' : ` ${hit.target}`} +${formatScore(hit.points)}`}
251 </Text>
252 </Box>
253 )
254
255 // Off the terminal there is no Raster: the same card, drawn in text.
256 if (e.surface !== 'terminal' || isCompact || columns < BIG_MIN_COLUMNS) {
257 return (
258 <Box flexDirection="column">
259 <Box flexDirection="row">
260 <Text color={accent} bold>{`x${current.combo} `}</Text>
261 {title}
262 </Box>
263 {!isCompact && (
264 <Box flexDirection="row">
265 <Text color={accent}>{bar(progress, meterColumns)}</Text>
266 {toNext}
267 </Box>
268 )}
269 </Box>
270 )
271 }
272
273 const { Raster } = $.ui.resolve(e)
274 const counter = bigText(`x${current.combo}`, heat)
275 const meter = heatBar(progress, meterColumns, heat)
276
277 return (
278 <Box flexDirection="row">
279 <Raster key="counter" columns={counter.columns} rows={counter.rows} cells={counter.cells} />
280 <Box flexDirection="column" marginLeft={2}>
281 {title}
282 <Box flexDirection="row">
283 <Raster key="meter" columns={meter.columns} rows={meter.rows} cells={meter.cells} />
284 {toNext}
285 </Box>
286 {lastHit || <Text color={hex(TRACK_COLOR)}>ready</Text>}
287 </Box>
288 </Box>
289 )
290 })
291}
292hooks/combo.ts 255 lines1// The fight itself: pure functions over plain data, so the state updater can
2// retry them on a version miss and the tests can drive them without an engine.
3import type { ComboBest, ComboKind, ComboRun } from '../types'
4
5// Each rank's heat runs from its first color to its second: the big combo
6// number and the meter are shaded across it, the rank's name in the second.
7export type Rank = { letter: string; name: string; at: number; heat: readonly [number, number] }
8
9export const RANKS: readonly Rank[] = [
10 { letter: 'D', name: 'DECENT', at: 3, heat: [0x6b7280, 0xa1a1aa] },
11 { letter: 'C', name: 'CLEAN', at: 6, heat: [0x0891b2, 0x22d3ee] },
12 { letter: 'B', name: 'BLAZING', at: 10, heat: [0x16a34a, 0x4ade80] },
13 { letter: 'A', name: 'ATOMIC', at: 16, heat: [0xf59e0b, 0xfde047] },
14 { letter: 'S', name: 'SHIPPING', at: 25, heat: [0xdb2777, 0xf9a8d4] },
15 { letter: 'SS', name: 'SENIOR STAFF', at: 40, heat: [0xdc2626, 0xfb923c] },
16 { letter: 'SSS', name: "SMOKIN' SHIP STYLE", at: 60, heat: [0xa855f7, 0xfacc15] },
17]
18
19/** Before the first rank: still warming up. */
20export const WARMUP_HEAT: readonly [number, number] = [0x4b5563, 0x6b7280]
21
22// Matches the test runners people actually type, wrapped in a package manager or not.
23const TEST_COMMAND = /\b(test|tests|vitest|jest|pytest|mocha|ava|rspec|phpunit|go test|cargo test|test:[\w-]+)\b/
24const COMMIT_COMMAND = /\bgit\s+commit\b/
25
26const TRAIL_LENGTH = 6
27const MAX_MULTIPLIER = 5
28const BREAK_TOAST_AT = 5
29const PERFECT_TURN_AT = 8
30
31export type Move = { kind: ComboKind; move: string; base: number }
32
33const MOVES: Record<ComboKind, Omit<Move, 'kind'>> = {
34 read: { move: 'JAB', base: 10 },
35 scout: { move: 'SCOUT', base: 10 },
36 edit: { move: 'SLASH', base: 25 },
37 write: { move: 'UPPERCUT', base: 30 },
38 bash: { move: 'HADOUKEN', base: 20 },
39 test: { move: 'RISING TEST', base: 50 },
40 commit: { move: 'SEAL', base: 60 },
41 agent: { move: 'ASSIST', base: 40 },
42 web: { move: 'LONG RANGE', base: 15 },
43 mcp: { move: 'SUMMON', base: 20 },
44 meta: { move: 'FOCUS', base: 5 },
45 other: { move: 'STRIKE', base: 10 },
46}
47
48/** Fixed bonuses for the special moves, checked in this order. */
49const SPECIALS = {
50 comeback: { name: 'COMEBACK!', bonus: 200 },
51 flawless: { name: 'FLAWLESS VICTORY', bonus: 300 },
52 tatsumaki: { name: 'RED→GREEN TATSUMAKI', bonus: 150 },
53 seek: { name: 'SEEK & STRIKE', bonus: 80 },
54 triple: { name: 'TRIPLE SLASH', bonus: 100 },
55} as const
56
57const FLAWLESS_AT = 20
58
59export const EMPTY_RUN: ComboRun = {
60 combo: 0,
61 score: 0,
62 maxCombo: 0,
63 hits: 0,
64 breaks: 0,
65 parries: 0,
66 specials: 0,
67 trail: [],
68 last: null,
69 broken: null,
70 testsRed: false,
71 turnHits: 0,
72 turnClean: true,
73 isRecord: false,
74}
75
76export const EMPTY_BEST: ComboBest = { combo: 0, score: 0, at: null }
77
78export function kindOf(tool: string, command: string | undefined): ComboKind {
79 switch (tool) {
80 case 'Read':
81 case 'NotebookRead':
82 return 'read'
83 case 'Grep':
84 case 'Glob':
85 case 'LS':
86 case 'ToolSearch':
87 return 'scout'
88 case 'Edit':
89 case 'MultiEdit':
90 case 'NotebookEdit':
91 return 'edit'
92 case 'Write':
93 return 'write'
94 case 'Agent':
95 case 'Task':
96 return 'agent'
97 case 'WebFetch':
98 case 'WebSearch':
99 return 'web'
100 case 'TodoWrite':
101 case 'Skill':
102 return 'meta'
103 case 'Bash':
104 if (command !== undefined && COMMIT_COMMAND.test(command)) return 'commit'
105 if (command !== undefined && TEST_COMMAND.test(command)) return 'test'
106 return 'bash'
107 }
108 return tool.startsWith('mcp__') ? 'mcp' : 'other'
109}
110
111export function moveOf(kind: ComboKind): Move {
112 return { kind, ...MOVES[kind] }
113}
114
115export function rankOf(combo: number): Rank | null {
116 let found: Rank | null = null
117 for (const rank of RANKS) {
118 if (combo >= rank.at) found = rank
119 }
120 return found
121}
122
123export function nextRankOf(combo: number): Rank | null {
124 return RANKS.find(rank => combo < rank.at) ?? null
125}
126
127export function multiplierOf(combo: number): number {
128 return Math.min(MAX_MULTIPLIER, 1 + Math.floor(combo / 10))
129}
130
131function endsWith(trail: readonly ComboKind[], tail: readonly ComboKind[]): boolean {
132 if (trail.length < tail.length) return false
133 const start = trail.length - tail.length
134 return tail.every((kind, i) => trail[start + i] === kind)
135}
136
137function specialOf(
138 run: ComboRun,
139 trail: readonly ComboKind[],
140 kind: ComboKind,
141 combo: number,
142): { name: string; bonus: number } | null {
143 if (kind === 'test' && run.testsRed) return SPECIALS.comeback
144 if (kind === 'commit' && combo >= FLAWLESS_AT) return SPECIALS.flawless
145 if (endsWith(trail, ['read', 'edit', 'test'])) return SPECIALS.tatsumaki
146 if (endsWith(trail, ['scout', 'read', 'edit'])) return SPECIALS.seek
147 if (endsWith(trail, ['edit', 'edit', 'edit'])) return SPECIALS.triple
148 return null
149}
150
151/** A tool call that came back clean. */
152export function land(run: ComboRun, best: ComboBest, kind: ComboKind, target: string): ComboRun {
153 const { move, base } = moveOf(kind)
154 const combo = run.combo + 1
155 const trail = [...run.trail, kind].slice(-TRAIL_LENGTH)
156 const special = specialOf(run, trail, kind, combo)
157 const points = base * multiplierOf(combo) + (special?.bonus ?? 0)
158 const before = rankOf(run.combo)
159 const after = rankOf(combo)
160 const rankUp = after !== null && after !== before ? `${after.letter} ${after.name}` : null
161
162 return {
163 ...run,
164 combo,
165 score: run.score + points,
166 maxCombo: Math.max(run.maxCombo, combo),
167 hits: run.hits + 1,
168 specials: run.specials + (special === null ? 0 : 1),
169 // A special consumes its trail, so the same three calls never score twice.
170 trail: special === null ? trail : [],
171 last: { move, target, points, combo, special: special?.name ?? null, rankUp },
172 broken: null,
173 testsRed: kind === 'test' ? false : run.testsRed,
174 turnHits: run.turnHits + 1,
175 isRecord: run.isRecord || combo > best.combo,
176 }
177}
178
179/** A tool call that errored: the chain is over. */
180export function smash(run: ComboRun, kind: ComboKind, by: string): ComboRun {
181 return {
182 ...run,
183 combo: 0,
184 breaks: run.combo > 0 ? run.breaks + 1 : run.breaks,
185 trail: [],
186 broken: run.combo > 0 ? { combo: run.combo, by } : run.broken,
187 testsRed: kind === 'test' ? true : run.testsRed,
188 turnClean: false,
189 }
190}
191
192/** A call refused before it ran (a permission denial): blocked, not broken. */
193export function parry(run: ComboRun): ComboRun {
194 return { ...run, parries: run.parries + 1 }
195}
196
197export function startTurn(run: ComboRun): ComboRun {
198 return { ...run, turnHits: 0, turnClean: true }
199}
200
201/** A clean turn with enough hits is a perfect round, worth a bonus. */
202export function perfectBonus(run: ComboRun): number {
203 return run.turnClean && run.turnHits >= PERFECT_TURN_AT ? run.turnHits * 10 : 0
204}
205
206export function award(run: ComboRun, bonus: number): ComboRun {
207 return { ...run, score: run.score + bonus }
208}
209
210export function shouldToastBreak(combo: number): boolean {
211 return combo >= BREAK_TOAST_AT
212}
213
214export function mergeBest(best: ComboBest, run: ComboRun, at: string): ComboBest {
215 const isBetter = run.maxCombo > best.combo || run.score > best.score
216 return isBetter
217 ? { combo: Math.max(best.combo, run.maxCombo), score: Math.max(best.score, run.score), at }
218 : best
219}
220
221/** A short name for what a call hit: the file's basename or the command's head. */
222export function targetOf(input: Readonly<Record<string, unknown>>): string {
223 const path = [input.file_path, input.notebook_path, input.path].find(
224 (value): value is string => typeof value === 'string',
225 )
226 if (path !== undefined) return path.split('/').pop() ?? path
227 for (const key of ['command', 'pattern', 'query', 'url', 'description'] as const) {
228 const value = input[key]
229 if (typeof value === 'string') return clip(value.trim().split('\n')[0] ?? '', 32)
230 }
231 return ''
232}
233
234export function clip(text: string, length: number): string {
235 return text.length > length ? `${text.slice(0, length - 1)}…` : text
236}
237
238export function bar(filled: number, width: number): string {
239 const cells = Math.max(0, Math.min(width, Math.round(filled * width)))
240 return '█'.repeat(cells) + '░'.repeat(width - cells)
241}
242
243export function formatScore(score: number): string {
244 // No Intl assumption in the hooks environment: group the digits by hand.
245 return String(Math.trunc(score)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
246}
247
248/** Progress from the current rank's threshold to the next one, 0 to 1. */
249export function progressOf(combo: number): number {
250 const next = nextRankOf(combo)
251 if (next === null) return 1
252 const floor = rankOf(combo)?.at ?? 0
253 return (combo - floor) / (next.at - floor)
254}
255hooks/pixel.ts 94 lines1// Arcade art for the terminal: a 3x5 pixel font drawn with half blocks, so
2// five pixel rows fit in three terminal rows, and a heat-gradient meter.
3// Both encode straight to a Raster's cells, never a Box per cell.
4
5const DEFAULT_COLOR = 0x01000000
6const UPPER = 0x2580 // ▀
7const LOWER = 0x2584 // ▄
8const FULL = 0x2588 // █
9const SPACE = 0x20
10const TRACK = 0x2591 // ░
11
12export const TRACK_COLOR = 0x3a3f4b
13
14// Each glyph: five rows of three pixels, '#' lit.
15const GLYPHS: Record<string, readonly string[]> = {
16 '0': ['###', '#.#', '#.#', '#.#', '###'],
17 '1': ['.#.', '##.', '.#.', '.#.', '###'],
18 '2': ['###', '..#', '###', '#..', '###'],
19 '3': ['###', '..#', '.##', '..#', '###'],
20 '4': ['#.#', '#.#', '###', '..#', '..#'],
21 '5': ['###', '#..', '###', '..#', '###'],
22 '6': ['###', '#..', '###', '#.#', '###'],
23 '7': ['###', '..#', '.#.', '.#.', '.#.'],
24 '8': ['###', '#.#', '###', '#.#', '###'],
25 '9': ['###', '#.#', '###', '..#', '###'],
26 x: ['...', '#.#', '.#.', '#.#', '...'],
27}
28
29const GLYPH_ROWS = 5
30const CELL_ROWS = 3
31
32/** A rank's color pair: where its gradient starts and where it ends. */
33export type Heat = readonly [number, number]
34
35export function hex(color: number): string {
36 return `#${color.toString(16).padStart(6, '0')}`
37}
38
39function mix(from: number, to: number, t: number): number {
40 const channel = (shift: number) => {
41 const a = (from >> shift) & 0xff
42 const b = (to >> shift) & 0xff
43 return Math.round(a + (b - a) * t) << shift
44 }
45 return channel(16) | channel(8) | channel(0)
46}
47
48function encode(words: readonly number[]): string {
49 const bytes = new Uint8Array(new Uint32Array(words).buffer)
50 let binary = ''
51 for (const byte of bytes) binary += String.fromCharCode(byte)
52 return btoa(binary)
53}
54
55export type Cells = { cells: string; columns: number; rows: number }
56
57/** `text` (digits and `x`) as big pixel type, shaded left to right across `heat`. */
58export function bigText(text: string, heat: Heat): Cells {
59 const glyphs = [...text].map(char => GLYPHS[char] ?? GLYPHS['0'] ?? [])
60 const columns = Math.max(1, glyphs.length * 4 - 1)
61 const lit = (x: number, y: number): boolean => {
62 if (y >= GLYPH_ROWS) return false
63 const glyph = glyphs[Math.floor(x / 4)]
64 const column = x % 4
65 return column < 3 && glyph?.[y]?.[column] === '#'
66 }
67
68 const words: number[] = []
69 for (let row = 0; row < CELL_ROWS; row += 1) {
70 for (let x = 0; x < columns; x += 1) {
71 const top = lit(x, row * 2)
72 const bottom = lit(x, row * 2 + 1)
73 const glyph = top && bottom ? FULL : top ? UPPER : bottom ? LOWER : SPACE
74 const color = mix(heat[0], heat[1], columns === 1 ? 1 : x / (columns - 1))
75 words.push(glyph, glyph === SPACE ? DEFAULT_COLOR : color, DEFAULT_COLOR)
76 }
77 }
78 return { cells: encode(words), columns, rows: CELL_ROWS }
79}
80
81/** A one-row meter: `filled` of `columns` lit along the heat gradient, the rest a dim track. */
82export function heatBar(filled: number, columns: number, heat: Heat): Cells {
83 const lit = Math.max(0, Math.min(columns, Math.round(filled * columns)))
84 const words: number[] = []
85 for (let x = 0; x < columns; x += 1) {
86 if (x < lit) {
87 words.push(FULL, mix(heat[0], heat[1], columns === 1 ? 1 : x / (columns - 1)), DEFAULT_COLOR)
88 } else {
89 words.push(TRACK, TRACK_COLOR, DEFAULT_COLOR)
90 }
91 }
92 return { cells: encode(words), columns, rows: 1 }
93}
94types/index.d.ts 62 lines1/** The family of a tool call, which decides its move name and points. */
2export type ComboKind =
3 | 'read'
4 | 'scout'
5 | 'edit'
6 | 'write'
7 | 'bash'
8 | 'test'
9 | 'commit'
10 | 'agent'
11 | 'web'
12 | 'mcp'
13 | 'meta'
14 | 'other'
15
16/** One landed hit: the move, what it hit, and the combo it brought you to. */
17export type ComboHit = {
18 move: string
19 target: string
20 points: number
21 combo: number
22 special: string | null
23 rankUp: string | null
24}
25
26/** The chain that just ended, shown until the next hit lands. */
27export type ComboBreak = {
28 combo: number
29 by: string
30}
31
32/** One session's fight. */
33export type ComboRun = {
34 combo: number
35 score: number
36 maxCombo: number
37 hits: number
38 breaks: number
39 parries: number
40 specials: number
41 trail: ComboKind[]
42 last: ComboHit | null
43 broken: ComboBreak | null
44 testsRed: boolean
45 turnHits: number
46 turnClean: boolean
47 isRecord: boolean
48}
49
50/** The all-time high score, kept across sessions. */
51export type ComboBest = {
52 combo: number
53 score: number
54 at: string | null
55}
56
57declare module 'claude-code' {
58 interface PluginState {
59 'combo-meter': { run: ComboRun; best: ComboBest }
60 }
61}
62