SLOPSHOPPER

heat

NBA-Jam-style heat tracker: prompts and tool calls heat you up, idle time cools you down, and a fire burst rises above the prompt as you get hot.

newbandspinnerguardcommandprompt
★ 3v1.0.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/heat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · heat
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /heat ⎿ heat: tracking: on ⎿ heat: heat: 17° ⎿ heat: tier: · lukewarm ⎿ heat: style: flame ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

heat

An NBA-Jam-style heat tracker for Claude Code. Each prompt heats you up, tool calls keep you warm, and idle time cools you down. Once you're hot, a fire burst rises above the prompt each time you send one.

Quick prompts heat the footer from ice cold to ON FIRE while a pixel fire grows above the prompt.

                   % #  %% ##%%## # %# %#%#
                 %%###%%#@%@@@@@#@#@@#@@@#%@@#@@@@
──────────────────────────────────────────────────────────
❯
──────────────────────────────────────────────────────────
                                    🔥 warm 42°   Opus 5.5

How heat works

EventHeat
A prompt+2, plus up to +6 for a longer prompt and up to +6 for sending it soon after the last
A tool call that succeeds+1
A tool call that fails−3
Idle time−1 every 30 seconds
A new session, or before compactionReset to 0

Heat runs from 0 to 100.

HeatTier
0–14ice cold
15–39· lukewarm
40–64🔥 warm
65–84🔥🔥 heating up
85–100🔥🔥🔥 ON FIRE! 🔥🔥🔥

From 15, each prompt lights a fire burst in the band above the prompt. The hotter you are, the wider, taller and longer the fire. The tier and heat show in the footer, next to the mode labels. In the terminal the fire is drawn in colored cells. In the Claude desktop app it is the same burst as an animated SVG.

Command

CommandWhat it does
/heat or /heat statusShows the heat, the tier, and whether heat is on
/heat resetSets heat to 0
/heat off / /heat onTurns heat off or on. The choice is kept across sessions.

Install

claude plugin marketplace add omeerdvrn/o-mods
claude plugin install heat@o-mods

Development

