A fighting-game combo counter above the prompt: big digits that hit harder the faster you type, with hit-stop, ranks, keys per second and your best.


A fighting-game combo counter for your Claude Code prompt. Every keystroke lands as a hit: big digits above the prompt count the combo and get knocked left and right, harder the faster you type, with a moment of hit-stop on each hit. Keep typing and the counter heats up; pause or send the prompt and the combo ends.
At the prompt of a Claude Code terminal session:
/plugin install typing-combo --marketplace VkaZas/claude-code-mods
Answer y to add the marketplace, then pick a scope (user is the first). From your shell instead (Claude Code 2.1.292 or later):
claude plugin install typing-combo --marketplace VkaZas/claude-code-mods
| Option | Values | Default |
|---|---|---|
| Language | en, zh | en |
session.start: loads your best combo and starts a 50 ms frame clock that plays the hits out.prompt.edit: on each keystroke in the prompt box, counts it and adds a colored decoration over the characters just typed. Your text and cursor pass through unchanged, and pastes are not counted.prompt.submit: when you send a prompt from the prompt box, ends the combo. The prompt itself goes through unchanged.ui.render on AbovePrompt: draws the counter, with whatever is drawn beneath it passed through unchanged below.打字连击:在提示框里打字,提示框上方出现格斗游戏式的大号连击数。每按一个键就是一次打击,数字被左右击退再弹回,打得越快震得越狠,并有停顿帧;速度分五档,颜色从白到蓝、黄、橙、红,刚打的字也会跟着变色;每 10 连和每个新段位整排震动。停 1.5 秒或发送就结束连击,粘贴不算。最高纪录跨会话保存。在 /config 里把 Language 设成 zh 就是中文。
hooks/register.tsx 287 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ComboView } from '../types'
5
6const FRAME_MS = 50
7const BREAK_MS = 1500
8const SHOW_END_MS = 1500
9const WINDOW_MS = 1000
10const MILESTONE_MS = 450
11const BASE_LEFT = 7
12
13const IDLE: ComboView = {
14 count: 0,
15 best: 0,
16 kps: 0,
17 level: 0,
18 lastKeyAt: 0,
19 hitDir: 1,
20 hitPower: 0,
21 milestoneAt: 0,
22 milestone: '',
23 endedAt: 0,
24 ended: 0,
25}
26
27const view = atom({ plugin: 'typing-combo', key: 'view' } as const, IDLE)
28const tick = atom({ plugin: 'typing-combo', key: 'tick' } as const, 0)
29
30// Faster typing, hotter colour, harder hits.
31const LEVEL_COLOR = ['#E8E8E8', '#7FC8F8', '#FFD54F', '#FF9F43', '#FF4D4D']
32const POWER = [1, 2, 3, 4, 6]
33// A hit: out, back past rest, settle. One entry per frame after the key.
34const SPRING = [1, -0.55, 0.3, -0.12, 0]
35const MILESTONE_SPRING = [1, -0.8, 0.6, -0.4, 0.2, -0.1, 0]
36const SPARKS = ['✦', '*', '+', '✧', '·']
37// Hit-stop: the hit frame holds before the knockback plays, longer for harder hits.
38const HITSTOP_MS = [30, 40, 55, 70, 85]
39const MILESTONE_HITSTOP_MS = 140
40
41// Which frame of a hit this moment is: held at 0 through the hit-stop, then the spring.
42function frameAt(elapsed: number, hitstop: number): number {
43 return elapsed < hitstop ? 0 : 1 + Math.floor((elapsed - hitstop) / FRAME_MS)
44}
45
46// The words on the band, in the language the options choose.
47type Lang = 'en' | 'zh'
48
49const TEXT = {
50 en: { ranks: ['GODLIKE!!!!', 'INSANE!!!', 'AMAZING!!', 'NICE!', 'GOOD'], ended: 'COMBO END', kps: 'keys/s', best: 'best' },
51 zh: { ranks: ['神之手!!!!', '疯狂!!!', '太强了!!', '漂亮!', '不错'], ended: '连击结束', kps: '键/秒', best: '最高' },
52}
53
54// The counts each rank starts at, highest first.
55const RANK_AT = [200, 100, 50, 25, 10]
56
57let lang: Lang = 'en'
58
59// Three rows per digit, three cells wide.
60const DIGITS: Record<string, [string, string, string]> = {
61 '0': ['█▀█', '█ █', '█▄█'],
62 '1': ['▄█ ', ' █ ', '▄█▄'],
63 '2': ['▀▀█', '█▀▀', '█▄▄'],
64 '3': ['▀▀█', ' ▀█', '▄▄█'],
65 '4': ['█ █', '▀▀█', ' █'],
66 '5': ['█▀▀', '▀▀█', '▄▄█'],
67 '6': ['█▀▀', '█▀█', '█▄█'],
68 '7': ['▀▀█', ' █', ' █'],
69 '8': ['█▀█', '█▀█', '█▄█'],
70 '9': ['█▀█', '▀▀█', '▄▄█'],
71}
72
73function bigDigits(n: number, heavy: boolean): [string, string, string] {
74 const rows: [string, string, string] = ['', '', '']
75 for (const ch of String(n)) {
76 const d = DIGITS[ch]
77 for (let r = 0; r < 3; r++) rows[r] += `${heavy ? d[r].replace(/[▀▄]/g, '█') : d[r]} `
78 }
79 return rows
80}
81
82function levelOf(kps: number): number {
83 return kps < 2 ? 0 : kps < 4 ? 1 : kps < 6 ? 2 : kps < 9 ? 3 : 4
84}
85
86function rankOf(count: number): string {
87 const i = RANK_AT.findIndex(at => count >= at)
88 return i < 0 ? '' : TEXT[lang].ranks[i]
89}
90
91// ---------- module state (a reload starts these over) ----------
92
93let times: number[] = []
94let best = 0
95let bestLoaded = false
96
97async function loadBest($: EngineInterface) {
98 if (bestLoaded) return
99 bestLoaded = true
100 try {
101 const saved = await $.store.get('best')
102 if (typeof saved === 'number') best = saved
103 } catch {
104 // a fresh record is fine
105 }
106}
107
108async function endCombo($: EngineInterface) {
109 const v = { ...IDLE, ...((await read($, view)) ?? {}) }
110 if (v.count === 0) return
111 times = []
112 if (v.count > best) {
113 best = v.count
114 try {
115 await $.store.set('best', best)
116 } catch {
117 // the record is best effort
118 }
119 }
120 await update($, view, () => ({ ...IDLE, best, endedAt: Date.now(), ended: v.count }))
121}
122
123async function keyed($: EngineInterface, typed: number) {
124 await loadBest($)
125 const now = Date.now()
126 times = [...times.filter(t => now - t < WINDOW_MS), ...Array.from({ length: typed }, () => now)]
127 const kps = times.length / (WINDOW_MS / 1000)
128 const level = levelOf(kps)
129 await update($, view, v => {
130 const cur = { ...IDLE, ...(v ?? {}) }
131 const before = now - cur.lastKeyAt < BREAK_MS ? cur.count : 0
132 const count = before + typed
133 // Every ten, and every new rank, is a milestone.
134 const rank = rankOf(count)
135 const isMilestone = Math.floor(count / 10) > Math.floor(before / 10) || rank !== rankOf(before)
136 return {
137 ...cur,
138 count,
139 kps,
140 level,
141 lastKeyAt: now,
142 // Hits land left, right, left: each one knocks the other way.
143 hitDir: -cur.hitDir || 1,
144 hitPower: POWER[level],
145 milestoneAt: isMilestone ? now : cur.milestoneAt,
146 milestone: isMilestone ? (rank !== rankOf(before) ? rank : `${Math.floor(count / 10) * 10}!`) : cur.milestone,
147 best: Math.max(best, cur.best),
148 endedAt: 0,
149 ended: 0,
150 }
151 })
152}
153
154// While a combo runs or its ending shows, the frame clock plays the hits out.
155async function frame($: EngineInterface) {
156 const v = { ...IDLE, ...((await read($, view)) ?? {}) }
157 const now = Date.now()
158 if (v.count > 0 && now - v.lastKeyAt >= BREAK_MS) {
159 await endCombo($)
160 return
161 }
162 if (v.count > 0) {
163 const kps = times.filter(t => now - t < WINDOW_MS).length / (WINDOW_MS / 1000)
164 if (levelOf(kps) !== v.level || Math.abs(kps - v.kps) >= 0.5) {
165 await update($, view, s => ({ ...IDLE, ...(s ?? {}), kps, level: levelOf(kps) }))
166 }
167 // Only while a hit or a milestone is still moving is there anything to draw.
168 const moving =
169 now - v.lastKeyAt < HITSTOP_MS[v.level] + FRAME_MS * SPRING.length ||
170 now - v.milestoneAt < MILESTONE_HITSTOP_MS + FRAME_MS * MILESTONE_SPRING.length
171 if (moving) await update($, tick, n => ((n ?? 0) + 1) % 1_000_000)
172 } else if (v.endedAt > 0) {
173 if (now - v.endedAt > SHOW_END_MS) await update($, view, s => ({ ...IDLE, ...(s ?? {}), endedAt: 0, ended: 0 }))
174 }
175}
176
177// ---------- the mod ----------
178
179export const register: Register = (on, options) => {
180 lang = (options as { language?: string } | undefined)?.language === 'zh' ? 'zh' : 'en'
181
182 on('session.start', async ($, e, next) => {
183 const started = await next(e)
184 await loadBest($)
185 await update($, view, v => ({ ...IDLE, ...(v ?? {}), best }))
186 void $.clock.every(FRAME_MS, () => frame($))
187 return started
188 })
189
190 // Every keystroke in the prompt box: count it, and light up what was typed.
191 on('prompt.edit', async ($, e, next) => {
192 const result = await next(e)
193 const isPaste = e.key === undefined && e.inputText.length > 2
194 if (e.inputText.length === 0 || isPaste) return result
195 await keyed($, Math.min(e.inputText.length, 4))
196 const v = { ...IDLE, ...((await read($, view)) ?? {}) }
197 if (v.level === 0) return result
198 // The characters just typed, in the combo's colour.
199 const end = result.cursor
200 const start = Math.max(0, end - e.inputText.length)
201 return {
202 ...result,
203 decorations: [...(result.decorations ?? []), { start, end, color: LEVEL_COLOR[v.level], bold: true }],
204 }
205 }).catch(($, e, next) => next(e))
206
207 on('prompt.submit', async ($, e, next) => {
208 if (e.origin.kind === 'composer') await endCombo($)
209 return next(e)
210 }).catch(($, e, next) => next(e))
211
212 // Above the prompt, on top of whatever else draws there.
213 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
214 // Nothing may answer beneath (a test, an engine that draws nothing there).
215 const below = await next(e).catch(() => null)
216 if (e.props.hasSurvey) return below
217 try {
218 const v = { ...IDLE, ...((await read($, view)) ?? {}) }
219 // Reading the tick redraws this band on every frame a hit is moving.
220 await read($, tick)
221 const showing = v.count > 0 ? v.count : v.endedAt > 0 ? v.ended : 0
222 if (showing === 0) return below
223 const { Box, Text } = $.ui.resolve(e) as any
224 const now = Date.now()
225 const isEnding = v.count === 0
226
227 // Which frame of the last hit, and of the last milestone, this is.
228 const f = isEnding ? SPRING.length : frameAt(now - v.lastKeyAt, HITSTOP_MS[v.level])
229 const mf = isEnding ? MILESTONE_SPRING.length : frameAt(now - v.milestoneAt, MILESTONE_HITSTOP_MS)
230 const isHit = f === 0
231 const isMilestone = mf < MILESTONE_SPRING.length && now - v.milestoneAt < MILESTONE_HITSTOP_MS + MILESTONE_MS
232 const knock = Math.round(v.hitDir * v.hitPower * (SPRING[f] ?? 0))
233 const quake = isMilestone ? Math.round(-v.hitDir * 6 * (MILESTONE_SPRING[mf] ?? 0)) : 0
234 const left = Math.max(0, BASE_LEFT + knock + quake)
235
236 const level = isEnding ? 0 : v.level
237 const color = isEnding ? 'subtle' : LEVEL_COLOR[level]
238 // The hit frame flashes inverted, the next one white, then the level's colour.
239 const flash = !isEnding && (isHit || (isMilestone && mf < 2))
240 const digitColor = flash ? color : f === 1 && !isEnding ? '#FFFFFF' : color
241 const digits = bigDigits(showing, isHit && !isEnding)
242 const sparks =
243 isMilestone || level >= 3
244 ? Array.from({ length: isMilestone ? 6 : level }, () => SPARKS[Math.floor(Math.random() * SPARKS.length)]).join(' ')
245 : ''
246 const rank = rankOf(showing)
247 const meter = '▮'.repeat(level + 1) + '▯'.repeat(4 - level)
248
249 const combo = (
250 <Box flexDirection="row" marginLeft={left}>
251 <Box flexDirection="column">
252 {digits.map(s => (
253 <Text color={digitColor} bold inverse={flash}>
254 {s}
255 </Text>
256 ))}
257 </Box>
258 <Box flexDirection="column" marginLeft={1}>
259 <Box flexDirection="row">
260 <Text color={color} bold inverse={isMilestone && mf < 2}>
261 {isEnding ? TEXT[lang].ended : isMilestone ? ` ${v.milestone} ` : 'COMBO'}
262 </Text>
263 {sparks !== '' && <Text color={LEVEL_COLOR[2]}>{` ${sparks}`}</Text>}
264 </Box>
265 <Text color={isEnding ? 'subtle' : color}>
266 {isEnding ? `x${showing}${rank === '' ? '' : ` ${rank}`}` : `x ${rank}${rank === '' ? '' : ' '}${v.kps.toFixed(1)} ${TEXT[lang].kps}`}
267 </Text>
268 <Box flexDirection="row">
269 {!isEnding && <Text color={color}>{meter}</Text>}
270 <Text color="subtle">{`${isEnding ? '' : ' '}${TEXT[lang].best} ${Math.max(best, v.best, showing)}`}</Text>
271 </Box>
272 </Box>
273 </Box>
274 )
275 if (below === null || below === undefined) return combo
276 return (
277 <Box flexDirection="column">
278 {combo}
279 {below}
280 </Box>
281 )
282 } catch {
283 return below
284 }
285 })
286}
287types/index.d.ts 28 lines1export type ComboView = {
2 // Keys in the running combo; 0 when none runs.
3 count: number
4 best: number
5 // Keys per second over the last second, and the level it maps to (0–4).
6 kps: number
7 level: number
8 lastKeyAt: number
9 // The last hit's knockback: which way (1 right, -1 left) and how far, in cells.
10 hitDir: number
11 hitPower: number
12 // A milestone (every 10, or a new rank): when, and what it shouts.
13 milestoneAt: number
14 milestone: string
15 // When the last combo ended, and how many it reached (shown briefly).
16 endedAt: number
17 ended: number
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'typing-combo': {
23 view: ComboView
24 tick: number
25 }
26 }
27}
28