SLOPSHOPPER

combo-meter

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…

newbandguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-05SARTHAK2511/claude-combo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · combo-meter
› fix the failing auth test and add an audit log call ╭───────────────────────────────╮ │ combo-meter │ ⏺ Read(src/auth.ts) │ 💥 COMBO BROKEN x5 — bun test │ ⎿ Read 6 lines ╰───────────────────────────────╯ ⏺ Update(src/auth.ts) ╭─────────────────────╮ ⎿ Added 2 lines, removed 1 line │ combo-meter │ ⏺ Bash(bun test) │ ▲ RANK D DECENT x3 │ ⎿ 3 pass, 1 fail ╰─────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /combo ⎿ combo-meter: 🥊 COMBO METER — fight card ⎿ combo-meter: ⎿ combo-meter: Current combo x3 ⎿ combo-meter: Best this fight x5 (D DECENT) ⎿ combo-meter: Score 165 ⎿ combo-meter: Hits / breaks 8 / 1 ▄ ▄ ▀▀█ D DECENT ×1 165 pts 🏆 NEW BEST ▄▀▄ ▀█ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3 to C ▀▀▀ HADOUKEN cat .env +20 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ combo-meter: ⚡ x3 D · 165 pts

Draws

Band
▄ ▄ ▀▀█ D DECENT ×1 165 pts 🏆 NEW BEST ▄▀▄ ▀█ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3 to C ▀▀▀ HADOUKEN cat .env +20
README

🥊 Claude Combo: a fighting-game combo meter for Claude Code

License: MIT Claude Code mod Tests

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.

Install

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.

How scoring works

Each tool call is a move. Points are multiplied by your combo: ×2 from 10 hits, up to ×5.

Tool callMovePoints
ReadJAB10
Grep / GlobSCOUT10
EditSLASH25
WriteUPPERCUT30
BashHADOUKEN20
Bash running testsRISING TEST50
git commitSEAL60
Agent / subagentASSIST40
MCP toolSUMMON20

Special moves

SpecialHow to land itBonus
RED→GREEN TATSUMAKIRead → Edit → passing test+150
SEEK & STRIKEGrep → Read → Edit+80
TRIPLE SLASHThree edits in a row+100
COMEBACK!Tests pass after they failed+200
FLAWLESS VICTORYgit commit on a 20+ combo+300
PERFECT TURN8+ hits in one turn, zero errors+10 per hit

Ranks

D DECENT (3) · C CLEAN (6) · B BLAZING (10) · A ATOMIC (16) · S SHIPPING (25) · SS SENIOR STAFF (40) · SSS SMOKIN' SHIP STYLE (60)

What breaks a combo

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.

What you see

  • Band above the prompt: a pixel-art combo counter drawn with half blocks, a heat meter to the next rank and your last move. It falls back to text on desktop, VS Code and mobile surfaces.
  • Status line: ⚡ x23 A · 4,180 pts
  • Toasts for rank ups, specials, breaks, new records and perfect turns.
  • Chiptune sound effects for the big moments. They play on macOS only, and the .wav files are generated by tools/make-sfx.py.
  • /combo shows the session's fight card. /combo reset-best clears your all-time record.

Options

Change them in /config:

OptionDefault
soundtrueChiptune effects (macOS)
stylebigbig pixel counter, or compact one-line text band

Footprint

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.

FAQ

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.

Development

claude plugin validate .
claude plugin test .
python3 tools/make-sfx.py   # regenerate sfx/*.wav

Issues and PRs are welcome. New moves and specials especially.

Related Claude Code mods

See the community catalogue awesome-claude-code-mods for status lines, dashboards, guardrails and games.

License

MIT. Made by Sarthak Bhatore.

Source 4 files
hooks/register.tsx 292 lines
1import { 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}
292
hooks/combo.ts 255 lines
1// 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}
255
hooks/pixel.ts 94 lines
1// 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}
94
types/index.d.ts 62 lines
1/** 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