claude --plugin-dir /path/to/o-mods/mods/heat
claude plugin validate .
claude plugin test .
FileContents
hooks/register.tsxHooks: heat on prompts and tool calls, /heat, the footer label, and the fire band
hooks/heat.tsBumps, decay and tiers
hooks/fire.tsThe particle burst: heat to fire parameters, frames, and Raster cell encoding
types/index.d.tsThe mod's state contract
tests/heat.test.tsTests for the heat math, the fire, and the hooks
Source 5 files
hooks/register.tsx 223 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Burst, FireStyle, Heat } from '../types'
5import { encodeCells, fireConfig, fireRows, fireSvg, frame, particles } from './fire'
6import { flameDuration, flameFrame, flameSvg } from './flame'
7import { COLD, heatNow, promptBump, tierOf, toolBump } from './heat'
8
9const FRAME_MS = 33
10const CLOCK_MS = 30_000
11const STORE_KEY = 'isOn'
12const FIRE_KEY = 'fire'
13const STYLE_KEY = 'style'
14
15/** `flame`: the solid fire (flame.ts). `classic`: the original v1 particle burst (fire.ts), kept as it was. */
16type Style = FireStyle
17
18const heat = atom({ plugin: 'heat', key: 'heat' } as const, { heat: 0, at: 0 } as Heat)
19const burst = atom({ plugin: 'heat', key: 'burst' } as const, null as Burst | null)
20const style = atom({ plugin: 'heat', key: 'style' } as const, 'flame' as Style)
21const isOn = atom({ plugin: 'heat', key: 'isOn' } as const, true)
22const now = atom({ plugin: 'heat', key: 'now' } as const, 0)
23
24// The band's instance and size, as its last drawing laid it out; the animation repaints that Raster.
25let band: { requestId: string; columns: number; rows: number } | null = null
26let animation: { cancel: () => void } | null = null
27
28async function reset($: EngineInterface) {
29  const clock = await $.clock.now()
30  await update($, heat, () => ({ heat: 0, at: clock }))
31  await update($, now, () => clock)
32}
33
34/** Lights the burst for the current heat and repaints it every frame until it burns out. */
35/** How long a burst of `kind` lasts at this heat; null when it is too cold to light. */
36function durationOf(kind: Style, level: number): number | null {
37  const config = fireConfig(level)
38  if (config === null) return null
39  return kind === 'flame' ? flameDuration(level) : config.durationMs
40}
41
42async function ignite($: EngineInterface, level: number) {
43  const kind = await read($, style)
44  const duration = durationOf(kind, level)
45  if (duration === null) return
46  animation?.cancel()
47  const startedAt = await $.clock.now()
48  const seed = Math.floor(Math.random() * 0xffffffff)
49  await update($, burst, () => ({ heat: level, startedAt, seed, style: kind }))
50
51  const timer = $.clock.every(FRAME_MS, () => void paint($, startedAt, seed, level, kind))
52  animation = timer
53  $.clock.after(duration + FRAME_MS * 2, () => void burnOut($, timer, startedAt, duration))
54}
55
56const swarms = new Map<number, { columns: number; list: ReturnType<typeof particles> }>()
57
58function swarmFor(seed: number, level: number, columns: number) {
59  const known = swarms.get(seed)
60  if (known && known.columns === columns) return known.list
61  const config = fireConfig(level)
62  const list = config === null ? [] : particles(config, columns, seed)
63  swarms.clear()
64  swarms.set(seed, { columns, list })
65  return list
66}
67
68/** Stops the animation and clears the band once the burst's time has passed. */
69async function burnOut($: EngineInterface, timer: { cancel: () => void }, startedAt: number, durationMs: number) {
70  if ((await $.clock.now()) - startedAt < durationMs) return
71  timer.cancel()
72  if (animation === timer) animation = null
73  await update($, burst, current => (current?.startedAt === startedAt ? null : current))
74}
75
76/** The burst's cells at `elapsed`, in its style. */
77function cellsAt(kind: Style, seed: number, level: number, elapsed: number, columns: number, rows: number): string {
78  if (kind === 'flame') return encodeCells(flameFrame(seed, level, elapsed, columns, rows))
79  return encodeCells(frame(fireConfig(level)!, swarmFor(seed, level, columns), elapsed, columns, rows))
80}
81
82async function paint($: EngineInterface, startedAt: number, seed: number, level: number, kind: Style) {
83  if (band === null || fireConfig(level) === null) return
84  const elapsed = (await $.clock.now()) - startedAt
85  const { requestId, columns, rows } = band
86  const cells = cellsAt(kind, seed, level, elapsed, columns, rows)
87  await $.ui.blit({ requestId, key: FIRE_KEY, columns, rows, cells }).catch(() => undefined)
88}
89
90async function status($: EngineInterface) {
91  const clock = await $.clock.now()
92  const level = heatNow(await read($, heat), clock)
93  const on = await read($, isOn)
94  const kind = await read($, style)
95  return [`tracking: ${on ? 'on' : 'off'}`, `heat: ${level}°`, `tier: ${tierOf(level).label}`, `style: ${kind}`].join('\n')
96}
97
98export const register: Register = on => {
99  on('session.start', async ($, e, next) => {
100    // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
101    // Typed as /heat in the terminal; the command file is what the desktop app lists in its menu.
102    void $.command.register({
103      name: 'heat',
104      description: 'Heat tracker: status, reset, on, off, style',
105      argumentHint: '[status|reset|on|off|style flame|classic]',
106    }).catch(() => undefined)
107    const stored = await $.store.get(STORE_KEY)
108    await update($, isOn, () => stored !== false)
109    const storedStyle = await $.store.get(STYLE_KEY)
110    if (storedStyle === 'flame' || storedStyle === 'classic') await update($, style, () => storedStyle)
111    // Every session starts ice cold.
112    await reset($)
113    $.clock.every(CLOCK_MS, () => {
114      void $.clock.now().then(clock => update($, now, () => clock))
115    })
116
117    return next(e)
118  })
119
120  on('session.compact', async ($, e, next) => {
121    await reset($)
122
123    return next(e)
124  })
125
126  on('prompt.submit', async ($, e, next) => {
127    if (await read($, isOn)) {
128      const clock = await $.clock.now()
129      const bumped = promptBump(await read($, heat), e.text.length, clock)
130      await update($, heat, () => bumped)
131      await update($, now, () => clock)
132      if (bumped.heat >= COLD) void ignite($, bumped.heat)
133    }
134
135    return next(e)
136  })
137
138  on('tool.call', async ($, e, next) => {
139    const result = await next(e)
140    if (await read($, isOn)) {
141      const isError = 'deny' in result || ('isError' in result && result.isError === true)
142      const clock = await $.clock.now()
143      await update($, heat, state => toolBump(state, isError, clock))
144      await update($, now, () => clock)
145    }
146
147    return result
148  })
149
150  on('command.run', { command: ['heat', 'heat:heat'] }, async ($, e) => {
151    const verb = e.args.trim() || 'status'
152    if (verb === 'reset') {
153      await reset($)
154      return { text: 'Heat reset to 0°.' }
155    }
156    if (verb === 'on' || verb === 'off') {
157      await $.store.set(STORE_KEY, verb === 'on')
158      await update($, isOn, () => verb === 'on')
159      return { text: `heat is ${verb}.` }
160    }
161    if (verb === 'status') return { text: await status($) }
162    // `/heat style` flips between the two; `/heat style flame|classic` picks one. The choice is kept.
163    const [word, asked] = verb.split(/\s+/)
164    if (word === 'style') {
165      const current = await read($, style)
166      const chosen: Style = asked === 'flame' || asked === 'classic' ? asked : current === 'flame' ? 'classic' : 'flame'
167      await $.store.set(STYLE_KEY, chosen)
168      await update($, style, () => chosen)
169      return { text: chosen === 'flame' ? 'Fire style: flame.' : 'Fire style: classic (the original particle burst).' }
170    }
171
172    return { text: 'usage: /heat [status|reset|on|off|style flame|classic]' }
173  })
174
175  // The tier rides in the footer's mode labels, where other footers (usage-bar) already draw them.
176  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
177    if (!(await read($, isOn))) return next(e)
178    const level = heatNow(await read($, heat), (await read($, now)) || Date.now())
179    const label = `${tierOf(level).label} ${level}°`
180
181    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
182  })
183
184  // The fire: a burst of cells rising in the band above the prompt while it burns.
185  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186    const lit = await read($, burst)
187    if (lit === null || e.props.hasSurvey) return next(e)
188    const config = fireConfig(lit.heat)
189    const duration = durationOf(lit.style, lit.heat)
190    if (config === null || duration === null) return next(e)
191    // The desktop app draws vectors: the same burst as an animated SVG, played by the surface itself.
192    if (e.surface === 'desktop') {
193      const { Svg } = $.ui.resolve(e)
194      const elapsed = Math.max(0, (await $.clock.now()) - lit.startedAt)
195      if (elapsed >= duration) return next(e)
196      const width = Math.max(10, e.props.bodyColumns)
197      const rows = fireRows(lit.heat, e.props.maxRows)
198      const source = lit.style === 'flame' ? flameSvg(lit.heat, lit.seed, width, rows * 2) : fireSvg(config, lit.seed, width, rows * 2)
199      return (
200        <Svg
201          key={`fire-${lit.seed}`}
202          source={source}
203          height={rows * 18}
204          alt="fire"
205          isInteractive
206        />
207      )
208    }
209    // Raster, a grid of colored cells, is the terminal's alone.
210    if (e.surface !== 'terminal') return next(e)
211    const { Raster } = $.ui.resolve(e)
212
213    const columns = Math.max(10, e.props.bodyColumns)
214    const rows = fireRows(lit.heat, e.props.maxRows)
215    band = { requestId: e.requestId, columns, rows }
216    const elapsed = Math.max(0, (await $.clock.now()) - lit.startedAt)
217    if (elapsed >= duration) return next(e)
218    const cells = cellsAt(lit.style, lit.seed, lit.heat, elapsed, columns, rows)
219
220    return <Raster key={FIRE_KEY} columns={columns} rows={rows} cells={cells} />
221  })
222}
223
hooks/fire.ts 205 lines
1/**
2 * The v1 heat-scaled particle burst, ported from heat-fire.py: a flame-shaped fan of particles rising
3 * from the bottom centre, aging from white-hot through orange and red to ember while their glyphs thin
4 * out. Drawn into a band of cells instead of straight onto the terminal.
5 */
6import { COLD } from './heat'
7
8const ASPECT_Y = 0.5
9/** Flame palette: white-hot core, yellow, orange, red, ember. */
10const RAMP = [0xffffdc, 0xffe682, 0xffb43c, 0xff8219, 0xeb500f, 0xb4280a, 0x6e1908, 0x370f05]
11const GLYPHS = ['@', '#', '%', '*', 'o', '+', '.', ' ']
12const DEFAULT_COLOR = 0x01000000
13const SPACE = 0x20
14/** heat-fire.py lit the flames under a full-height terminal; this is the height its speeds were tuned for. */
15const SCREEN_ROWS = 40
16
17export type FireConfig = {
18  count: number
19  velocityMin: number
20  velocityMax: number
21  spreadDegrees: number
22  gravity: number
23  spawnShare: number
24  edgeVelocityScale: number
25  turbulenceAmp: number
26  turbulenceFreq: number
27  durationMs: number
28}
29
30/** Heat to particle parameters, as heat-fire.py maps them; null below the cold threshold. */
31export function fireConfig(heat: number): FireConfig | null {
32  if (heat < COLD) return null
33  const t = Math.max(0, Math.min(1, (heat - COLD) / (100 - COLD)))
34  return {
35    count: Math.round(60 + t * 840),
36    velocityMin: 4 + t * 10,
37    velocityMax: 10 + t * 50,
38    spreadDegrees: 28,
39    gravity: 10 - t * 8,
40    // Spawn width: a narrow ember at low heat, the full band at max.
41    spawnShare: 0.2 + 0.8 * t,
42    edgeVelocityScale: 0.5 - t * 0.32,
43    turbulenceAmp: 1 + t * 1.4,
44    turbulenceFreq: 6,
45    durationMs: Math.round(280 + t * 1020),
46  }
47}
48
49/** The band's height for a heat: a few rows of ember up to a tall blaze. */
50export function fireRows(heat: number, maxRows: number): number {
51  const t = Math.max(0, Math.min(1, (heat - COLD) / (100 - COLD)))
52  return Math.max(3, Math.min(maxRows, Math.round(4 + t * 10)))
53}
54
55type Particle = { vx: number; vy: number; x0: number; y0: number; spawn: number; phase: number }
56
57/** A small seeded generator, so every frame of one burst replays the same particles. */
58function random(seed: number): () => number {
59  let state = seed >>> 0 || 1
60  return () => {
61    state ^= state << 13
62    state ^= state >>> 17
63    state ^= state << 5
64    return (state >>> 0) / 0x100000000
65  }
66}
67
68export function particles(config: FireConfig, columns: number, seed: number): Particle[] {
69  const next = random(seed)
70  const between = (low: number, high: number) => low + next() * (high - low)
71  const spread = (config.spreadDegrees * Math.PI) / 180
72  const duration = config.durationMs / 1000
73  const radius = Math.max(2, (columns / 2 - 2) * config.spawnShare)
74
75  return Array.from({ length: config.count }, () => {
76    const speed = between(config.velocityMin, config.velocityMax)
77    const angle = -Math.PI / 2 + between(-spread / 2, spread / 2)
78    const x0 = between(-radius, radius)
79    const scale = 1 - (Math.abs(x0) / radius) * (1 - config.edgeVelocityScale)
80    return {
81      vx: speed * Math.cos(angle) * scale,
82      vy: speed * Math.sin(angle) * ASPECT_Y * scale,
83      x0,
84      y0: between(-0.5, 0.5),
85      spawn: between(0, duration * 0.4),
86      phase: between(0, 2 * Math.PI),
87    }
88  })
89}
90
91/** One frame at `elapsedMs`, as Raster cells: little-endian u32 triplets [codePoint, foreground, background]. */
92export function frame(
93  config: FireConfig,
94  burst: readonly Particle[],
95  elapsedMs: number,
96  columns: number,
97  rows: number,
98): Uint32Array {
99  const words = new Uint32Array(columns * rows * 3)
100  for (let cell = 0; cell < columns * rows; cell++) {
101    words[cell * 3] = SPACE
102    words[cell * 3 + 1] = DEFAULT_COLOR
103    words[cell * 3 + 2] = DEFAULT_COLOR
104  }
105  const duration = config.durationMs / 1000
106  const elapsed = elapsedMs / 1000
107  // Speeds were tuned for a full screen; squeeze the rise into the band's height.
108  const lift = rows / SCREEN_ROWS
109  const gravity = config.gravity * ASPECT_Y
110  const bottom = rows - 1
111  const centre = Math.floor(columns / 2)
112
113  for (const particle of burst) {
114    const age = elapsed - particle.spawn
115    if (age < 0) continue
116    const share = age / duration
117    if (share >= 1) continue
118    const sway = Math.sin(age * config.turbulenceFreq + particle.phase) * config.turbulenceAmp
119    const x = particle.x0 + particle.vx * age + sway
120    const y = (particle.y0 + particle.vy * age + 0.5 * gravity * age * age) * lift * 2.5
121    const column = centre + Math.round(x)
122    const row = bottom + Math.round(y)
123    if (row < 0 || row >= rows || column < 0 || column >= columns) continue
124    const glyph = GLYPHS[Math.min(GLYPHS.length - 1, Math.floor(share * GLYPHS.length))]!
125    if (glyph === ' ') continue
126    const at = (row * columns + column) * 3
127    words[at] = glyph.charCodeAt(0)
128    words[at + 1] = RAMP[Math.min(RAMP.length - 1, Math.floor(share * RAMP.length))]!
129  }
130
131  return words
132}
133
134const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
135
136/** Standard padded base64 of the words' little-endian bytes, as RasterProps' `cells` takes. */
137export function encodeCells(words: Uint32Array): string {
138  const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
139  let out = ''
140  for (let index = 0; index < bytes.length; index += 3) {
141    const a = bytes[index]!
142    const b = bytes[index + 1]
143    const c = bytes[index + 2]
144    out += ALPHABET[a >> 2]
145    out += ALPHABET[((a & 3) << 4) | ((b ?? 0) >> 4)]
146    out += b === undefined ? '=' : ALPHABET[((b & 15) << 2) | ((c ?? 0) >> 6)]
147    out += c === undefined ? '=' : ALPHABET[c & 63]
148  }
149  return out
150}
151
152/** The burst's colors as CSS, white-hot first. */
153const RAMP_CSS = RAMP.map(color => `#${color.toString(16).padStart(6, '0')}`)
154/** An SVG burst stays light: at most this many particles. */
155const SVG_PARTICLES = 120
156
157/**
158 * The same burst as an animated SVG, for the desktop app, which draws vectors but no cells: each particle
159 * is a small square that rises along the burst's path, sways, and cools from white-hot to ember with SMIL,
160 * so the surface plays it at its own frame rate. `width` and `height` are the drawing's units.
161 */
162export function fireSvg(config: FireConfig, seed: number, width: number, height: number): string {
163  const all = particles(config, width, seed)
164  const step = Math.max(1, Math.ceil(all.length / SVG_PARTICLES))
165  const burst = all.filter((_, index) => index % step === 0)
166  const duration = config.durationMs / 1000
167  const lift = (height / SCREEN_ROWS) * 2.5
168  const gravity = config.gravity * ASPECT_Y
169  const centre = width / 2
170  const bottom = height - 1
171  const keys = [0, 0.25, 0.5, 0.75, 1]
172  const keyTimes = keys.join(';')
173
174  const squares = burst.map(particle => {
175    const life = duration - particle.spawn
176    if (life <= 0) return ''
177    const at = (share: number) => {
178      const age = share * life
179      const sway = Math.sin(age * config.turbulenceFreq + particle.phase) * config.turbulenceAmp
180      return {
181        x: (centre + particle.x0 + particle.vx * age + sway).toFixed(1),
182        y: (bottom + (particle.y0 + particle.vy * age + 0.5 * gravity * age * age) * lift).toFixed(1),
183      }
184    }
185    const path = keys.map(at)
186    const colors = keys.map(share => RAMP_CSS[Math.min(RAMP_CSS.length - 1, Math.floor(((share * life + particle.spawn) / duration) * RAMP_CSS.length))])
187    const begin = `${particle.spawn.toFixed(2)}s`
188    const dur = `${life.toFixed(2)}s`
189    const animate = (attribute: string, values: string) =>
190      `<animate attributeName="${attribute}" values="${values}" keyTimes="${keyTimes}" begin="${begin}" dur="${dur}" fill="freeze"/>`
191
192    return (
193      `<rect width="1" height="1" opacity="0" fill="${RAMP_CSS[0]}">` +
194      `<set attributeName="opacity" to="1" begin="${begin}"/>` +
195      animate('x', path.map(point => point.x).join(';')) +
196      animate('y', path.map(point => point.y).join(';')) +
197      animate('fill', colors.join(';')) +
198      animate('opacity', '1;1;0.85;0.5;0') +
199      '</rect>'
200    )
201  })
202
203  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">${squares.join('')}</svg>`
204}
205
hooks/flame.ts 275 lines
1/**
2 * The "flame" style: a solid fire instead of the classic particle burst (fire.ts).
3 *
4 * In the terminal, a heat field rises and cools a row at a time (the classic demo-scene fire), drawn with
5 * half blocks, so every cell holds two pixels: a solid body from white-hot to deep red that flickers and
6 * burns out. On the desktop, flame tongues in an animated SVG, softened with blur, with sparks rising.
7 */
8import { COLD } from './heat'
9
10const DEFAULT_COLOR = 0x01000000
11const SPACE = 0x20
12const UPPER_HALF = 0x2580
13const LOWER_HALF = 0x2584
14/** Field values at or below this draw nothing, so the fire fades into the terminal's own background. */
15const CUTOFF = 0.07
16const STEP_MS = 16
17
18/** Heat 0 to 1 over the range that lights a fire. */
19function intensity(heat: number): number {
20  return Math.max(0, Math.min(1, (heat - COLD) / (100 - COLD)))
21}
22
23/** How long a flame burst lasts: longer than the classic one, so it can build, roar and die down. */
24export function flameDuration(heat: number): number {
25  return Math.round(900 + intensity(heat) * 1600)
26}
27
28/** A small seeded generator, so every frame of one burst replays the same fire. */
29function random(seed: number): () => number {
30  let state = seed >>> 0 || 1
31  return () => {
32    state ^= state << 13
33    state ^= state >>> 17
34    state ^= state << 5
35    return (state >>> 0) / 0x100000000
36  }
37}
38
39/** Field value 0 to 1 to color: ember red through orange and yellow to white-hot. */
40const STOPS: [number, number][] = [
41  [0, 0x2a0603],
42  [0.12, 0x5c0d04],
43  [0.25, 0x9a1c06],
44  [0.4, 0xd63a0a],
45  [0.55, 0xf26914],
46  [0.7, 0xffa128],
47  [0.84, 0xffd257],
48  [1, 0xfff6d6],
49]
50const PALETTE = Array.from({ length: 64 }, (_, index) => {
51  const value = index / 63
52  const upper = STOPS.findIndex(([at]) => at >= value)
53  if (upper <= 0) return STOPS[0]![1]
54  const [a, from] = STOPS[upper - 1]!
55  const [b, to] = STOPS[upper]!
56  const mix = (value - a) / (b - a)
57  const channel = (shift: number) =>
58    Math.round(((from >> shift) & 255) + (((to >> shift) & 255) - ((from >> shift) & 255)) * mix)
59  return (channel(16) << 16) | (channel(8) << 8) | channel(0)
60})
61
62function colorOf(value: number): number {
63  return PALETTE[Math.max(0, Math.min(63, Math.round(value * 63)))]!
64}
65
66type Spark = { x0: number; spawn: number; speed: number; life: number; phase: number }
67
68type Fire = {
69  key: string
70  width: number
71  height: number
72  field: Float32Array
73  steps: number
74  phase: number
75  next: () => number
76  sparks: Spark[]
77}
78
79let fire: Fire | null = null
80
81/** Where the burst's source burns at `share` of its life: kindles, holds, then is starved and dies down. */
82function envelope(share: number): number {
83  if (share < 0.08) return share / 0.08
84  if (share < 0.5) return 1
85  if (share < 0.7) return 1 - (share - 0.5) / 0.2
86  return 0
87}
88
89function kindle(seed: number, heat: number, width: number, height: number, key: string): Fire {
90  const t = intensity(heat)
91  const next = random(seed)
92  const duration = flameDuration(heat) / 1000
93  const half = Math.max(2, (width * (0.2 + 0.8 * t)) / 2)
94  const sparks = Array.from({ length: Math.round(3 + t * 22) }, () => ({
95    x0: width / 2 + (next() * 2 - 1) * half * 0.8,
96    spawn: next() * duration * 0.6,
97    speed: height * (1.2 + next() * 1.4),
98    life: 0.35 + next() * 0.5,
99    phase: next() * Math.PI * 2,
100  }))
101  return { key, width, height, field: new Float32Array(width * height), steps: 0, phase: next() * Math.PI * 2, next, sparks }
102}
103
104/** One step: the source row burns, and every row takes the row below it, shifted a little and cooled. */
105function step(state: Fire, heat: number, share: number) {
106  const { width, height, field, next } = state
107  const t = intensity(heat)
108  const half = Math.max(2, (width * (0.2 + 0.8 * t)) / 2)
109  const centre = width / 2
110  const burn = envelope(share)
111  const bottom = (height - 1) * width
112  // Hot spots wander along the source, so the fire rises in tongues instead of one even wall.
113  const time = state.steps * (STEP_MS / 1000)
114  for (let x = 0; x < width; x++) {
115    const reach = 1 - ((x + 0.5 - centre) / half) ** 2
116    const spots = 0.5 + 0.5 * Math.sin(x * 0.55 + time * 6 + state.phase) * Math.sin(x * 0.21 - time * 3.7)
117    field[bottom + x] = reach <= 0 ? 0 : burn * Math.min(1, reach * 1.8) * (0.45 + 0.55 * spots) * (0.85 + 0.15 * next())
118  }
119  // A hotter fire cools slower, so it climbs higher before it fades; tongues lean with a slow wind.
120  const cooling = 1 / ((0.45 + 0.5 * t) * height)
121  const wind = Math.sin(time * 2.3 + state.phase) * 0.35
122  for (let y = 1; y < height; y++) {
123    for (let x = 0; x < width; x++) {
124      const source = field[y * width + x]!
125      const roll = next() + wind
126      const shift = roll < 0.2 ? -1 : roll > 0.8 ? 1 : 0
127      const drift = Math.min(width - 1, Math.max(0, x + shift))
128      // Hot cores carry further; thin edges cool off fast and break into licks.
129      const loss = next() * 2 * cooling * (source < 0.35 ? 1.6 : 1)
130      field[(y - 1) * width + drift] = Math.max(0, source - loss)
131    }
132  }
133  state.steps += 1
134}
135
136/** Pixel color at (x, y), or null where the fire leaves the background showing. */
137function pixel(state: Fire, x: number, y: number, sparks: Map<number, number>): number | null {
138  const value = state.field[y * state.width + x]!
139  if (value > CUTOFF) return colorOf(value)
140  return sparks.get(y * state.width + x) ?? null
141}
142
143/** One frame of the flame at `elapsedMs`, as Raster cells: little-endian u32 triplets [codePoint, fg, bg]. */
144export function flameFrame(seed: number, heat: number, elapsedMs: number, columns: number, rows: number): Uint32Array {
145  const width = columns
146  const height = rows * 2
147  const key = `${seed}:${heat}:${width}x${height}`
148  const target = Math.floor(elapsedMs / STEP_MS)
149  if (fire === null || fire.key !== key || fire.steps > target) fire = kindle(seed, heat, width, height, key)
150  const duration = flameDuration(heat)
151  while (fire.steps < target) step(fire, heat, (fire.steps * STEP_MS) / duration)
152
153  // Sparks: single bright pixels flying up out of the flame, cooling as they go.
154  const lit = new Map<number, number>()
155  const age = elapsedMs / 1000
156  for (const spark of fire.sparks) {
157    const own = age - spark.spawn
158    if (own < 0 || own > spark.life) continue
159    const y = Math.round(height - 1 - spark.speed * own)
160    const x = Math.round(spark.x0 + Math.sin(own * 9 + spark.phase) * 1.5)
161    if (y < 0 || y >= height || x < 0 || x >= width) continue
162    lit.set(y * width + x, colorOf(0.95 - (own / spark.life) * 0.5))
163  }
164
165  const words = new Uint32Array(columns * rows * 3)
166  for (let row = 0; row < rows; row++) {
167    for (let column = 0; column < columns; column++) {
168      const at = (row * columns + column) * 3
169      const top = pixel(fire, column, row * 2, lit)
170      const bottom = pixel(fire, column, row * 2 + 1, lit)
171      if (top === null && bottom === null) {
172        words[at] = SPACE
173        words[at + 1] = DEFAULT_COLOR
174        words[at + 2] = DEFAULT_COLOR
175      } else if (top === null) {
176        words[at] = LOWER_HALF
177        words[at + 1] = bottom!
178        words[at + 2] = DEFAULT_COLOR
179      } else {
180        words[at] = UPPER_HALF
181        words[at + 1] = top
182        words[at + 2] = bottom ?? DEFAULT_COLOR
183      }
184    }
185  }
186  return words
187}
188
189const n = (value: number) => value.toFixed(2)
190
191/**
192 * The flame as an animated SVG for the desktop app: a glow, flame tongues and a white-hot core, each tongue
193 * flickering and swaying on its own clock, the whole fire kindling, roaring and dying down over the burst,
194 * with sparks rising out of it. `width` and `height` are the drawing's units (a cell is about 1 by 2).
195 */
196export function flameSvg(heat: number, seed: number, width: number, height: number): string {
197  const t = intensity(heat)
198  const next = random(seed)
199  const between = (low: number, high: number) => low + next() * (high - low)
200  const duration = flameDuration(heat) / 1000
201  const id = `f${seed.toString(36)}`
202  const half = Math.max(3, (width * (0.2 + 0.8 * t)) / 2)
203  const tall = height * (0.55 + 0.4 * t)
204  const count = Math.round(5 + t * 15)
205  const spacing = (half * 2) / count
206
207  const tongue = (fill: string, widthScale: number, heightScale: number) => {
208    const parts: string[] = []
209    for (let index = 0; index < count; index++) {
210      const x = -half + spacing * (index + 0.5) + between(-0.3, 0.3) * spacing
211      const centred = 1 - Math.abs(x) / half
212      const h = tall * heightScale * (0.45 + 0.55 * centred) * between(0.8, 1.05)
213      const w = spacing * 1.25 * widthScale * between(0.85, 1.15)
214      const d =
215        `M${n(-w)} 0C${n(-w)} ${n(-h * 0.45)} ${n(-w * 0.35)} ${n(-h * 0.7)} 0 ${n(-h)}` +
216        `C${n(w * 0.35)} ${n(-h * 0.7)} ${n(w)} ${n(-h * 0.45)} ${n(w)} 0Z`
217      const sway = between(4, 12)
218      parts.push(
219        `<g transform="translate(${n(x)} 0)"><g>` +
220          `<animateTransform attributeName="transform" type="skewX" values="0;${n(sway)};${n(-sway * 0.7)};0" dur="${n(between(0.5, 0.9))}s" repeatCount="indefinite"/>` +
221          `<path d="${d}" fill="url(#${id}${fill})">` +
222          `<animateTransform attributeName="transform" type="scale" values="1 1;0.9 1.12;1.06 0.88;1 1" dur="${n(between(0.22, 0.42))}s" repeatCount="indefinite"/>` +
223          `</path></g></g>`,
224      )
225    }
226    return parts.join('')
227  }
228
229  const sparks = Array.from({ length: Math.round(4 + t * 26) }, () => {
230    const x = between(-half * 0.8, half * 0.8)
231    const rise = height * between(0.7, 1.15)
232    const begin = `${n(between(0, duration * 0.6))}s`
233    const dur = `${n(between(0.45, 0.9))}s`
234    const drift = between(-2, 2)
235    return (
236      `<circle cx="${n(x)}" cy="0" r="${n(between(0.25, 0.45))}" fill="#fff1a8" opacity="0">` +
237      `<animate attributeName="cy" values="0;${n(-rise)}" begin="${begin}" dur="${dur}" fill="freeze"/>` +
238      `<animate attributeName="cx" values="${n(x)};${n(x + drift)};${n(x - drift * 0.5)}" begin="${begin}" dur="${dur}" fill="freeze"/>` +
239      `<animate attributeName="fill" values="#fff1a8;#ffb43c;#ff5a14" begin="${begin}" dur="${dur}" fill="freeze"/>` +
240      `<animate attributeName="opacity" values="0;1;0.8;0" keyTimes="0;0.08;0.6;1" begin="${begin}" dur="${dur}" fill="freeze"/>` +
241      `</circle>`
242    )
243  })
244
245  const life = `dur="${n(duration)}s" fill="freeze" keyTimes="0;0.12;0.5;0.72;1"`
246  const defs =
247    `<defs>` +
248    `<linearGradient id="${id}b" x1="0" y1="1" x2="0" y2="0">` +
249    `<stop offset="0" stop-color="#fff3c4"/><stop offset="0.25" stop-color="#ffc23a"/>` +
250    `<stop offset="0.55" stop-color="#ff7a1a"/><stop offset="0.8" stop-color="#e0400c" stop-opacity="0.85"/>` +
251    `<stop offset="1" stop-color="#a01e06" stop-opacity="0"/></linearGradient>` +
252    `<linearGradient id="${id}c" x1="0" y1="1" x2="0" y2="0">` +
253    `<stop offset="0" stop-color="#ffffff"/><stop offset="0.45" stop-color="#fff1a8"/>` +
254    `<stop offset="1" stop-color="#ffd04a" stop-opacity="0"/></linearGradient>` +
255    `<linearGradient id="${id}g" x1="0" y1="1" x2="0" y2="0">` +
256    `<stop offset="0" stop-color="#ff9a2a" stop-opacity="0.8"/><stop offset="1" stop-color="#ff3d0a" stop-opacity="0"/></linearGradient>` +
257    `<filter id="${id}s" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="0.35"/></filter>` +
258    `<filter id="${id}w" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.6"/></filter>` +
259    `</defs>`
260
261  return (
262    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMax meet" overflow="hidden">${defs}` +
263    `<g transform="translate(${n(width / 2)} ${height})" opacity="0">` +
264    `<animate attributeName="opacity" values="0;1;1;0.8;0" ${life}/>` +
265    `<g transform="scale(1 0)">` +
266    `<animateTransform attributeName="transform" type="scale" values="1 0;1 1.08;1 1;1 0.55;1 0" ${life}/>` +
267    `<g filter="url(#${id}w)" opacity="0.7">${tongue('g', 1.4, 1.1)}</g>` +
268    `<g filter="url(#${id}s)">${tongue('b', 1, 1)}</g>` +
269    `<g filter="url(#${id}s)">${tongue('c', 0.5, 0.55)}</g>` +
270    `</g>` +
271    sparks.join('') +
272    `</g></svg>`
273  )
274}
275
hooks/heat.ts 51 lines
1import type { Heat } from '../types'
2
3export const MAX_HEAT = 100
4/** Milliseconds of idle time per point of heat lost. */
5export const DECAY_MS = 30_000
6const BASE_BUMP = 2
7const TOOL_BUMP = 1
8const TOOL_PENALTY = 3
9/** Below this heat no fire is lit. */
10export const COLD = 15
11
12/** Heat after idle decay: one point per 30 seconds since it was last written. */
13export function heatNow(state: Heat, now: number): number {
14  const lost = Math.floor(Math.max(0, now - state.at) / DECAY_MS)
15  return Math.max(0, state.heat - lost)
16}
17
18function clamp(heat: number): number {
19  return Math.max(0, Math.min(MAX_HEAT, heat))
20}
21
22/**
23 * A prompt's bump: a base, a bonus for a longer prompt, and a bonus for sending it soon after the last
24 * write, so rapid-fire prompting heats up fastest (NBA Jam's consecutive baskets).
25 */
26export function promptBump(state: Heat, promptLength: number, now: number): Heat {
27  const elapsed = Math.max(0, now - state.at) / 1000
28  const length =
29    promptLength >= 1000 ? 6 : promptLength >= 200 ? 5 : promptLength >= 50 ? 4 : promptLength >= 10 ? 2 : 0
30  const pace = elapsed < 10 ? 6 : elapsed < 30 ? 3 : elapsed < 60 ? 1 : 0
31
32  return { heat: clamp(heatNow(state, now) + BASE_BUMP + length + pace), at: now }
33}
34
35/** A tool call: success warms by one, an error cools by three. */
36export function toolBump(state: Heat, isError: boolean, now: number): Heat {
37  return { heat: clamp(heatNow(state, now) + (isError ? -TOOL_PENALTY : TOOL_BUMP)), at: now }
38}
39
40export const TIERS = [
41  { min: 85, label: "🔥🔥🔥 ON FIRE! 🔥🔥🔥", color: '#FF4D2E', isBold: true },
42  { min: 65, label: '🔥🔥 heating up', color: '#FF7A2E', isBold: true },
43  { min: 40, label: '🔥 warm', color: '#FFB43C', isBold: false },
44  { min: 15, label: '· lukewarm', color: '#C9A227', isBold: false },
45  { min: 0, label: 'ice cold', color: '#5C8DB8', isBold: false },
46] as const
47
48export function tierOf(heat: number) {
49  return TIERS.find(tier => heat >= tier.min) ?? TIERS[TIERS.length - 1]!
50}
51
types/index.d.ts 31 lines
1export type Heat = {
2  /** 0 to 100, as last written; read it through the decay with `heatNow`. */
3  heat: number
4  /** Milliseconds since the epoch when `heat` was written. */
5  at: number
6}
7
8export type Burst = {
9  /** Heat the burst was lit at. */
10  heat: number
11  startedAt: number
12  seed: number
13  /** The style it was lit in; a burst keeps it even if /heat style changes mid-burn. */
14  style: FireStyle
15}
16
17/** `flame`: the solid fire. `classic`: the original v1 particle burst. */
18export type FireStyle = 'flame' | 'classic'
19
20declare module 'claude-code' {
21  interface PluginState {
22    'heat': {
23      heat: Heat
24      burst: Burst | null
25      isOn: boolean
26      now: number
27      style: FireStyle
28    }
29  }
30}
31