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.

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.

% # %% ##%%## # %# %#%#
%%###%%#@%@@@@@#@#@@#@@@#%@@#@@@@
──────────────────────────────────────────────────────────
❯
──────────────────────────────────────────────────────────
🔥 warm 42° Opus 5.5
| Event | Heat |
|---|---|
| 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 compaction | Reset to 0 |
Heat runs from 0 to 100.
| Heat | Tier |
|---|---|
| 0–14 | ice 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 | What it does |
|---|---|
/heat or /heat status | Shows the heat, the tier, and whether heat is on |
/heat reset | Sets heat to 0 |
/heat off / /heat on | Turns heat off or on. The choice is kept across sessions. |
claude plugin marketplace add omeerdvrn/o-mods
claude plugin install heat@o-mods
claude --plugin-dir /path/to/o-mods/mods/heat
claude plugin validate .
claude plugin test .
| File | Contents |
|---|---|
hooks/register.tsx | Hooks: heat on prompts and tool calls, /heat, the footer label, and the fire band |
hooks/heat.ts | Bumps, decay and tiers |
hooks/fire.ts | The particle burst: heat to fire parameters, frames, and Raster cell encoding |
types/index.d.ts | The mod's state contract |
tests/heat.test.ts | Tests for the heat math, the fire, and the hooks |
hooks/register.tsx 223 lines1import { 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}
223hooks/fire.ts 205 lines1/**
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}
205hooks/flame.ts 275 lines1/**
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}
275hooks/heat.ts 51 lines1import 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}
51types/index.d.ts 31 lines1export 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