Live cricket commentary for Claude Code: every tool call is a ball, with a scoreboard, a pixel big screen, crowd sounds and an Indian commentator who speaks.

A Claude Code mod that turns every session into a live IPL match.
Every tool call is a ball. Failing tests get you bowled, git push is a six, and an Indian commentator calls every big moment out loud while the crowd roars.

In Claude Code:
/plugin marketplace add dhruvkumar1805/cricket-commentary
/plugin install cricket-commentary@dhruv-mods
Then give Claude any task. The toss happens on your first prompt. Turn your sound on 🔊
| What Claude does | What you get |
|---|---|
tests pass, git push | SIX 🎆 |
| build, lint, commit | FOUR |
| edits | singles, twos and threes |
| reading and searching | dot ball |
| tests fail | OUT, bowled |
| build breaks | OUT, caught behind |
| command not found | OUT, stumped |
rm -rf, force push, sudo | NO BALL, free hit |
| you deny a permission | WIDE |
Fifties, centuries, hat-tricks and maiden overs get their own moments. When the turn ends, the commentator names the player of the match.

/cpl | full scorecard for the innings |
/cpl career | career stats across sessions |
/cpl demo | a scripted innings, to see and hear everything |
/cpl stop / start | pause and resume the match |
/cpl mute / unmute | the commentator |
/cpl crowd | toggle the crowd |
/cpl hinglish / english | commentary language |
/cpl hide / show | the scoreboard |
hooks/.MIT · made by @dhruvkumar1805
hooks/register.tsx 622 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Ball, Career, Celebration, CelebrationKind, Innings, Language, Moment, MomentKind, Settings } from '../types'
5import { BANNER, DURATION_MS, frame, screenRowsFor } from './jumbotron'
6import { cannedLine, cleanLine, commentaryRequest, headline } from './lines'
7import type { LineContext } from './lines'
8import { bowl, newInnings, oversText, playerOfTheMatch, runRate, scoreDelivery, scoreText, shorten } from './scoring'
9import type { Delivery, Outcome } from './scoring'
10
11const DEFAULT_SETTINGS: Settings = { isVoiceOn: true, isCrowdOn: true, isHidden: false, isPaused: false, language: 'english' }
12const EMPTY_CAREER: Career = { innings: 0, runs: 0, sixes: 0, fours: 0, wickets: 0, fifties: 0, centuries: 0, best: 0 }
13const VOICE: Record<Language, string> = { english: 'Aman', hinglish: 'Lekha' }
14
15const inningsAtom = atom({ plugin: 'cricket-commentary', key: 'innings' } as const, null)
16const momentAtom = atom({ plugin: 'cricket-commentary', key: 'moment' } as const, null)
17const settingsAtom = atom({ plugin: 'cricket-commentary', key: 'settings' } as const, DEFAULT_SETTINGS)
18const celebrationAtom = atom({ plugin: 'cricket-commentary', key: 'celebration' } as const, null)
19
20/** Highest first: the one moment a ball is remembered for. */
21const PRIORITY: readonly MomentKind[] = ['century', 'fifty', 'hattrick', 'allout', 'six', 'wicket', 'four', 'noball', 'wide', 'maiden']
22const CELEBRATES: Partial<Record<MomentKind, CelebrationKind>> = {
23 century: 'century',
24 fifty: 'fifty',
25 hattrick: 'hattrick',
26 allout: 'wicket',
27 six: 'six',
28 wicket: 'wicket',
29 four: 'four',
30 noball: 'noball',
31}
32const MUST_SAY = new Set<MomentKind>(['start', 'end', 'century', 'hattrick'])
33const BIG = new Set<MomentKind>(['six', 'wicket', 'fifty', 'allout'])
34
35const CHIP: Record<Ball['kind'], { label: (b: Ball) => string; bg: string; fg: string }> = {
36 dot: { label: () => '•', bg: '#2b3a55', fg: '#9fb3d1' },
37 run: { label: b => String(b.runs), bg: '#1f2d4d', fg: '#ffffff' },
38 four: { label: () => '4', bg: '#1565c0', fg: '#ffffff' },
39 six: { label: () => '6', bg: '#8e24aa', fg: '#ffffff' },
40 wicket: { label: () => 'W', bg: '#c62828', fg: '#ffffff' },
41 noball: { label: () => 'nb', bg: '#ef6c00', fg: '#ffffff' },
42 wide: { label: () => 'wd', bg: '#455a64', fg: '#ffffff' },
43}
44
45const NICKNAMES: Record<string, string> = {
46 Read: 'the wall, blocks everything',
47 Grep: 'finds the gaps',
48 Glob: 'reads the field',
49 Bash: 'the finisher',
50 Edit: 'rotates the strike',
51 Write: 'clears the ropes',
52 Agent: 'brings on the specialists',
53 WebSearch: 'studies the opposition',
54 WebFetch: 'scouting report',
55}
56
57const NAVY = '#0a1838'
58const GOLD = '#f5c518'
59
60const DEMO: readonly Delivery[] = [
61 { tool: 'Read', input: { file_path: 'src/app.ts' }, outcome: 'ok' },
62 { tool: 'Grep', input: { pattern: 'useAuth' }, outcome: 'ok' },
63 { tool: 'Edit', input: { file_path: 'src/auth.ts', new_string: 'a\nb\nc\nd\ne\nf' }, outcome: 'ok' },
64 { tool: 'Bash', input: { command: 'pnpm build' }, outcome: 'ok' },
65 { tool: 'Bash', input: { command: 'pnpm test' }, outcome: 'ok', text: '42 passed' },
66 { tool: 'Bash', input: { command: 'pnpm lint' }, outcome: 'error' },
67 { tool: 'Write', input: { file_path: 'src/session.ts', content: 'x\n'.repeat(120) }, outcome: 'ok' },
68 { tool: 'Edit', input: { file_path: 'src/session.ts', new_string: 'x\n'.repeat(25) }, outcome: 'ok' },
69 { tool: 'Bash', input: { command: 'pnpm test' }, outcome: 'ok', text: '58 passed' },
70 { tool: 'Bash', input: { command: 'git commit -m "fix auth"' }, outcome: 'ok' },
71 { tool: 'Bash', input: { command: 'git push' }, outcome: 'ok' },
72]
73
74type Job =
75 | { type: 'start'; task: string }
76 | { type: 'ball'; delivery: Delivery }
77 | { type: 'end'; isAborted: boolean }
78 | { type: 'demo' }
79 | { type: 'stop-demo' }
80
81type BigScreen = { requestId: string; columns: number; rows: number; seq: number }
82
83function scorecard(innings: Innings | null): string {
84 if (innings === null) return 'No innings yet. Send Claude a task and the match begins, or try **/cpl demo**.'
85 const rows = Object.values(innings.batters).sort((a, b) => b.runs - a.runs || b.balls - a.balls)
86 const star = playerOfTheMatch(innings)
87 const table = [
88 '| Batter | | R | B | 4s | 6s | SR |',
89 '|---|---|--:|--:|--:|--:|--:|',
90 ...rows.map(
91 one =>
92 `| **${one.name}**${one.out ? '' : '\\*'} | ${one.out ? `_${one.out}_` : NICKNAMES[one.name] ?? 'not out'} | **${one.runs}** | ${one.balls} | ${one.fours} | ${one.sixes} | ${((one.runs * 100) / Math.max(1, one.balls)).toFixed(1)} |`,
93 ),
94 ]
95 const fow = innings.fallOfWickets.map(w => `${w.score}-${w.wicket} (${w.batter}: ${w.how})`).join(', ')
96 return [
97 `### 🏏 Claude Premier League · Innings ${innings.number}`,
98 `**CLAUDE ${scoreText(innings)}** (${oversText(innings.balls)} ov) · CRR ${runRate(innings.runs, innings.balls)} · extras ${innings.extras}${innings.isLive ? ' · **LIVE**' : ''}`,
99 `_Task: ${innings.task}_`,
100 '',
101 ...table,
102 '',
103 fow ? `**Fall of wickets:** ${fow}` : '**Fall of wickets:** none, flawless',
104 star ? `**Player of the match:** ${star.name}, ${star.runs} off ${star.balls}` : '',
105 ]
106 .filter(line => line !== '')
107 .join('\n')
108}
109
110function careerCard(c: Career): string {
111 return [
112 '### 🏆 Claude career stats',
113 '| Innings | Runs | Best | 50s | 100s | 4s | 6s | Wickets lost |',
114 '|--:|--:|--:|--:|--:|--:|--:|--:|',
115 `| ${c.innings} | ${c.runs} | ${c.best} | ${c.fifties} | ${c.centuries} | ${c.fours} | ${c.sixes} | ${c.wickets} |`,
116 '',
117 `Average ${(c.runs / Math.max(1, c.wickets)).toFixed(1)} runs per wicket.`,
118 ].join('\n')
119}
120
121const HELP = [
122 '**/cpl** scorecard · **/cpl career** · **/cpl demo** (a scripted innings) · **/cpl stop** / **start** (pause the match)',
123 '**/cpl mute** / **unmute** (commentator) · **/cpl crowd** (toggle crowd) · **/cpl hinglish** / **english** · **/cpl hide** / **show**',
124].join('\n')
125
126export const register: Register = on => {
127 const jobs: Job[] = []
128 let screen: BigScreen | null = null
129 let celebrationStartedAt = 0
130
131 // Everything that outlives one hook (sounds, the commentator, the big screen,
132 // the demo) runs here, from the session's own long-lived timers.
133 on('session.start', async ($, e, next) => {
134 let seq = 0
135 let speaking = 0
136 let isDraining = false
137 let animation: { cancel: () => void } | null = null
138 let demo: { cancel: () => void } | null = null
139
140 const stored = (await $.store.get('settings')) as Partial<Settings> | undefined
141 await update($, settingsAtom, () => ({ ...DEFAULT_SETTINGS, ...(stored ?? {}) }))
142 await $.command.register({
143 name: 'cpl',
144 description: 'Claude Premier League: scorecard, career, demo, mute, crowd, hinglish, hide',
145 argumentHint: '[scorecard|career|demo|stop|mute|unmute|crowd|hinglish|english|hide|show]',
146 })
147
148 const settingsNow = async (): Promise<Settings> => ({ ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}) })
149 const careerNow = async (): Promise<Career> => ({
150 ...EMPTY_CAREER,
151 ...(((await $.store.get('career')) as Partial<Career> | undefined) ?? {}),
152 })
153
154 const play = (asset: string, gain = 1) => {
155 $.audio.play({ asset }, { gain }).catch(() => undefined)
156 }
157
158 const crowd = (kind: MomentKind) => {
159 if (kind === 'six') {
160 play('sounds/bat.wav')
161 play('sounds/six.wav', 0.9)
162 } else if (kind === 'four') {
163 play('sounds/bat.wav')
164 play('sounds/four.wav', 0.8)
165 } else if (kind === 'wicket' || kind === 'hattrick' || kind === 'allout') {
166 play('sounds/stumps.wav')
167 play('sounds/groan.wav', 0.85)
168 } else if (kind === 'century' || kind === 'fifty') {
169 play('sounds/applause.wav')
170 play('sounds/six.wav', 0.6)
171 } else if (kind === 'noball') {
172 play('sounds/groan.wav', 0.5)
173 } else if (kind === 'maiden') {
174 play('sounds/applause.wav', 0.35)
175 } else if (kind === 'start') {
176 play('sounds/four.wav', 0.6)
177 } else if (kind === 'end') {
178 play('sounds/applause.wav', 0.9)
179 }
180 }
181
182 const speak = async (text: string, language: Language) => {
183 speaking += 1
184 try {
185 const line = language === 'english' ? `[[rate 205]] ${text}` : text
186 await $.audio.speak(line, { voice: VOICE[language] }).catch(() => $.audio.speak(text))
187 } catch {
188 // no synthesizer here: the band still shows the line
189 } finally {
190 speaking -= 1
191 }
192 }
193
194 const commentate = async (kind: MomentKind, ctx: LineContext, mine: number) => {
195 const settings = await settingsNow()
196 const canned = cannedLine(kind, ctx, settings.language, Math.random())
197 const moment: Moment = { kind, headline: headline(kind), line: canned, seq: mine }
198 await update($, momentAtom, () => moment)
199
200 if (settings.isCrowdOn) crowd(kind)
201 if (!settings.isVoiceOn) return
202 if (!MUST_SAY.has(kind) && speaking >= (BIG.has(kind) ? 2 : 1)) return
203
204 let line = canned
205 const ask = commentaryRequest(kind, ctx, settings.language)
206 const reply = await $.model
207 .complete({
208 model: 'haiku',
209 system: ask.system,
210 prompt: ask.prompt,
211 maxTokens: 160,
212 timeoutMs: MUST_SAY.has(kind) ? 6000 : 3200,
213 })
214 .catch(() => undefined)
215 const written = reply?.isAnswered ? cleanLine(reply.text) : undefined
216 if (written !== undefined) {
217 line = written
218 await update($, momentAtom, current => (current?.seq === mine ? { ...current, line } : current))
219 }
220 if (!MUST_SAY.has(kind) && seq - mine > 3) return
221 await speak(line, settings.language)
222 }
223
224 const celebrate = async (kind: CelebrationKind, mine: number) => {
225 animation?.cancel()
226 celebrationStartedAt = await $.clock.now()
227 const celebration: Celebration = { kind, text: BANNER[kind], seq: mine }
228 await update($, celebrationAtom, () => celebration)
229 const duration = DURATION_MS[kind]
230 let isPainting = false
231 const timer = $.clock.every(33, () => {
232 if (isPainting) return
233 isPainting = true
234 void (async () => {
235 try {
236 const elapsed = (await $.clock.now()) - celebrationStartedAt
237 if (elapsed >= duration) {
238 timer.cancel()
239 if (animation === timer) animation = null
240 await update($, celebrationAtom, current => (current?.seq === mine ? null : current))
241 return
242 }
243 const target = screen
244 if (target === null || target.seq !== mine) return
245 await $.ui
246 .blit({ requestId: target.requestId, key: 'big-screen', cells: frame(kind, elapsed, target.columns, mine, target.rows) })
247 .catch(() => undefined)
248 } finally {
249 isPainting = false
250 }
251 })()
252 })
253 animation = timer
254 }
255
256 const startInnings = async (task: string) => {
257 const record = await careerNow()
258 const innings = newInnings(record.innings + 1, task, await $.clock.now())
259 await update($, inningsAtom, () => innings)
260 seq += 1
261 $.ui.status('🏏 CLAUDE 0/0 (0.0)')
262 void commentate('start', { detail: '', batter: 'Claude', score: '0/0', overs: '0.0', task }, seq)
263 return innings
264 }
265
266 const deliver = async (delivery: Delivery) => {
267 let innings = await read($, inningsAtom)
268 if (innings === null || !innings.isLive) innings = await startInnings('whatever the captain asked for')
269
270 const b = scoreDelivery(delivery)
271 const { innings: after, moments } = bowl(innings, b)
272 await update($, inningsAtom, () => after)
273 $.ui.status(`🏏 CLAUDE ${scoreText(after)} (${oversText(after.balls)})`)
274
275 const top = PRIORITY.find(kind => moments.includes(kind))
276 if (top === undefined) return
277 seq += 1
278 const ctx: LineContext = {
279 detail: b.detail,
280 batter: b.batter,
281 score: scoreText(after),
282 overs: oversText(after.balls),
283 how: b.how,
284 task: after.task,
285 }
286 const show = CELEBRATES[top]
287 if (show !== undefined) void celebrate(show, seq)
288 void commentate(top, ctx, seq)
289 }
290
291 const endInnings = async (isAborted: boolean) => {
292 const innings = await read($, inningsAtom)
293 if (innings === null || !innings.isLive) return
294 const ended: Innings = { ...innings, isLive: false, endedAt: await $.clock.now() }
295 await update($, inningsAtom, () => ended)
296 if (ended.balls === 0 && ended.runs === 0) return
297
298 const star = playerOfTheMatch(ended)
299 const record = await careerNow()
300 const sixes = Object.values(ended.batters).reduce((n, one) => n + one.sixes, 0)
301 const fours = Object.values(ended.batters).reduce((n, one) => n + one.fours, 0)
302 const updated: Career = {
303 innings: record.innings + 1,
304 runs: record.runs + ended.runs,
305 sixes: record.sixes + sixes,
306 fours: record.fours + fours,
307 wickets: record.wickets + ended.fallOfWickets.length,
308 fifties: record.fifties + ended.milestones.filter(m => m % 100 !== 0).length,
309 centuries: record.centuries + ended.milestones.filter(m => m % 100 === 0).length,
310 best: Math.max(record.best, ended.runs),
311 }
312 await $.store.set('career', updated)
313
314 seq += 1
315 const starText = star ? `${star.name}, ${star.runs} off ${star.balls} balls` : 'Claude'
316 void commentate('end', { detail: '', batter: 'Claude', score: scoreText(ended), overs: oversText(ended.balls), star: starText }, seq)
317
318 const summary = [
319 `🏏 CPL · CLAUDE ${scoreText(ended)} (${oversText(ended.balls)} ov)`,
320 isAborted ? 'rain stopped play' : `CRR ${runRate(ended.runs, ended.balls)}`,
321 star ? `player of the match: ${star.name} ${star.runs}${star.out ? '' : '*'} (${star.balls})` : '',
322 ended.runs > record.best && record.innings > 0 ? 'NEW CAREER BEST' : '',
323 '/cpl for the scorecard',
324 ]
325 await $.session
326 .append({ message: { type: 'system', content: [{ type: 'text', text: summary.filter(Boolean).join(' · ') }] } })
327 .catch(() => undefined)
328 }
329
330 const runDemo = async () => {
331 demo?.cancel()
332 await startInnings('fix the login bug and ship it')
333 let i = 0
334 const timer = $.clock.every(3800, () => {
335 const one = DEMO[i]
336 i += 1
337 if (one === undefined) {
338 timer.cancel()
339 if (demo === timer) demo = null
340 jobs.push({ type: 'end', isAborted: false })
341 return
342 }
343 jobs.push({ type: 'ball', delivery: one })
344 })
345 demo = timer
346 }
347
348 const drain = async () => {
349 if (isDraining) return
350 isDraining = true
351 try {
352 for (let job = jobs.shift(); job !== undefined; job = jobs.shift()) {
353 try {
354 if (job.type === 'start') {
355 const live = await read($, inningsAtom)
356 if (!(live?.isLive ?? false)) await startInnings(job.task)
357 } else if (job.type === 'ball') {
358 await deliver(job.delivery)
359 } else if (job.type === 'end') {
360 await endInnings(job.isAborted)
361 } else if (job.type === 'demo') {
362 await runDemo()
363 } else {
364 demo?.cancel()
365 demo = null
366 }
367 } catch (error) {
368 $.ui.log(`cricket-commentary: ${String(error)}`)
369 }
370 }
371 } finally {
372 isDraining = false
373 }
374 }
375
376 $.clock.every(60, () => {
377 if (jobs.length > 0) void drain()
378 })
379
380 return next(e)
381 })
382
383 on('prompt.submit', async ($, e, next) => {
384 const settings = await read($, settingsAtom)
385 if (!(settings?.isPaused ?? false) && !e.text.trimStart().startsWith('/')) {
386 jobs.push({ type: 'start', task: shorten(e.text, 90) })
387 }
388 return next(e)
389 })
390
391 on('tool.call', async ($, e, next) => {
392 const ran = await next(e)
393 try {
394 const settings = await read($, settingsAtom)
395 if (settings?.isPaused ?? false) return ran
396 // a plugin's view of a Bash result carries the output in stdout and
397 // stderr; `text` is only what the model read, and may be absent here
398 const result = ran.result as { interrupted?: boolean; stdout?: unknown; stderr?: unknown } | undefined
399 const text = [result?.stdout, result?.stderr, ran.text]
400 .filter((part): part is string => typeof part === 'string' && part.length > 0)
401 .join('\n')
402 let outcome: Outcome = 'ok'
403 if (ran.deny !== undefined || (ran.isError === true && /doesn't want to proceed|user rejected|denied by the user|rejected this/i.test(text))) {
404 outcome = 'denied'
405 } else if (result?.interrupted === true) {
406 outcome = 'interrupted'
407 } else if (ran.isError === true) {
408 outcome = 'error'
409 }
410 const input = { ...(e as unknown as Record<string, unknown>) }
411 // test runners print their counts last: keep the tail of long output
412 const kept = text.length > 6000 ? `${text.slice(0, 1500)}\n…\n${text.slice(-4500)}` : text
413 jobs.push({ type: 'ball', delivery: { tool: String(e.tool), input, outcome, text: kept } })
414 } catch {
415 // never let the scoreboard break a tool call
416 }
417 return ran
418 })
419
420 on('turn.complete', ($, e, next) => {
421 if (e.agentId === undefined) jobs.push({ type: 'end', isAborted: e.isAborted })
422 return next(e)
423 })
424
425 on('command.run', { command: 'cpl' }, async ($, e) => {
426 const arg = e.args.trim().toLowerCase()
427 const change = async (patch: Partial<Settings>) => {
428 const now = { ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}), ...patch }
429 await update($, settingsAtom, () => now)
430 await $.store.set('settings', now)
431 return now
432 }
433 if (arg === '' || arg === 'scorecard') return { text: scorecard(await read($, inningsAtom)) }
434 if (arg === 'career') {
435 const c = { ...EMPTY_CAREER, ...(((await $.store.get('career')) as Partial<Career> | undefined) ?? {}) }
436 return { text: careerCard(c) }
437 }
438 if (arg === 'demo') {
439 jobs.push({ type: 'demo' })
440 return { text: '🏏 Demo innings starting. Turn the volume up.' }
441 }
442 if (arg === 'stop' || arg === 'pause') {
443 jobs.push({ type: 'stop-demo' })
444 await change({ isPaused: true })
445 return { text: '⏸️ Play suspended. No scoring and no commentary until **/cpl start**.' }
446 }
447 if (arg === 'start' || arg === 'resume') {
448 await change({ isPaused: false })
449 return { text: '🏏 Play resumes. The next task starts a fresh innings.' }
450 }
451 if (arg === 'mute') {
452 await change({ isVoiceOn: false })
453 return { text: '🔇 Commentator muted, the crowd stays. **/cpl unmute** brings him back.' }
454 }
455 if (arg === 'unmute') {
456 await change({ isVoiceOn: true })
457 return { text: '🎙️ Commentator is back in the box.' }
458 }
459 if (arg === 'crowd') {
460 const current = { ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}) }
461 const now = await change({ isCrowdOn: !current.isCrowdOn })
462 return { text: now.isCrowdOn ? '📣 Crowd is back.' : '🤫 Crowd silenced.' }
463 }
464 if (arg === 'hinglish' || arg === 'hindi') {
465 await change({ language: 'hinglish' })
466 return { text: '🇮🇳 Hindi commentary on.' }
467 }
468 if (arg === 'english') {
469 await change({ language: 'english' })
470 return { text: '🎙️ English commentary on.' }
471 }
472 if (arg === 'hide') {
473 await change({ isHidden: true })
474 return { text: 'Scoreboard hidden. **/cpl show** brings it back.' }
475 }
476 if (arg === 'show') {
477 await change({ isHidden: false })
478 return { text: 'Scoreboard is back.' }
479 }
480 return { text: HELP }
481 })
482
483 on('ui.render', { component: 'CommandOutput', props: { command: 'cpl' } }, ($, e, next) => {
484 if (e.props.isErrored) return next(e)
485 const { Markdown } = $.ui.resolve(e)
486 return <Markdown text={e.props.text} />
487 })
488
489 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
490 const settings = { ...DEFAULT_SETTINGS, ...((await read($, settingsAtom)) ?? {}) }
491 if (e.props.hasSurvey || settings.isHidden) return next(e)
492
493 const innings = await read($, inningsAtom)
494 const moment = await read($, momentAtom)
495 const celebration = await read($, celebrationAtom)
496 const ui = $.ui.resolve(e)
497 const { Box, Text } = ui
498 const width = Math.max(20, e.props.bodyColumns)
499
500 if (settings.isPaused) {
501 return (
502 <Box>
503 <Text backgroundColor={GOLD} color="#000000" bold>
504 {' CPL '}
505 </Text>
506 <Text color="#9fb3d1" wrap="truncate-end">
507 {innings === null
508 ? ' ⏸ play suspended · /cpl start to resume'
509 : ` ⏸ play suspended at ${scoreText(innings)} (${oversText(innings.balls)}) · /cpl start to resume`}
510 </Text>
511 </Box>
512 )
513 }
514
515 if (innings === null) {
516 return (
517 <Box>
518 <Text backgroundColor={GOLD} color="#000000" bold>
519 {' CPL '}
520 </Text>
521 <Text color="#9fb3d1" wrap="truncate-end">
522 {' Claude Premier League · send a task to start the innings · /cpl demo for a preview'}
523 </Text>
524 </Box>
525 )
526 }
527
528 let bigScreen = null
529 const rows = screenRowsFor(e.props.maxRows)
530 if (celebration !== null && e.surface === 'terminal' && rows > 0 && width >= 30) {
531 const { Raster } = $.ui.resolve(e)
532 const columns = Math.min(width, 120)
533 const elapsed = Math.max(0, (await $.clock.now()) - celebrationStartedAt)
534 screen = { requestId: e.requestId, columns, rows, seq: celebration.seq }
535 bigScreen = <Raster key="big-screen" columns={columns} rows={rows} cells={frame(celebration.kind, elapsed, columns, celebration.seq, rows)} />
536 } else {
537 screen = null
538 }
539 const call = moment === null ? '' : moment.line.replace(new RegExp(`^${moment.headline.replace(/[^A-Z]/g, '')}!?\\s*`, 'i'), '')
540
541 const batters = Object.values(innings.batters)
542 .filter(one => one.runs > 0 || one.balls > 2)
543 .sort((a, b) => b.runs - a.runs)
544 .slice(0, 3)
545 const lastWicket = innings.fallOfWickets[innings.fallOfWickets.length - 1]
546 const isWide = width >= 90
547
548 return (
549 <Box flexDirection="column">
550 {bigScreen}
551 <Box>
552 <Text backgroundColor={GOLD} color="#000000" bold>
553 {' CPL '}
554 </Text>
555 <Text backgroundColor={NAVY} color="#ffffff" bold>
556 {' CLAUDE '}
557 </Text>
558 <Text backgroundColor={NAVY} color={GOLD} bold>
559 {`${scoreText(innings)} `}
560 </Text>
561 <Text backgroundColor={NAVY} color="#cfd8e3">
562 {` ${oversText(innings.balls)} ov `}
563 </Text>
564 {isWide && (
565 <Text backgroundColor={NAVY} color="#8aa0c2">
566 {` CRR ${runRate(innings.runs, innings.balls)} `}
567 </Text>
568 )}
569 <Text backgroundColor={NAVY} color="#8aa0c2">
570 {' THIS OVER '}
571 </Text>
572 {innings.thisOver.length === 0 && (
573 <Text backgroundColor={NAVY} color="#5c6f91">
574 {' - '}
575 </Text>
576 )}
577 {innings.thisOver.map(one => (
578 <Text backgroundColor={CHIP[one.kind].bg} color={CHIP[one.kind].fg} bold>
579 {` ${CHIP[one.kind].label(one)} `}
580 </Text>
581 ))}
582 <Text backgroundColor={NAVY}>{' '}</Text>
583 {innings.isLive ? (
584 <Text backgroundColor="#b71c1c" color="#ffffff" bold>
585 {e.props.isWorking ? ' ● LIVE ' : ' LIVE '}
586 </Text>
587 ) : (
588 <Text backgroundColor="#37474f" color="#ffffff" bold>
589 {' STUMPS '}
590 </Text>
591 )}
592 </Box>
593 {bigScreen === null && (batters.length > 0 || lastWicket !== undefined) && (
594 <Box>
595 {batters.map((one, i) => (
596 <Text color={i === 0 ? '#ffffff' : '#b0bec5'} bold={i === 0}>
597 {`${i === 0 ? ' ' : ' · '}${one.name.toUpperCase()} ${one.runs}${one.out ? '' : '*'} (${one.balls})`}
598 </Text>
599 ))}
600 {innings.extras > 0 && <Text color="#78909c">{` · extras ${innings.extras}`}</Text>}
601 {lastWicket !== undefined && isWide && (
602 <Text color="#ef9a9a" wrap="truncate-end">
603 {` · last wkt ${lastWicket.score}-${lastWicket.wicket}: ${lastWicket.how}`}
604 </Text>
605 )}
606 </Box>
607 )}
608 {moment !== null && (
609 <Box>
610 <Text color={GOLD} bold>
611 {` 🎙 ${moment.headline} `}
612 </Text>
613 <Text color="#e3f2fd" italic wrap="truncate-end">
614 {call}
615 </Text>
616 </Box>
617 )}
618 </Box>
619 )
620 })
621}
622hooks/jumbotron.ts 374 lines1import type { CelebrationKind } from '../types'
2
3/**
4 * The stadium big screen: a pixel canvas drawn into a terminal Raster with
5 * half blocks (each cell is two pixels stacked: ▀ painted fg over bg), so a
6 * band of R rows is a 2R pixel tall screen animated by $.ui.blit.
7 */
8
9export const SCREEN_ROWS = 10
10
11const FONT: Record<string, readonly string[]> = {
12 A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
13 B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
14 C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'],
15 D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
16 E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
17 F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
18 H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
19 I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'],
20 K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
21 L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
22 M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
23 N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
24 O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
25 R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
26 S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
27 T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
28 U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
29 W: ['10001', '10001', '10001', '10101', '10101', '11011', '10001'],
30 X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
31 Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
32 '0': ['01110', '10011', '10101', '10101', '10101', '11001', '01110'],
33 '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
34 '2': ['01110', '10001', '00001', '00110', '01000', '10000', '11111'],
35 '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
36 '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
37 '5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
38 '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
39 '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
40 '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
41 '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
42 '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'],
43 '-': ['00000', '00000', '00000', '01110', '00000', '00000', '00000'],
44 ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
45}
46
47export const DURATION_MS: Record<CelebrationKind, number> = {
48 six: 3400,
49 four: 2300,
50 wicket: 2800,
51 fifty: 3200,
52 century: 4200,
53 hattrick: 3600,
54 noball: 2300,
55}
56
57export const BANNER: Record<CelebrationKind, string> = {
58 six: 'SIX!',
59 four: 'FOUR!',
60 wicket: 'OUT!',
61 fifty: 'FIFTY!',
62 century: '100!',
63 hattrick: 'HAT-TRICK!',
64 noball: 'NO BALL!',
65}
66
67type RGB = readonly [number, number, number]
68
69const hex = (n: number): RGB => [(n >> 16) & 255, (n >> 8) & 255, n & 255]
70const pack = ([r, g, b]: RGB): number => (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
71const mixRGB = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
72const clamp01 = (x: number) => Math.max(0, Math.min(1, x))
73
74function hsl(h: number, s: number, l: number): RGB {
75 const k = (n: number) => (n + h / 30) % 12
76 const a = s * Math.min(l, 1 - l)
77 const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
78 return [f(0) * 255, f(8) * 255, f(4) * 255]
79}
80
81function rng(seed: number) {
82 let s = seed >>> 0 || 1
83 return () => {
84 s ^= s << 13
85 s ^= s >>> 17
86 s ^= s << 5
87 return ((s >>> 0) % 100000) / 100000
88 }
89}
90
91const THEME: Record<CelebrationKind, { text: RGB; glow: RGB; top: RGB; bottom: RGB }> = {
92 six: { text: hex(0xffd23f), glow: hex(0xffffff), top: hex(0x12002b), bottom: hex(0x3a0a6b) },
93 four: { text: hex(0x4fc3f7), glow: hex(0xe1f5fe), top: hex(0x001326), bottom: hex(0x00306b) },
94 wicket: { text: hex(0xffffff), glow: hex(0xffcdd2), top: hex(0x2b0000), bottom: hex(0x6b0a0a) },
95 fifty: { text: hex(0xffe082), glow: hex(0xffffff), top: hex(0x1a1200), bottom: hex(0x4a3500) },
96 century: { text: hex(0xffd700), glow: hex(0xffffff), top: hex(0x1a1000), bottom: hex(0x5c3d00) },
97 hattrick: { text: hex(0xff4081), glow: hex(0xffffff), top: hex(0x1f0016), bottom: hex(0x5a0040) },
98 noball: { text: hex(0xffa726), glow: hex(0xfff3e0), top: hex(0x1f1000), bottom: hex(0x4d2600) },
99}
100
101class Canvas {
102 readonly px: Float64Array
103 readonly width: number
104 readonly height: number
105 constructor(width: number, height: number) {
106 this.width = width
107 this.height = height
108 this.px = new Float64Array(width * height * 3)
109 }
110 set(x: number, y: number, c: RGB, alpha = 1) {
111 const xi = Math.round(x)
112 const yi = Math.round(y)
113 if (xi < 0 || yi < 0 || xi >= this.width || yi >= this.height) return
114 const i = (yi * this.width + xi) * 3
115 const a = clamp01(alpha)
116 this.px[i] = this.px[i]! + (c[0] - this.px[i]!) * a
117 this.px[i + 1] = this.px[i + 1]! + (c[1] - this.px[i + 1]!) * a
118 this.px[i + 2] = this.px[i + 2]! + (c[2] - this.px[i + 2]!) * a
119 }
120 get(x: number, y: number): RGB {
121 const i = (y * this.width + x) * 3
122 return [this.px[i]!, this.px[i + 1]!, this.px[i + 2]!]
123 }
124}
125
126function textWidth(text: string, sx: number): number {
127 return text.length * 6 * sx - sx
128}
129
130function drawText(cv: Canvas, text: string, x0: number, y0: number, sx: number, sy: number, color: (x: number, y: number) => RGB) {
131 let x = x0
132 for (const ch of text) {
133 const glyph = FONT[ch] ?? FONT[' ']!
134 for (let gy = 0; gy < 7; gy++) {
135 for (let gx = 0; gx < 5; gx++) {
136 if (glyph[gy]![gx] !== '1') continue
137 for (let dy = 0; dy < sy; dy++) {
138 for (let dx = 0; dx < sx; dx++) {
139 const px = x + gx * sx + dx
140 const py = y0 + gy * sy + dy
141 cv.set(px, py, color(px, py))
142 }
143 }
144 }
145 }
146 x += 6 * sx
147 }
148}
149
150function drawShadowText(cv: Canvas, text: string, x0: number, y0: number, sx: number, sy: number) {
151 drawText(cv, text, x0 + 1, y0 + 1, sx, sy, () => [0, 0, 0])
152}
153
154type Burst = { t0: number; x: number; y: number; hue: number; count: number; speed: number }
155
156function fireworks(cv: Canvas, bursts: readonly Burst[], t: number, seed: number) {
157 for (const [i, b] of bursts.entries()) {
158 const age = t - b.t0
159 if (age < 0 || age > 1.4) continue
160 const r = rng(seed * 31 + i * 7919)
161 for (let j = 0; j < b.count; j++) {
162 const angle = (j / b.count) * Math.PI * 2 + r() * 0.3
163 const v = b.speed * (0.6 + r() * 0.6)
164 const x = b.x + Math.cos(angle) * v * age * 2.2
165 const y = b.y + Math.sin(angle) * v * age + 9 * age * age
166 const fade = 1 - age / 1.4
167 const c = hsl((b.hue + r() * 40) % 360, 1, 0.55 + 0.35 * fade)
168 cv.set(x, y, c, fade)
169 if (age < 0.9) cv.set(x - Math.cos(angle) * 1.5, y - Math.sin(angle) * 0.8, c, fade * 0.35)
170 }
171 if (age < 0.12) cv.set(b.x, b.y, [255, 255, 255])
172 }
173}
174
175function confetti(cv: Canvas, t: number, seed: number, amount: number) {
176 const r = rng(seed * 13 + 5)
177 for (let i = 0; i < amount; i++) {
178 const x0 = r() * cv.width
179 const speed = 5 + r() * 9
180 const start = r() * 1.6
181 const y = ((t - start) * speed) % (cv.height + 6)
182 if (t < start) continue
183 const sway = Math.sin(t * 4 + i) * 1.5
184 cv.set(x0 + sway, y - 3, hsl((i * 47) % 360, 0.95, 0.6))
185 }
186}
187
188function floodlights(cv: Canvas, t: number, seed: number) {
189 const r = rng(seed + 99)
190 for (let x = 1; x < cv.width; x += 3) {
191 const twinkle = 0.35 + 0.65 * Math.abs(Math.sin(t * (2 + r() * 4) + r() * 6))
192 cv.set(x, 0, [255, 250, 220], twinkle * 0.8)
193 }
194}
195
196function stumpsAndBails(cv: Canvas, t: number, x: number, seed: number) {
197 const base = cv.height - 2
198 const tall = Math.min(12, cv.height - 4)
199 const wood: RGB = [242, 222, 172]
200 const shade: RGB = [190, 160, 110]
201 // the middle stump is sent cartwheeling back, the outer two stay planted
202 for (let s = 0; s < 3; s++) {
203 const isHit = s === 1 && t > 0.04
204 const lean = isHit ? Math.min(1, (t - 0.04) * 4) * 5 : 0
205 for (let h = 0; h < tall; h++) {
206 const px = x + s * 3 + (lean * h) / tall
207 cv.set(px, base - h, wood)
208 cv.set(px + 1, base - h, shade)
209 }
210 }
211 for (let gx = -1; gx < 10; gx++) cv.set(x + gx, base + 1, [60, 120, 60])
212 const r = rng(seed + 3)
213 const pieces = 16
214 for (let i = 0; i < pieces; i++) {
215 const age = t - 0.04
216 if (age < 0) {
217 if (i < 2) cv.set(x + 1 + i * 3, base - tall, [255, 255, 255])
218 continue
219 }
220 const vx = (r() - 0.35) * 34
221 const vy = -14 - r() * 14
222 const bx = x + 4 + vx * age
223 const by = base - tall + vy * age + 30 * age * age
224 const isBail = i < 2
225 cv.set(bx, by, isBail ? [255, 255, 255] : wood, clamp01(1.8 - age))
226 if (isBail) cv.set(bx + 1, by, [255, 255, 255], clamp01(1.8 - age))
227 }
228}
229
230function ballStreak(cv: Canvas, t: number, duration: number) {
231 const p = clamp01(t / (duration * 0.55))
232 const ease = 1 - (1 - p) ** 3
233 const x = ease * (cv.width + 6) - 3
234 const y = cv.height - 3 - Math.sin(p * Math.PI) * 2
235 for (let k = 0; k < 14; k++) cv.set(x - k, y + k * 0.05, [255, 255 - k * 12, 255 - k * 18], 1 - k / 14)
236 cv.set(x, y, [230, 30, 30])
237 cv.set(x + 1, y, [255, 80, 80])
238 if (p >= 1) {
239 const flash = clamp01(1 - (t - duration * 0.55) * 2)
240 for (let xx = 0; xx < cv.width; xx++) cv.set(xx, cv.height - 1, [255, 255, 255], flash)
241 }
242}
243
244function warningStripes(cv: Canvas, t: number) {
245 for (let x = 0; x < cv.width; x++) {
246 const on = Math.floor((x + t * 20) / 3) % 2 === 0
247 if (on) {
248 cv.set(x, 0, [255, 167, 38])
249 cv.set(x, cv.height - 1, [255, 167, 38])
250 }
251 }
252}
253
254/** Paints one frame of a celebration at `ms` into a pixel canvas. */
255export function paint(kind: CelebrationKind, ms: number, width: number, seed: number, rows = SCREEN_ROWS): Canvas {
256 const text = BANNER[kind]
257 const height = rows * 2
258 const cv = new Canvas(width, height)
259 const theme = THEME[kind]
260 const t = ms / 1000
261 const duration = DURATION_MS[kind] / 1000
262
263 for (let y = 0; y < height; y++) {
264 const row = mixRGB(theme.top, theme.bottom, y / (height - 1))
265 for (let x = 0; x < width; x++) cv.set(x, y, row)
266 }
267 if (kind === 'wicket' && t < 0.7 && Math.floor(t * 8) % 2 === 0) {
268 for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) cv.set(x, y, [200, 20, 20], 0.55)
269 }
270 floodlights(cv, t, seed)
271
272 const r = rng(seed)
273 if (kind === 'six' || kind === 'century' || kind === 'hattrick' || kind === 'fifty') {
274 const count = kind === 'century' ? 9 : kind === 'six' ? 7 : 5
275 const bursts: Burst[] = Array.from({ length: count }, (_, i) => ({
276 t0: 0.05 + (i * (duration * 0.7)) / count + r() * 0.15,
277 x: i % 2 === 0 ? r() * width * 0.22 + 6 : width - r() * width * 0.22 - 6,
278 y: 4 + r() * (height * 0.35),
279 hue: r() * 360,
280 count: 30 + Math.floor(r() * 14),
281 speed: 5 + r() * 4,
282 }))
283 fireworks(cv, bursts, t, seed)
284 }
285 if (kind === 'century' || kind === 'fifty') confetti(cv, t, seed, Math.floor(width * (kind === 'century' ? 0.9 : 0.5)))
286 if (kind === 'four') ballStreak(cv, t, duration)
287 if (kind === 'noball') warningStripes(cv, t)
288
289 // a short screen (a cramped band) draws the banner at single height
290 const sy = height >= 16 ? 2 : 1
291 const sx = sy === 2 && textWidth(text, 2) <= width - 4 ? 2 : 1
292 const tw = textWidth(text, sx)
293 const pop = clamp01(t / 0.32)
294 const bounce = Math.round((1 - pop) * -10 + Math.sin(pop * Math.PI) * -1.5)
295 const x0 = Math.floor((width - tw) / 2)
296 const y0 = Math.floor((height - 7 * sy) / 2) + bounce
297 const sweep = ((t * 1.4) % 1.6) * (tw + 20) - 10
298 const textColor = (x: number, y: number): RGB => {
299 const d = Math.abs(x - x0 - sweep - (y - y0) * 0.6)
300 const shine = clamp01(1 - d / 5)
301 const base = kind === 'six' || kind === 'century' ? mixRGB(theme.text, hsl((x * 4 + t * 240) % 360, 1, 0.62), kind === 'six' ? 0.25 : 0.1) : theme.text
302 return mixRGB(base, theme.glow, shine * 0.85)
303 }
304 drawShadowText(cv, text, x0, y0, sx, sy)
305 drawText(cv, text, x0, y0, sx, sy, textColor)
306
307 if (kind === 'wicket' || kind === 'hattrick') {
308 stumpsAndBails(cv, t, 4, seed)
309 stumpsAndBails(cv, t, width - 13, seed + 1)
310 }
311
312 const fadeOut = clamp01((duration - t) / 0.35)
313 if (fadeOut < 1) {
314 for (let y = 0; y < height; y++) {
315 for (let x = 0; x < width; x++) {
316 const c = cv.get(x, y)
317 const i = (y * width + x) * 3
318 cv.px[i] = c[0] * fadeOut
319 cv.px[i + 1] = c[1] * fadeOut
320 cv.px[i + 2] = c[2] * fadeOut
321 }
322 }
323 }
324 return cv
325}
326
327const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
328
329function toBase64(bytes: Uint8Array): string {
330 let out = ''
331 let i = 0
332 for (; i + 2 < bytes.length; i += 3) {
333 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
334 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
335 }
336 const rest = bytes.length - i
337 if (rest === 1) {
338 const n = bytes[i]! << 16
339 out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}==`
340 } else if (rest === 2) {
341 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
342 out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`
343 }
344 return out
345}
346
347const UPPER_HALF = 0x2580
348
349/** Packs a canvas into Raster cells: one ▀ per cell, top pixel fg, bottom bg. */
350export function cells(cv: Canvas): string {
351 const rows = cv.height / 2
352 const words = new Uint32Array(cv.width * rows * 3)
353 let k = 0
354 for (let row = 0; row < rows; row++) {
355 for (let x = 0; x < cv.width; x++) {
356 words[k++] = UPPER_HALF
357 words[k++] = pack(cv.get(x, row * 2))
358 words[k++] = pack(cv.get(x, row * 2 + 1))
359 }
360 }
361 return toBase64(new Uint8Array(words.buffer))
362}
363
364export function frame(kind: CelebrationKind, ms: number, width: number, seed: number, rows = SCREEN_ROWS): string {
365 return cells(paint(kind, ms, width, seed, rows))
366}
367
368/** Screen rows that fit a band of `maxRows`, leaving room for the scorebug and the call; 0 when too cramped. */
369export function screenRowsFor(maxRows: number): number {
370 // one spare row: the band's height moves as the spinner and tips come and go
371 const room = Math.min(SCREEN_ROWS, maxRows - 3)
372 return room >= 4 ? room : 0
373}
374hooks/lines.ts 191 lines1import type { Language, MomentKind } from '../types'
2
3export type LineContext = {
4 detail: string
5 batter: string
6 score: string
7 overs: string
8 how?: string
9 task?: string
10 star?: string
11}
12
13type Bank = Record<MomentKind, readonly string[]>
14
15const ENGLISH: Bank = {
16 start: [
17 'Welcome to the Claude Premier League! Claude walks out to the middle, the crowd is on its feet, and we are ready for the first ball.',
18 'What an atmosphere here tonight! The covers are off, the floodlights are on, and Claude is taking guard.',
19 'Good evening and welcome! A packed stadium, a fresh pitch, and one job to do. Claude, over to you.',
20 ],
21 six: [
22 'That is out of the ground! {detail}, and the ball is still travelling!',
23 'Oh, what a shot! {detail} sails over long on and into the second tier!',
24 'SIX! Clean, crisp, magnificent. {detail}, and the crowd goes absolutely berserk!',
25 'He has picked the length early and deposited it into the stands. {detail}. Massive!',
26 'That has gone like a rocket! {detail}, out of the park, and somebody fetch that ball from the car park!',
27 'Up, up and away! {detail}. That is maximum, and Claude is on fire tonight!',
28 ],
29 four: [
30 'Cracking shot! {detail} races away to the boundary, nobody is stopping that.',
31 'Through the covers like a bullet! {detail}, four runs.',
32 'Glorious timing! {detail}, and the fielder can only watch it cross the rope.',
33 'That is a beautiful stroke. {detail}, threaded through the gap for four.',
34 'Short and wide, and punished! {detail}, boundary!',
35 ],
36 wicket: [
37 'Oh, he is gone! {how}. {detail}. The stadium falls silent!',
38 'Timber! {how}. Claude walks back, and that is a big, big wicket.',
39 'Got him! {how}. {detail}, and the bowler is off on a lap of celebration!',
40 'That is a shocker! {how}. Claude cannot believe it, and neither can we.',
41 'Out! {how}. {detail}. Back to the drawing board for Claude.',
42 ],
43 noball: [
44 'No ball! Overstepped by a mile! {detail}. That is a free hit, and you can feel the danger in the air!',
45 'Ooh, that is reckless! {detail}. The umpire signals no ball, and the stadium gasps!',
46 ],
47 wide: [
48 'Wide called! The captain said no, and the umpire stretches those arms out.',
49 'That is a wide. Permission declined, and an extra run for the batting side.',
50 ],
51 fifty: [
52 'Fifty up! The bat is raised, the helmet is off, and this crowd is loving every minute of it!',
53 'There it is, a magnificent half century! {score}, and Claude is just getting started.',
54 ],
55 century: [
56 'A HUNDRED! What a knock! Claude leaps in the air, kisses the badge, and this stadium is shaking!',
57 'Century! Take a bow, Claude! That is one of the finest innings you will ever see!',
58 ],
59 hattrick: [
60 'HAT-TRICK! Three in three! I have never seen anything like it! Absolute carnage out there!',
61 ],
62 maiden: [
63 'A maiden over. Six balls, no runs. Claude is just reading the pitch here, patience personified.',
64 'Maiden! Claude is defending everything, studying the conditions like a Test match veteran.',
65 ],
66 allout: ['All out! The innings collapses, and that is the end of a dramatic, dramatic effort.'],
67 end: [
68 'And that is the end of the innings! Claude finishes on {score} off {overs} overs. Player of the match, {star}!',
69 'What an innings! {score} off {overs} overs. Take a bow. Player of the match tonight, {star}!',
70 ],
71}
72
73const HINGLISH: Bank = {
74 start: [
75 'क्लॉड प्रीमियर लीग में आपका स्वागत है! स्टेडियम खचाखच भरा है, और क्लॉड क्रीज़ पर आ चुके हैं!',
76 'नमस्कार दोस्तों! फ्लडलाइट्स ऑन हैं, पिच तैयार है, और क्लॉड बैटिंग के लिए उतर चुके हैं!',
77 ],
78 six: [
79 'क्या शॉट मारा है! गेंद सीधा स्टेडियम के बाहर! छक्का!',
80 'छक्का! बॉल अभी भी हवा में है भाई साहब, क्या टाइमिंग है!',
81 'उठाके मारा और सीधा दर्शकों के बीच! क्लॉड आज रुकने वाले नहीं हैं!',
82 'ये गया, ये गया, और ये गया! बाउंड्री के बाहर! ज़बरदस्त छक्का!',
83 ],
84 four: [
85 'चौका! गेंद गोली की रफ़्तार से बाउंड्री की तरफ़!',
86 'क्या खूबसूरत शॉट है! फील्डर बस देखता रह गया, चार रन!',
87 'गैप ढूंढा और निकाल दिया! शानदार चौका!',
88 ],
89 wicket: [
90 'आउट! अरे ये क्या हो गया! पूरा स्टेडियम खामोश!',
91 'गया! बड़ा विकेट! क्लॉड को यकीन ही नहीं हो रहा!',
92 'बोल्ड! गिल्लियां बिखर गईं! क्या गेंद थी!',
93 ],
94 noball: ['नो बॉल! ये तो बहुत ख़तरनाक शॉट था! अब फ्री हिट!'],
95 wide: ['वाइड! कप्तान ने मना कर दिया, एक्स्ट्रा रन!'],
96 fifty: ['पचास रन पूरे! बल्ला ऊपर, और दर्शक झूम उठे!'],
97 century: ['शतक! क्या पारी है! क्लॉड ने तो कमाल कर दिया! पूरा स्टेडियम खड़ा है!'],
98 hattrick: ['हैट्रिक! तीन गेंद, तीन विकेट! ऐसा नज़ारा कभी नहीं देखा!'],
99 maiden: ['मेडन ओवर! छह गेंद, एक भी रन नहीं, क्लॉड बस पिच पढ़ रहे हैं।'],
100 allout: ['ऑल आउट! पूरी टीम पवेलियन लौट गई!'],
101 end: [
102 'और पारी ख़त्म! क्लॉड ने बनाए {score}, {overs} ओवर में! मैन ऑफ़ द मैच, {star}!',
103 ],
104}
105
106const HEADLINES: Record<MomentKind, string> = {
107 start: 'TOSS',
108 six: 'SIX!',
109 four: 'FOUR!',
110 wicket: 'OUT!',
111 noball: 'NO BALL',
112 wide: 'WIDE',
113 fifty: 'FIFTY!',
114 century: 'CENTURY!',
115 hattrick: 'HAT-TRICK!',
116 maiden: 'MAIDEN',
117 allout: 'ALL OUT',
118 end: 'INNINGS OVER',
119}
120
121export function headline(kind: MomentKind): string {
122 return HEADLINES[kind]
123}
124
125function fill(template: string, ctx: LineContext): string {
126 return template
127 .replaceAll('{detail}', ctx.detail || 'that one')
128 .replaceAll('{batter}', ctx.batter)
129 .replaceAll('{score}', ctx.score.replace('/', ' for '))
130 .replaceAll('{overs}', ctx.overs)
131 .replaceAll('{how}', ctx.how ?? 'out')
132 .replaceAll('{star}', ctx.star ?? 'Claude')
133 .replace(/\s+([,.!])/g, '$1')
134}
135
136/** A hand-written line, used at once and whenever the model is slow or absent. */
137export function cannedLine(kind: MomentKind, ctx: LineContext, language: Language, roll: number): string {
138 const bank = (language === 'hinglish' ? HINGLISH : ENGLISH)[kind]
139 return fill(bank[Math.floor(roll * bank.length) % bank.length] ?? '', ctx)
140}
141
142const STYLE_ENGLISH = `You are the most excitable IPL television commentator alive, in the booming style of a legendary Indian cricket commentator: huge energy, vivid cricket metaphors, crowd-pleasing exaggeration. You are commentating live as a coding agent called Claude bats in a T20 match. Every tool call it makes is one delivery.
143
144Write ONE line of spoken commentary, at most 24 words, plain text for a text-to-speech voice: no emojis, no quotes, no hashtags, no markdown, no stage directions. Weave the real technical detail into a cricket metaphor so a developer laughs. Never repeat the example phrasing.`
145
146const STYLE_HINGLISH = `तुम IPL के सबसे जोशीले हिंदी कमेंटेटर हो। एक कोडिंग एजेंट "क्लॉड" T20 मैच में बैटिंग कर रहा है, उसका हर टूल कॉल एक गेंद है।
147
148सिर्फ़ एक लाइन लिखो, ज़्यादा से ज़्यादा 22 शब्द, देवनागरी लिपि में Hinglish (जैसे असली हिंदी कमेंट्री: "क्या शॉट है! गेंद सीधा स्टेडियम के बाहर!")। अंग्रेज़ी टेक शब्द भी देवनागरी में लिखो (टेस्ट, बिल्ड, कमिट, फ़ाइल)। कोई इमोजी, कोटेशन या मार्कडाउन नहीं। असली टेक्निकल बात को क्रिकेट की तरह मज़ेदार बनाओ।`
149
150const ASK: Record<MomentKind, string> = {
151 start: 'The innings is starting. Welcome the crowd and introduce what Claude has been asked to do',
152 six: 'Claude just hit a SIX',
153 four: 'Claude just hit a FOUR',
154 wicket: 'Claude just lost a WICKET',
155 noball: 'Claude just bowled a reckless NO BALL by running a dangerous command',
156 wide: 'The human declined a permission request, the umpire called a WIDE',
157 fifty: 'Claude just reached a FIFTY',
158 century: 'Claude just reached a CENTURY, the biggest moment of the night',
159 hattrick: 'Claude just lost three wickets in three balls, a HAT-TRICK',
160 maiden: 'Claude just played a MAIDEN over, six balls of only reading files and no runs',
161 allout: 'Claude is ALL OUT',
162 end: 'The innings is over. Sum it up and name the player of the match',
163}
164
165export function commentaryRequest(kind: MomentKind, ctx: LineContext, language: Language): { system: string; prompt: string } {
166 const facts = [
167 `Moment: ${ASK[kind]}.`,
168 ctx.detail ? `What actually happened in the code: ${ctx.batter} ran ${ctx.detail}.` : `Batter: ${ctx.batter}.`,
169 ctx.how ? `How it went wrong: ${ctx.how}.` : '',
170 `Score: ${ctx.score} after ${ctx.overs} overs.`,
171 ctx.task ? `Claude's task this innings: ${ctx.task}.` : '',
172 ctx.star ? `Player of the match: ${ctx.star}.` : '',
173 ]
174 return {
175 system: language === 'hinglish' ? STYLE_HINGLISH : STYLE_ENGLISH,
176 prompt: facts.filter(Boolean).join('\n'),
177 }
178}
179
180/** Cleans a model reply into one speakable line, or undefined when unusable. */
181export function cleanLine(text: string): string | undefined {
182 const line = text
183 .replace(/[*_`#>"“”]/g, '')
184 .replace(/\p{Extended_Pictographic}/gu, '')
185 .split('\n')
186 .map(one => one.trim())
187 .find(one => one.length > 0)
188 if (line === undefined || line.length < 8) return undefined
189 return line.length > 220 ? `${line.slice(0, 217)}...` : line
190}
191hooks/scoring.ts 290 lines1import type { Ball, Batter, Innings, MomentKind } from '../types'
2
3export type Outcome = 'ok' | 'error' | 'denied' | 'interrupted'
4
5export type Delivery = {
6 tool: string
7 input: Record<string, unknown>
8 outcome: Outcome
9 text?: string
10}
11
12const TEST = /\b(jest|vitest|pytest|mocha|playwright|cypress|rspec|phpunit|ctest|unittest)\b|\b(npm|pnpm|yarn|bun|deno|cargo|go|mix|dotnet|swift|gradle|mvn|make|rake)\s+(run\s+)?test\b|\bnode\s+--test\b/
13const BUILD = /\b(tsc|typecheck|type-check|webpack|esbuild|rollup|compile)\b|\b(npm|pnpm|yarn|bun|cargo|go|next|vite|swift|gradle|mvn|make)\s+(run\s+)?build\b|^\s*make\b/
14const LINT = /\b(eslint|prettier|biome|ruff|clippy|flake8|rubocop|stylelint)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?lint\b/
15const INSTALL = /\b(npm|pnpm|yarn|bun)\s+(i|install|add|ci)\b|\bpip3?\s+install\b|\bbrew\s+install\b|\bcargo\s+add\b|\buv\s+(add|pip\s+install)\b/
16const PUSH = /\bgit\s+push\b/
17const COMMIT = /\bgit\s+commit\b/
18const DANGER = /\brm\s+-[a-zA-Z]*(rf|fr)\b|\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+-[a-zA-Z]*f|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*(--force\b|\s-f\b)|\bdrop\s+(table|database)\b|\bsudo\s/i
19const LOOK = /^\s*(grep|rg|find|ls|cat|head|tail|wc|which|pwd|echo|tree|file|stat|less|git\s+(status|log|diff|show|branch))\b/
20const NOT_FOUND = /command not found|No such file or directory|not recognized as/i
21const TIMEOUT = /timed out|timeout/i
22
23const READERS = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'ToolSearch', 'TaskList', 'TaskGet', 'TaskOutput'])
24const SINGLES = new Set(['WebSearch', 'WebFetch'])
25
26export function shorten(text: string, max = 46): string {
27 const flat = text.replace(/\s+/g, ' ').trim()
28 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
29}
30
31function basename(path: unknown): string {
32 return typeof path === 'string' ? path.split('/').filter(Boolean).pop() ?? path : 'a file'
33}
34
35function lines(value: unknown): number {
36 return typeof value === 'string' ? value.split('\n').length : 0
37}
38
39function countFrom(text: string | undefined, pattern: RegExp): number | undefined {
40 const match = text?.match(pattern)
41 return match ? Number(match[1]) : undefined
42}
43
44export function testCounts(text: string | undefined): { passed?: number; failed?: number } {
45 // jest/vitest/pytest say "42 passed", node --test and TAP say "pass 42"
46 const passed = countFrom(text, /(\d+)\s+(?:tests?\s+)?pass(?:ed|ing)?/i) ?? countFrom(text, /^\W*pass\s+(\d+)/im)
47 const failed = countFrom(text, /(\d+)\s+(?:tests?\s+)?fail(?:ed|ing|ures?)?/i) ?? countFrom(text, /^\W*fail\s+(\d+)/im)
48 return { passed, failed }
49}
50
51// what failing runs print even when `| tail` has cut the summary off
52const FAILURE_MARKS = /AssertionError|✖ failing tests|^not ok \d+|^\s*(FAIL|✕)\s|^\s*● .+ › |\bTests:\s+\d+ failed|=+ FAILURES =+|^FAILED /m
53
54export function hasFailures(text: string | undefined): boolean {
55 const { failed } = testCounts(text)
56 return failed !== undefined ? failed > 0 : FAILURE_MARKS.test(text ?? '')
57}
58
59export function testSummary(text: string | undefined): string {
60 const { passed, failed } = testCounts(text)
61 if (failed) return `${failed} failing`
62 if (failed === undefined && FAILURE_MARKS.test(text ?? '')) return 'failing'
63 if (passed) return `${passed} passing`
64 return ''
65}
66
67const SCORING = [DANGER, PUSH, TEST, BUILD, LINT, COMMIT, INSTALL]
68
69/**
70 * The command a commentator would name: the step of a pipeline or chain that
71 * matters (`npm test`, not `cd x && npm test 2>&1 | grep pass`), redirects gone.
72 */
73export function coreCommand(command: string): string {
74 const steps = command
75 .split(/\s*(?:&&|\|\||;|\|)\s*/)
76 .map(step => step.replace(/\s*\d?>>?&?\s*\S+/g, '').replace(/\s+/g, ' ').trim())
77 .filter(step => step !== '' && !/^cd\s/.test(step))
78 for (const pattern of SCORING) {
79 const step = steps.find(one => pattern.test(one))
80 if (step !== undefined) return step
81 }
82 return steps[0] ?? command.trim()
83}
84
85function ball(kind: Ball['kind'], runs: number, batter: string, detail: string, how?: string): Ball {
86 return how === undefined ? { kind, runs, batter, detail } : { kind, runs, batter, detail, how }
87}
88
89function scoreBash(d: Delivery): Ball {
90 const full = typeof d.input.command === 'string' ? d.input.command : ''
91 const command = coreCommand(full)
92 const detail = shorten(command, 40)
93 const isOk = d.outcome === 'ok'
94
95 if (d.outcome === 'interrupted') return ball('dot', 0, 'Bash', detail, 'rain stopped play')
96 if (DANGER.test(full)) {
97 return isOk ? ball('noball', 1, 'Bash', detail) : ball('wicket', 0, 'Bash', detail, 'hit wicket, dangerous shot')
98 }
99 if (PUSH.test(command)) {
100 // a pipe can hide git's exit code, its words cannot
101 const isRejected = /\[rejected\]|\[remote rejected\]|failed to push|fatal:/i.test(d.text ?? '')
102 return isOk && !isRejected
103 ? ball('six', 6, 'Bash', detail)
104 : ball('wicket', 0, 'Bash', detail, 'caught on the boundary, push rejected')
105 }
106 if (TEST.test(command) && !LOOK.test(command)) {
107 const summary = testSummary(d.text)
108 const named = summary ? `${detail} (${summary})` : detail
109 // a pipe into grep or tail exits 0 even when tests fail: trust the counts
110 const isGreen = isOk && !hasFailures(d.text)
111 return isGreen ? ball('six', 6, 'Bash', named) : ball('wicket', 0, 'Bash', named, 'bowled, tests failing')
112 }
113 if (BUILD.test(command)) {
114 const isBroken = /error TS\d+|npm ERR!|ELIFECYCLE|Build failed|Failed to compile/i.test(d.text ?? '')
115 return isOk && !isBroken
116 ? ball('four', 4, 'Bash', detail)
117 : ball('wicket', 0, 'Bash', detail, 'caught behind, build broken')
118 }
119 if (LINT.test(command)) {
120 return isOk ? ball('four', 4, 'Bash', detail) : ball('wicket', 0, 'Bash', detail, 'lbw, the linter appealed')
121 }
122 if (COMMIT.test(command)) {
123 return isOk ? ball('four', 4, 'Bash', detail) : ball('dot', 0, 'Bash', detail)
124 }
125 if (INSTALL.test(command)) {
126 return isOk ? ball('run', 2, 'Bash', detail) : ball('wicket', 0, 'Bash', detail, 'run out, install failed')
127 }
128 if (LOOK.test(command)) return ball('dot', 0, 'Bash', detail)
129 if (isOk) return ball('run', (d.text?.length ?? 0) > 2000 ? 2 : 1, 'Bash', detail)
130 if (NOT_FOUND.test(d.text ?? '')) return ball('wicket', 0, 'Bash', detail, 'stumped, command not found')
131 if (TIMEOUT.test(d.text ?? '')) return ball('wicket', 0, 'Bash', detail, 'retired hurt, timed out')
132
133 return ball('wicket', 0, 'Bash', detail, 'run out, command failed')
134}
135
136/** One tool call is one delivery: what it scored, who faced it, and how. */
137export function scoreDelivery(d: Delivery): Ball {
138 const tool = d.tool.startsWith('mcp__') ? d.tool.split('__')[1] ?? d.tool : d.tool
139
140 if (d.outcome === 'denied') return ball('wide', 1, tool, 'permission declined')
141 if (d.tool === 'Bash') return scoreBash(d)
142 if (d.outcome === 'interrupted') return ball('dot', 0, tool, 'interrupted')
143
144 if (d.tool === 'Write') {
145 const file = basename(d.input.file_path)
146 if (d.outcome !== 'ok') return ball('dot', 0, 'Write', file)
147 return lines(d.input.content) >= 80 ? ball('six', 6, 'Write', file) : ball('four', 4, 'Write', file)
148 }
149 if (d.tool === 'Edit' || d.tool === 'MultiEdit' || d.tool === 'NotebookEdit') {
150 const file = basename(d.input.file_path ?? d.input.notebook_path)
151 if (d.outcome !== 'ok') return ball('dot', 0, 'Edit', `${file}, played and missed`)
152 const size = lines(d.input.new_string) + lines(d.input.new_source)
153 const total = Array.isArray(d.input.edits) ? Math.max(size, d.input.edits.length * 4) : size
154 return total > 60 ? ball('four', 4, 'Edit', file) : ball('run', total > 20 ? 3 : total > 3 ? 2 : 1, 'Edit', file)
155 }
156 if (READERS.has(d.tool)) {
157 const target = d.input.file_path ?? d.input.pattern ?? d.input.path ?? ''
158 return ball('dot', 0, d.tool, typeof target === 'string' ? shorten(basename(target) || target, 30) : '')
159 }
160 if (SINGLES.has(d.tool)) {
161 const what = d.input.query ?? d.input.url ?? ''
162 return d.outcome === 'ok' ? ball('run', 1, d.tool, shorten(String(what), 34)) : ball('dot', 0, d.tool, '')
163 }
164 if (d.tool === 'Agent' || d.tool === 'Task') {
165 const what = d.input.description ?? d.input.subagent_type ?? 'a subagent'
166 return d.outcome === 'ok' ? ball('run', 2, 'Agent', shorten(String(what), 34)) : ball('dot', 0, 'Agent', '')
167 }
168
169 return d.outcome === 'ok' ? ball('run', 1, tool, '') : ball('dot', 0, tool, '')
170}
171
172export function isLegal(b: Ball): boolean {
173 return b.kind !== 'wide' && b.kind !== 'noball'
174}
175
176export function legalBalls(over: readonly Ball[]): number {
177 return over.filter(isLegal).length
178}
179
180export function oversText(balls: number): string {
181 return `${Math.floor(balls / 6)}.${balls % 6}`
182}
183
184export function runRate(runs: number, balls: number): string {
185 return balls === 0 ? '0.00' : ((runs * 6) / balls).toFixed(2)
186}
187
188export function newInnings(number: number, task: string, startedAt: number): Innings {
189 return {
190 number,
191 task,
192 runs: 0,
193 wickets: 0,
194 balls: 0,
195 extras: 0,
196 thisOver: [],
197 overs: [],
198 batters: {},
199 fallOfWickets: [],
200 milestones: [],
201 wicketStreak: 0,
202 isLive: true,
203 startedAt,
204 }
205}
206
207const MILESTONES = [50, 100, 150, 200, 250, 300, 400, 500]
208
209/** Adds one delivery to the innings and names the moments it made. */
210export function bowl(innings: Innings, b: Ball): { innings: Innings; moments: MomentKind[] } {
211 const moments: MomentKind[] = []
212 const overs = [...innings.overs]
213 let thisOver = innings.thisOver
214 if (legalBalls(thisOver) >= 6) {
215 overs.push(thisOver)
216 thisOver = []
217 }
218
219 const before = innings.runs
220 const legal = isLegal(b)
221 const old: Batter = innings.batters[b.batter] ?? { name: b.batter, runs: 0, balls: 0, fours: 0, sixes: 0 }
222 const batter: Batter = {
223 ...old,
224 runs: old.runs + (legal ? b.runs : 0),
225 balls: old.balls + (b.kind === 'wide' ? 0 : 1),
226 fours: old.fours + (b.kind === 'four' ? 1 : 0),
227 sixes: old.sixes + (b.kind === 'six' ? 1 : 0),
228 }
229 if (b.kind === 'wicket' && b.how !== undefined) batter.out = b.how
230
231 let wickets = innings.wickets + (b.kind === 'wicket' ? 1 : 0)
232 const runs = innings.runs + b.runs
233 const wicketStreak = b.kind === 'wicket' ? innings.wicketStreak + 1 : isLegal(b) ? 0 : innings.wicketStreak
234 const fallOfWickets =
235 b.kind === 'wicket'
236 ? [...innings.fallOfWickets, { score: runs, wicket: wickets, batter: b.batter, how: b.how ?? 'out', detail: b.detail }]
237 : innings.fallOfWickets
238
239 if (b.kind === 'six' || b.kind === 'four' || b.kind === 'wicket' || b.kind === 'noball' || b.kind === 'wide') {
240 moments.push(b.kind)
241 }
242 if (wicketStreak === 3) moments.push('hattrick')
243 if (wickets >= 10) {
244 moments.push('allout')
245 wickets = 0
246 }
247
248 const milestones = [...innings.milestones]
249 for (const mark of MILESTONES) {
250 if (before < mark && runs >= mark && !milestones.includes(mark)) {
251 milestones.push(mark)
252 moments.push(mark % 100 === 0 ? 'century' : 'fifty')
253 }
254 }
255
256 thisOver = [...thisOver, b]
257 const balls = innings.balls + (legal ? 1 : 0)
258 if (legal && legalBalls(thisOver) === 6 && thisOver.every(one => one.runs === 0 && one.kind !== 'wicket')) {
259 moments.push('maiden')
260 }
261
262 return {
263 innings: {
264 ...innings,
265 runs,
266 wickets,
267 balls,
268 extras: innings.extras + (legal ? 0 : b.runs),
269 thisOver,
270 overs,
271 batters: { ...innings.batters, [b.batter]: batter },
272 fallOfWickets,
273 milestones,
274 wicketStreak,
275 },
276 moments,
277 }
278}
279
280/** The batter with the most runs, then the best strike rate. */
281export function playerOfTheMatch(innings: Innings): Batter | undefined {
282 return Object.values(innings.batters)
283 .filter(one => one.runs > 0)
284 .sort((a, b) => b.runs - a.runs || b.runs / Math.max(1, b.balls) - a.runs / Math.max(1, a.balls))[0]
285}
286
287export function scoreText(innings: Innings): string {
288 return `${innings.runs}/${innings.wickets}`
289}
290types/index.d.ts 91 lines1export type BallKind = 'dot' | 'run' | 'four' | 'six' | 'wicket' | 'noball' | 'wide'
2
3export type Ball = {
4 kind: BallKind
5 runs: number
6 batter: string
7 detail: string
8 how?: string
9}
10
11export type Batter = {
12 name: string
13 runs: number
14 balls: number
15 fours: number
16 sixes: number
17 out?: string
18}
19
20export type FallOfWicket = { score: number; wicket: number; batter: string; how: string; detail: string }
21
22export type Innings = {
23 number: number
24 task: string
25 runs: number
26 wickets: number
27 balls: number
28 extras: number
29 thisOver: Ball[]
30 overs: Ball[][]
31 batters: Record<string, Batter>
32 fallOfWickets: FallOfWicket[]
33 milestones: number[]
34 wicketStreak: number
35 isLive: boolean
36 startedAt: number
37 endedAt?: number
38}
39
40export type Language = 'english' | 'hinglish'
41
42export type Settings = {
43 isVoiceOn: boolean
44 isCrowdOn: boolean
45 isHidden: boolean
46 isPaused: boolean
47 language: Language
48}
49
50export type MomentKind =
51 | 'start'
52 | 'six'
53 | 'four'
54 | 'wicket'
55 | 'noball'
56 | 'wide'
57 | 'fifty'
58 | 'century'
59 | 'hattrick'
60 | 'maiden'
61 | 'allout'
62 | 'end'
63
64export type Moment = { kind: MomentKind; headline: string; line: string; seq: number }
65
66export type CelebrationKind = 'six' | 'four' | 'wicket' | 'fifty' | 'century' | 'hattrick' | 'noball'
67
68export type Celebration = { kind: CelebrationKind; text: string; seq: number }
69
70export type Career = {
71 innings: number
72 runs: number
73 sixes: number
74 fours: number
75 wickets: number
76 fifties: number
77 centuries: number
78 best: number
79}
80
81declare module 'claude-code' {
82 interface PluginState {
83 'cricket-commentary': {
84 innings: Innings | null
85 moment: Moment | null
86 settings: Settings
87 celebration: Celebration | null
88 }
89 }
90}
91