SLOPSHOPPER

Typing Combo

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.

newbandprompttimer
v1.0.0MITupdated 2026-10-09VkaZas/claude-code-mods/typing-combo
A shopper browsing a rack in a slop shop
README

Typing Combo

A combo counter above the prompt, its digits knocked about by each keystroke

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.

Install

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

What you will see

  • Hits. Each key knocks the three-row digits the other way and springs them back. The hit frame holds for a beat (longer at higher speeds), drawn inverted and heavy, then flashes white.
  • Heat. Five speed levels, from under two keys a second to over nine, take the color from white through blue, yellow and orange to red, fill a meter and throw sparks. The characters you just typed light up in the prompt in the same color.
  • Milestones. Every ten keys, and each new rank (GOOD at 10, NICE! at 25, AMAZING!! at 50, INSANE!!! at 100, GODLIKE!!!! at 200), shakes the whole band and shouts it.
  • Your best. Keys per second and your best combo sit under the count; the best is remembered across sessions.
  • The end. A pause of a second and a half, or sending the prompt, ends the combo and shows how far it went. Pastes don't count.

Settings

OptionValuesDefault
Languageen, zhen

What it hooks

  • 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.

Notes

  • Typing Combo is a mod: a hooks module that draws in the band above the prompt and colors what you type. It was built and tested with Claude Code 2.1.295 in Windows Terminal.
  • It shares that band: whatever else draws there (another mod, a hint) still shows, below the counter.
  • It reads nothing but your keystrokes in the prompt box, counts them, and sends nothing anywhere. The only thing it stores is your best combo, in the plugin's own store.
  • The preview above was rendered from a replay of the plugin's drawing rules; the colors in your terminal depend on its theme and font.

中文说明

打字连击:在提示框里打字,提示框上方出现格斗游戏式的大号连击数。每按一个键就是一次打击,数字被左右击退再弹回,打得越快震得越狠,并有停顿帧;速度分五档,颜色从白到蓝、黄、橙、红,刚打的字也会跟着变色;每 10 连和每个新段位整排震动。停 1.5 秒或发送就结束连击,粘贴不算。最高纪录跨会话保存。在 /config 里把 Language 设成 zh 就是中文。

License

MIT

Source 2 files
hooks/register.tsx 287 lines
1import { 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}
287
types/index.d.ts 28 lines
1export 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